From 49df948de25d28601469e9ca993f33530989ebd0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 22:55:49 +0100 Subject: v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migrations; require 80x24 --- AGENTS.md | 8 + Cargo.toml | 2 +- docs/GAMES.md | 43 ++++-- docs/SERVER_SETUP.md | 5 +- docs/TESTING.md | 37 +++-- src/bin/client/connection.rs | 2 +- src/bin/client/main.rs | 15 ++ src/bin/client/repl.rs | 2 + src/client/core.rs | 102 +++---------- src/client/database.rs | 41 ----- src/client/games/manager.rs | 17 ++- src/client/games/roulette.rs | 346 +++++++++++++++++++++++++++++++++++-------- src/client/ui.rs | 17 +++ src/roulette.rs | 215 +++++++++++++++++++++------ src/server/database.rs | 6 - src/server/games/roulette.rs | 2 +- tests/client_core.rs | 86 ++++++----- tests/client_ui.rs | 52 ++++++- tests/roulette.rs | 334 +++++++++++++++++++++++++++++++---------- tests/server_core.rs | 10 +- tests/storage.rs | 49 ------ 21 files changed, 957 insertions(+), 434 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a70ba2..8de7a2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,14 @@ count lines against the same string the cursor will erase. Prefer returning a fixed known count for the game's static layout and letting dynamic content fill it, rather than inferring the count. +- The client refuses to start below 80x24 (`terminal_too_small`). Every game + board must fit in that size; a new board wider or taller than the minimum + must raise the minimum in the same change. + +## Schema +- The app is in alpha: do not write schema migrations. `init_db` creates the + current shape; a schema change means deleting `server.db` and `client.db`. + Files written by an older build are not supported. ## Error Paths - When a game action fails after a balance deduction, the refund must happen diff --git a/Cargo.toml b/Cargo.toml index e0e17a7..69df089 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "2.4.0" +version = "3.0.0" edition = "2024" [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 ## Roulette - European wheel: 0-36, one green zero. -- Bets: red, black, even, odd, and straight numbers. -- Bets can be split across all of those; every winning bet is added together. -- Straight pays 35:1 (36x returned), outside bets pay 1:1 (2x returned). -- 0 loses all outside bets. RTP is 36/37 = 97.30%. -- Numbers are color-coded in the TUI: red numbers are red, 0 is green, and - black numbers stay at the default terminal color. Bet amounts and balances - are not colored. +- The felt is drawn in full and bet with a cursor. Over the numbers the cursor + sits on a *lattice point*, the way a chip sits between numbers on a real + felt: an inner point covers four numbers (corner), an edge point covers two + (split), and an outer corner is a straight. +- Bet types and returns (per unit staked, stake included): + + | Bet | Covers | Return | + | --- | --- | --- | + | Straight | one number | 36x | + | Split | two adjacent numbers | 18x | + | Corner | four numbers | 9x | + | Dozen | 1-12, 13-24, 25-36 | 3x | + | Column | 1,4,...,34 / 2,5,...,35 / 3,6,...,36 | 3x | + | Outside | 1-18, 19-36, EVEN, ODD, RED, BLACK | 2x | + +- 0 loses every outside bet, both dozens and every column. It pays a straight, + and split/corner bets containing 0 are not offered. +- Every chip is settled independently and added together. RTP is 36/37 = + 97.30% because every bet uses the same European odds. +- Bet amounts and balances are not colored in the TUI. Client controls inside roulette: ``` -R / B / E / O add the current chip to red/black/even/odd -digits + Enter add the current chip to that number +arrows move the cursor over the felt, including between numbers +ENTER place the current chip on the cursor C clear all bets + or = / - change the chip size SPACE spin @@ -125,7 +138,7 @@ plays ( {"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"} // roulette -{"number":"17","color":"black","bets":"{\"red\":10,\"black\":10,\"even\":10,\"odd\":10,\"numbers\":[{\"number\":17,\"amount\":5}]}"} +{"number":"17","color":"black","bets":"{\"chips\":[{\"target\":{\"bet\":\"outside\",\"kind\":\"red\"},\"amount\":10}]}"} // blackjack {"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 GAME_RESULT ``` -Roulette adds a compact bets payload and a roulette-specific result: +Roulette sends the chips and gets a roulette-specific result: ``` PLAY_START roulette ROULETTE_RESULT ``` +`bets-json` is `{"chips":[{"target":{...},"amount":N}]}`. A target is one of +`{"bet":"straight","number":17}`, `{"bet":"split","a":1,"b":2}`, +`{"bet":"corner","numbers":[1,2,4,5]}`, `{"bet":"dozen","index":1}`, +`{"bet":"column","index":0}` or +`{"bet":"outside","kind":"red"}` (`low`, `high`, `even`, `odd`, `red`, +`black`). The server rejects unknown, out-of-range or duplicate-number +targets, and requires the total to equal the sum of the chips. + Blackjack is a multi-step session: ``` 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. The server creates `server.db` in the current directory on first use. It holds users, balances, and the per-game `plays` audit. The client keeps its own -`client.db` for host fingerprints and stored credentials. Databases from before -the fingerprint-keyed credentials are migrated on startup; rows whose host was -already forgotten cannot be re-keyed and require one fresh login. +`client.db` for host fingerprints and stored credentials. There are no schema +migrations while the app is in alpha: a schema change means deleting both files. During development, delete both files to reset users, balances, history, and 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. | `tests/blackjack.rs` | Card totals, naturals, hit/stand/double, dealer bust, S17, state parsing. | | `tests/crash.rs` | Multiplier curve, payout floor, crash-point distribution, house edge, cap. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | -| `tests/roulette.rs` | European wheel, split-bet payouts, zero handling, result parser. | +| `tests/roulette.rs` | Wheel, every bet type and payout, zero handling, cursor lattice, result parser. | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | | `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | | `tests/slots.rs` | Server slots payout rules and overflow handling. | @@ -71,18 +71,31 @@ Use an 80-column terminal because several output lines are width-sensitive. ## Manual roulette checklist -1. `play roulette` shows the balance, chip size, empty bets, and controls. -2. `R`/`B`/`E`/`O` add one chip; digits + Enter add a number; `C` clears; - `+`/`=`/`-` change the chip size. -3. Space scrolls the wheel under a fixed ball marker and ends on the winning +1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, + and the six outside cells, with the cursor on number 1. +2. Arrows move the cursor. On the numbers the highlighted cells show the bet: + one number (straight), two (split), four (corner). Down leaves the felt into + the dozens, then columns, then outside; Up walks back. +3. ENTER places the chip where the cursor is; `C` clears; `+`/`=`/`-` change the + chip size. The bets line lists every chip and the total. +4. Space scrolls the wheel under a fixed ball marker and ends on the winning number. -4. Check a split result: - - `17 black odd`: black+odd pay, red+even lose, straight 17 pays 36x. - - `0`: all outside bets lose; a straight 0 pays 36x. -5. Numbers are color-coded: red numbers red, 0 green, black default. Check the - bets line, the rolling strip, and the winning number. -6. The result board shows the winning number/color/parity, the payout, and the - new balance. The next spin reuses the placed bets. +5. Check the payouts: + - a straight pays 36x, a split 18x, a corner 9x; + - a dozen or column pays 3x; + - an outside bet pays 2x; + - `0` loses every outside bet, both dozens and every column, and pays a + straight 0. +6. The result line names the number, color and parity, the total returned and + the profit. The next spin reuses the placed chips. + +## Manual terminal size check + +1. Start the client in a terminal smaller than 80x24: it must print + `zagan.bet needs a terminal of at least 80x24 cells` and exit without + switching to the alternate screen or raw mode. +2. At 80x24 every game board (slots 10 lines, roulette 12, blackjack 6, crash 6) + must fit without wrapping. ## Manual blackjack checklist 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( Ok(Ok((tls, fp))) => (tls, fp), Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), Err(mpsc::RecvTimeoutError::Timeout) => { - return Err("Connection timed out after 3 seconds".into()); + return Err("Connection timed out after 10 seconds".into()); } Err(_) => return Err("Connection attempt failed".into()), }; 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() { let args: Vec = std::env::args().collect(); let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); + // Every game board is drawn in place, so refuse a terminal that cannot fit + // them before touching the network or the terminal mode. + let size = crossterm::terminal::size(); + let (columns, rows) = match &size { + Ok((columns, rows)) => (*columns, *rows), + Err(_) => (0, 0), + }; + if let Some(message) = zagan_bet::client::ui::terminal_too_small(columns, rows) { + eprintln!("{message}"); + if size.is_err() { + eprintln!("Could not read the terminal size (is this a real terminal?)."); + } + std::process::exit(1); + } + if unsafe_mode { println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); } 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 { KeyCode::Backspace => Some(Key::Backspace), KeyCode::Up => Some(Key::Up), KeyCode::Down => Some(Key::Down), + KeyCode::Left => Some(Key::Left), + KeyCode::Right => Some(Key::Right), KeyCode::Tab => Some(Key::Tab), KeyCode::Esc => Some(Key::Esc), _ => 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::{ record_history, }; use crate::crash::{multiplier_at, CrashState}; -use crate::roulette::{Bets, NumberBet}; +use crate::client::games::roulette::Direction; +use crate::roulette::Bets; use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,6 +18,8 @@ pub enum Key { Backspace, Up, Down, + Left, + Right, Tab, Esc, CtrlC, @@ -76,14 +79,6 @@ enum Pending { Info, } -#[derive(Debug, Clone, Copy)] -enum RouletteSide { - Red, - Black, - Even, - Odd, -} - pub struct ClientCore { input: String, history: Vec, @@ -171,6 +166,7 @@ impl ClientCore { } Key::Up => self.history_previous(), Key::Down => self.history_next(), + Key::Left | Key::Right => Vec::new(), Key::Tab => self.complete(), Key::Esc => Vec::new(), } @@ -355,6 +351,19 @@ impl ClientCore { } fn handle_roulette_key(&mut self, key: Key) -> Vec { + let direction = match key { + Key::Up => Some(Direction::Up), + Key::Down => Some(Direction::Down), + Key::Left => Some(Direction::Left), + Key::Right => Some(Direction::Right), + _ => None, + }; + if let Some(direction) = direction { + let cursor = self.game.roulette_cursor().moved(direction); + self.game.set_roulette_cursor(&cursor); + return self.board_effects(); + } + match key { Key::CtrlC => vec![Effect::Quit], Key::Char('q') => { @@ -364,18 +373,9 @@ impl ClientCore { Effect::Print(vec!["Game exited.".to_string()]), ] } - Key::Esc => { - self.game.set_roulette_number_input(""); - self.board_effects() - } Key::Char(' ') => self.spin_roulette(), - Key::Char('r') => self.add_roulette_side(RouletteSide::Red), - Key::Char('b') => self.add_roulette_side(RouletteSide::Black), - Key::Char('e') => self.add_roulette_side(RouletteSide::Even), - Key::Char('o') => self.add_roulette_side(RouletteSide::Odd), Key::Char('c') => { self.game.set_roulette_bets(&Bets::default()); - self.game.set_roulette_number_input(""); self.game.set_roulette_status("place"); self.board_effects() } @@ -389,63 +389,17 @@ impl ClientCore { self.game.set_roulette_chip(chip); self.board_effects() } - Key::Char(c) if c.is_ascii_digit() => { - let mut input = self.roulette_input(); - if input.len() < 2 { - input.push(c); - self.game.set_roulette_number_input(&input); - } - self.board_effects() - } - Key::Backspace => { - let mut input = self.roulette_input(); - input.pop(); - self.game.set_roulette_number_input(&input); - self.board_effects() - } - Key::Enter => { - let input = self.roulette_input(); - if !input.is_empty() { - match input.parse::() { - Ok(number) if number <= 36 => { - self.add_roulette_number(number); - self.game.set_roulette_number_input(""); - } - _ => { - self.game.set_roulette_number_input(""); - self.game.set_error("Number must be 0-36"); - } - } - } - self.board_effects() - } + Key::Enter => self.place_roulette_chip(), _ => Vec::new(), } } - fn add_roulette_side(&mut self, side: RouletteSide) -> Vec { - let chip = self.game.roulette_chip(); - let mut bets = self.game.roulette_bets(); - match side { - RouletteSide::Red => bets.red = bets.red.saturating_add(chip), - RouletteSide::Black => bets.black = bets.black.saturating_add(chip), - RouletteSide::Even => bets.even = bets.even.saturating_add(chip), - RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip), - } - self.place_roulette_bets(bets) - } - - fn add_roulette_number(&mut self, number: u8) -> Vec { - let chip = self.game.roulette_chip(); + fn place_roulette_chip(&mut self) -> Vec { + let Some(target) = self.game.roulette_cursor().target() else { + return Vec::new(); + }; let mut bets = self.game.roulette_bets(); - if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { - existing.amount = existing.amount.saturating_add(chip); - } else { - bets.numbers.push(NumberBet { - number, - amount: chip, - }); - } + bets.add(target, self.game.roulette_chip()); self.place_roulette_bets(bets) } @@ -487,14 +441,6 @@ impl ClientCore { effects } - fn roulette_input(&self) -> String { - self.game - .get_current_state() - .and_then(|state| state.data.get("number_input")) - .cloned() - .unwrap_or_default() - } - fn handle_blackjack_key(&mut self, key: Key) -> Vec { let status = self .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) { [], ) .unwrap(); - // Migration for databases created before TOFU trust. - let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); // Credentials belong to a server identity (the certificate key), not to a // single address, so every address that presents the same key shares them. - if has_column(conn, "credentials", "host") { - migrate_credentials_to_fingerprints(conn); - } conn.execute( "CREATE TABLE IF NOT EXISTS credentials ( fingerprint TEXT PRIMARY KEY, @@ -39,42 +34,6 @@ pub fn init_db(conn: &Connection) { .unwrap(); } -fn has_column(conn: &Connection, table: &str, column: &str) -> bool { - let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else { - return false; - }; - let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else { - return false; - }; - rows.flatten().any(|name| name == column) -} - -/// Re-key host-bound credentials by the fingerprint of their host. -/// -/// Rows whose host was already forgotten have no identity to attach to and are -/// dropped; those addresses have to re-authenticate once. -fn migrate_credentials_to_fingerprints(conn: &Connection) { - let tx = conn.unchecked_transaction().unwrap(); - tx.execute_batch( - "DROP TABLE IF EXISTS credentials_by_identity; - CREATE TABLE credentials_by_identity ( - fingerprint TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL - ); - INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password) - SELECT h.fingerprint, c.username, c.password - FROM credentials c - JOIN hosts h ON h.host = c.host - WHERE h.fingerprint IS NOT NULL - ORDER BY h.last_used ASC, h.host ASC; - DROP TABLE credentials; - ALTER TABLE credentials_by_identity RENAME TO credentials;", - ) - .unwrap(); - tx.commit().unwrap(); -} - pub fn get_last_host(conn: &Connection) -> Option { let mut stmt = conn .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 { ); data.insert("chip".to_string(), "10".to_string()); data.insert("status".to_string(), "place".to_string()); - data.insert("number_input".to_string(), String::new()); + data.insert("cursor".to_string(), "lattice:0:3".to_string()); 0 } else if game_type == "blackjack" { data.insert("status".to_string(), "ready".to_string()); @@ -234,11 +234,19 @@ impl GameManager { } } - pub fn set_roulette_number_input(&mut self, value: &str) { + pub fn roulette_cursor(&self) -> crate::client::games::roulette::Cursor { + self.current_session + .as_ref() + .and_then(|session| session.data.get("cursor")) + .map(|raw| crate::client::games::roulette::Cursor::parse(raw)) + .unwrap_or_default() + } + + pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { if let Some(session) = self.current_session.as_mut() { session .data - .insert("number_input".to_string(), value.to_string()); + .insert("cursor".to_string(), cursor.encoded()); } } @@ -274,9 +282,6 @@ impl GameManager { session .data .insert("status".to_string(), "result".to_string()); - session - .data - .insert("number_input".to_string(), String::new()); } 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 @@ use super::traits::{Game, GameState}; -use crate::roulette::{is_even, is_odd, Bets}; +use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside}; -pub const ROULETTE_BOARD_LINES: usize = 8; +pub const ROULETTE_BOARD_LINES: usize = 12; + +/// Width of one number cell on the felt. +const CELL: usize = 5; +const NUM_COLUMNS: usize = 12; +const NUM_WIDTH: usize = CELL * NUM_COLUMNS; +const THIRD_WIDTH: usize = NUM_WIDTH / 3; +const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6; +const OUTSIDE_ORDER: [Outside; 6] = [ + Outside::Low, + Outside::Even, + Outside::Red, + Outside::Black, + Outside::Odd, + Outside::High, +]; pub struct RouletteGame; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +/// Where the betting cursor is. +/// +/// Over the numbers the cursor sits on a *lattice point*, the way a chip sits +/// between numbers on a real felt: an inner point covers four numbers (corner), +/// an edge point covers two (split), and an outer corner is a straight number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cursor { + Zero, + Lattice { x: usize, y: usize }, + Dozen(usize), + Column(usize), + Outside(usize), +} + +impl Default for Cursor { + fn default() -> Self { + // Bottom-left number (1). + Cursor::Lattice { x: 0, y: 3 } + } +} + +impl Cursor { + pub fn parse(raw: &str) -> Self { + let parts: Vec<&str> = raw.split(':').collect(); + match parts.as_slice() { + ["zero"] => Cursor::Zero, + ["lattice", x, y] => match (x.parse::(), y.parse::()) { + (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y }, + _ => Cursor::default(), + }, + ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), + ["column", index] => parse_index(index).map(Cursor::Column).unwrap_or_default(), + ["outside", index] => match index.parse::() { + Ok(index) if index < OUTSIDE_ORDER.len() => Cursor::Outside(index), + _ => Cursor::default(), + }, + _ => Cursor::default(), + } + } + + pub fn encoded(&self) -> String { + match self { + Cursor::Zero => "zero".to_string(), + Cursor::Lattice { x, y } => format!("lattice:{x}:{y}"), + Cursor::Dozen(index) => format!("dozen:{index}"), + Cursor::Column(index) => format!("column:{index}"), + Cursor::Outside(index) => format!("outside:{index}"), + } + } + + pub fn target(&self) -> Option { + match self { + Cursor::Zero => Some(BetTarget::Straight { number: 0 }), + Cursor::Lattice { x, y } => lattice_target(*x, *y), + Cursor::Dozen(index) => Some(BetTarget::Dozen { + index: *index as u8, + }), + Cursor::Column(index) => Some(BetTarget::Column { + index: *index as u8, + }), + Cursor::Outside(index) => Some(BetTarget::Outside { + kind: OUTSIDE_ORDER[*index], + }), + } + } + + /// Numbers the cursor would cover, for highlighting the felt. + pub fn numbers(&self) -> Vec { + self.target().map(|target| target.numbers()).unwrap_or_default() + } + + pub fn moved(&self, direction: Direction) -> Cursor { + match (*self, direction) { + (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, + (Cursor::Zero, _) => Cursor::Zero, + + (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { + x: x.saturating_sub(1), + y, + }, + (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { + x: (x + 1).min(NUM_COLUMNS), + y, + }, + (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, + (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, + (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), + (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, + + (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), + (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), + (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { + x: 4 * index + 1, + y: 3, + }, + (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), + + (Cursor::Column(index), Direction::Left) => Cursor::Column(index.saturating_sub(1)), + (Cursor::Column(index), Direction::Right) => Cursor::Column((index + 1).min(2)), + (Cursor::Column(index), Direction::Up) => Cursor::Dozen(index), + (Cursor::Column(index), Direction::Down) => Cursor::Outside(index * 2), + + (Cursor::Outside(index), Direction::Left) => { + Cursor::Outside(index.saturating_sub(1)) + } + (Cursor::Outside(index), Direction::Right) => { + Cursor::Outside((index + 1).min(OUTSIDE_ORDER.len() - 1)) + } + (Cursor::Outside(index), Direction::Up) => Cursor::Column(index / 2), + (Cursor::Outside(index), Direction::Down) => Cursor::Outside(index), + } + } +} + +fn parse_index(raw: &str) -> Option { + raw.parse::().ok().filter(|index| *index < 3) +} + +/// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner). +fn lattice_target(x: usize, y: usize) -> Option { + let cell = |column: isize, row: isize| -> Option { + if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) { + return None; + } + Some((3 * column + (3 - row)) as u8) + }; + + let x = x as isize; + let y = y as isize; + let mut numbers: Vec = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)] + .into_iter() + .filter_map(|(column, row)| cell(column, row)) + .collect(); + numbers.sort_unstable(); + + match numbers.as_slice() { + [number] => Some(BetTarget::Straight { number: *number }), + [a, b] => Some(BetTarget::Split { a: *a, b: *b }), + [a, b, c, d] => Some(BetTarget::Corner { + numbers: [*a, *b, *c, *d], + }), + _ => None, + } +} + impl RouletteGame { pub fn new() -> Self { Self @@ -14,40 +182,99 @@ impl RouletteGame { state .data .get("bets") - .and_then(|raw| serde_json::from_str(raw).ok()) + .and_then(|raw| Bets::from_json(raw).ok()) .unwrap_or_default() } - fn bets_line(bets: &Bets) -> String { - let mut parts = Vec::new(); - if bets.red > 0 { - parts.push(format!("RED {}", bets.red)); - } - if bets.black > 0 { - parts.push(format!("BLACK {}", bets.black)); - } - if bets.even > 0 { - parts.push(format!("EVEN {}", bets.even)); - } - if bets.odd > 0 { - parts.push(format!("ODD {}", bets.odd)); - } - for bet in &bets.numbers { - parts.push(format!("#{} {}", bet.number, bet.amount)); - } + fn cursor(state: &GameState) -> Cursor { + state + .data + .get("cursor") + .map(|raw| Cursor::parse(raw)) + .unwrap_or_default() + } - let list = if parts.is_empty() { - "(none)".to_string() + fn zero_cell(cursor: Cursor) -> String { + let cell = if cursor == Cursor::Zero { + "[ 0 ]".to_string() } else { - parts.join(" ") + format!("{:^CELL$}", 0) }; - format!("Bets: {} Total: {} PLN", list, bets.total()) + format!("{cell: String { + (0..NUM_COLUMNS) + .map(|column| { + let number = (3 * column + (3 - row)) as u8; + if covered.contains(&number) { + format!("[{number:^3}]") + } else { + format!("{number:^CELL$}") + } + }) + .collect() + } + + fn wide_cell(label: &str, width: usize, active: bool) -> String { + if active { + format!("[{:^inner$}]", label, inner = width - 2) + } else { + format!("{label:^width$}") + } + } + + fn dozens_row(cursor: Cursor) -> String { + ["1st 12", "2nd 12", "3rd 12"] + .iter() + .enumerate() + .map(|(index, label)| { + Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Dozen(index)) + }) + .collect() + } + + fn columns_row(cursor: Cursor) -> String { + ["col 1 2:1", "col 2 2:1", "col 3 2:1"] + .iter() + .enumerate() + .map(|(index, label)| { + Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Column(index)) + }) + .collect() + } + + fn outside_row(cursor: Cursor) -> String { + OUTSIDE_ORDER + .iter() + .enumerate() + .map(|(index, kind)| { + Self::wide_cell(kind.label(), OUTSIDE_WIDTH, cursor == Cursor::Outside(index)) + }) + .collect() } - fn result_lines(state: &GameState) -> (String, String) { + fn bets_line(bets: &Bets) -> String { + let mut parts: Vec = bets + .chips + .iter() + .filter(|chip| chip.amount > 0) + .map(|chip| format!("{} {}", chip.target.label(), chip.amount)) + .collect(); + if parts.is_empty() { + parts.push("(none)".to_string()); + } + let mut list = parts.join(" "); + if list.chars().count() > 40 { + list = list.chars().take(39).collect::() + "…"; + } + format!("Bets: {list} Total: {} PLN", bets.total()) + } + + fn status_line(state: &GameState) -> String { if let Some(error) = state.data.get("error") { if !error.is_empty() { - return (format!("ERROR: {error}"), String::new()); + return format!("ERROR: {error}"); } } @@ -77,15 +304,16 @@ impl RouletteGame { .get("winnings") .map(String::as_str) .unwrap_or("0"); - let result = if winnings == "0" { - "No win.".to_string() + let stake = state.bet_amount; + if winnings == "0" { + format!("No win at {number} {color}{parity}") } else { - format!("Won {winnings} PLN") - }; - (format!("Winning: {number} {color}{parity}"), result) + let profit = winnings.parse::().unwrap_or(0).saturating_sub(stake); + format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})") + } } - Some("spinning") => ("Spinning...".to_string(), String::new()), - _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), + Some("spinning") => "Spinning...".to_string(), + _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), } } } @@ -99,27 +327,25 @@ impl Game for RouletteGame { let bets = Self::bets(state); let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); - let input = state - .data - .get("number_input") - .map(String::as_str) - .unwrap_or(""); - let input_line = if input.is_empty() { - String::new() - } else { - format!("Number: {input}_") - }; - let (status, result) = Self::result_lines(state); + let cursor = Self::cursor(state); + let covered = cursor.numbers(); - format!( - "=== 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", - balance, - chip, + let lines = [ + "=== ROULETTE ===".to_string(), + format!("Balance: {balance} PLN Chip: {chip} PLN"), + Self::zero_cell(cursor), + Self::number_row(0, &covered), + Self::number_row(1, &covered), + Self::number_row(2, &covered), + Self::dozens_row(cursor), + Self::columns_row(cursor), + Self::outside_row(cursor), Self::bets_line(&bets), - input_line, - status, - result - ) + Self::status_line(state), + "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit" + .to_string(), + ]; + lines.join("\r\n") } fn render_lines(&self, _state: &GameState) -> usize { @@ -129,20 +355,20 @@ impl Game for RouletteGame { fn get_help(&self) -> String { "ROULETTE (European)\n\ ==================\n\ - 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ + 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\n\ \n\ - Betting:\n\ - R / B / E / O - add the current chip to red/black/even/odd\n\ - digits + Enter - add the current chip to that number\n\ + Betting with the cursor:\n\ + arrows - move over the felt; between numbers picks a split or corner\n\ + ENTER - place the current chip where the cursor is\n\ C - clear all bets\n\ + or = / - - change the chip size\n\ SPACE - spin when at least one bet is placed\n\ Q - exit\n\ \n\ - Payouts:\n\ - straight number: 35:1 (36x returned)\n\ - red/black: 1:1 (2x returned)\n\ - even/odd: 1:1 (2x returned)\n\ + Payouts (returned per unit staked):\n\ + straight 36x, split 18x, corner 9x\n\ + dozen 3x, column 3x\n\ + red/black, even/odd, 1-18/19-36: 2x\n\ Multiple bets are settled independently and added together." .to_string() } 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 { /// Format remembered hosts so a 64-character fingerprint does not wrap on an /// 80-column terminal. +/// Smallest terminal every game fits in. Roulette's felt is 12 lines and the +/// controls are ~72 columns, so anything smaller would wrap or hide the board. +pub const MIN_COLUMNS: u16 = 80; +pub const MIN_ROWS: u16 = 24; + +/// Refuse to start when the terminal cannot show every game. +pub fn terminal_too_small(columns: u16, rows: u16) -> Option { + if columns >= MIN_COLUMNS && rows >= MIN_ROWS { + return None; + } + Some(format!( + "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \ + but this one is {columns}x{rows}.\n\ + Resize it and start again." + )) +} + /// Render remembered hosts. /// /// 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] = [ /// Total return on a winning straight (35:1 plus the stake). pub const STRAIGHT_RETURN: u64 = 36; +/// Total return on a winning split (17:1 plus the stake). +pub const SPLIT_RETURN: u64 = 18; +/// Total return on a winning corner (8:1 plus the stake). +pub const CORNER_RETURN: u64 = 9; +/// Total return on a winning dozen or column (2:1 plus the stake). +pub const THIRD_RETURN: u64 = 3; /// Total return on a winning outside bet (1:1 plus the stake). pub const EVEN_MONEY_RETURN: u64 = 2; @@ -47,67 +53,176 @@ pub fn rtp() -> f64 { 36.0 / 37.0 } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outside { + Low, + High, + Even, + Odd, + Red, + Black, +} + +impl Outside { + pub fn wins(self, number: u8) -> bool { + match self { + Outside::Low => (1..=18).contains(&number), + Outside::High => (19..=36).contains(&number), + Outside::Even => is_even(number), + Outside::Odd => is_odd(number), + Outside::Red => is_red(number), + Outside::Black => is_black(number), + } + } + + pub fn label(self) -> &'static str { + match self { + Outside::Low => "1-18", + Outside::High => "19-36", + Outside::Even => "EVEN", + Outside::Odd => "ODD", + Outside::Red => "RED", + Outside::Black => "BLACK", + } + } +} + +/// One place on the felt where a chip can sit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "bet", rename_all = "snake_case")] +pub enum BetTarget { + Straight { number: u8 }, + Split { a: u8, b: u8 }, + Corner { numbers: [u8; 4] }, + /// 0 = 1-12, 1 = 13-24, 2 = 25-36. + Dozen { index: u8 }, + /// 0 = 1,4,...,34; 1 = 2,5,...,35; 2 = 3,6,...,36. + Column { index: u8 }, + Outside { kind: Outside }, +} + +impl BetTarget { + /// Numbers this target covers on the number grid (empty for outside bets). + pub fn numbers(&self) -> Vec { + match self { + BetTarget::Straight { number } => vec![*number], + BetTarget::Split { a, b } => vec![*a, *b], + BetTarget::Corner { numbers } => numbers.to_vec(), + _ => Vec::new(), + } + } + + /// Total returned per unit staked, or 0 when the bet loses. + pub fn return_multiplier(&self, number: u8) -> u64 { + match self { + BetTarget::Straight { number: straight } => { + u64::from(*straight == number) * STRAIGHT_RETURN + } + BetTarget::Split { a, b } => u64::from(*a == number || *b == number) * SPLIT_RETURN, + BetTarget::Corner { numbers } => { + u64::from(numbers.contains(&number)) * CORNER_RETURN + } + BetTarget::Dozen { index } => { + let wins = number != 0 && (number - 1) / 12 == *index; + u64::from(wins) * THIRD_RETURN + } + BetTarget::Column { index } => { + let wins = match index { + 0 => number % 3 == 1, + 1 => number % 3 == 2, + 2 => number != 0 && number % 3 == 0, + _ => false, + }; + u64::from(wins) * THIRD_RETURN + } + BetTarget::Outside { kind } => u64::from(kind.wins(number)) * EVEN_MONEY_RETURN, + } + } + + pub fn label(&self) -> String { + match self { + BetTarget::Straight { number } => number.to_string(), + BetTarget::Split { a, b } => format!("{a}/{b}"), + BetTarget::Corner { numbers } => numbers + .iter() + .map(u8::to_string) + .collect::>() + .join("/"), + BetTarget::Dozen { index } => match index { + 0 => "1st12".to_string(), + 1 => "2nd12".to_string(), + _ => "3rd12".to_string(), + }, + BetTarget::Column { index } => format!("col{}", index + 1), + BetTarget::Outside { kind } => kind.label().to_string(), + } + } + + fn validates(&self) -> bool { + match self { + BetTarget::Straight { number } => *number <= 36, + BetTarget::Split { a, b } => *a <= 36 && *b <= 36 && a != b, + BetTarget::Corner { numbers } => { + numbers.iter().all(|number| *number <= 36) + && numbers + .iter() + .enumerate() + .all(|(index, number)| !numbers[..index].contains(number)) + } + BetTarget::Dozen { index } | BetTarget::Column { index } => *index < 3, + BetTarget::Outside { .. } => true, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NumberBet { - pub number: u8, +pub struct Chip { + pub target: BetTarget, pub amount: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Bets { #[serde(default)] - pub red: u64, - #[serde(default)] - pub black: u64, - #[serde(default)] - pub even: u64, - #[serde(default)] - pub odd: u64, - #[serde(default)] - pub numbers: Vec, + pub chips: Vec, } impl Bets { pub fn total(&self) -> u64 { - let mut total = self - .red - .saturating_add(self.black) - .saturating_add(self.even) - .saturating_add(self.odd); - for bet in &self.numbers { - total = total.saturating_add(bet.amount); - } - total + self.chips + .iter() + .fold(0u64, |total, chip| total.saturating_add(chip.amount)) } pub fn is_empty(&self) -> bool { - self.total() == 0 + self.chips.iter().all(|chip| chip.amount == 0) } - /// Total returned for a winning number, including returned stakes. - pub fn winnings(&self, number: u8) -> u64 { - let mut winnings = 0u64; - - if number != 0 { - if is_red(number) { - winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN)); - } else { - winnings = winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN)); - } - if is_even(number) { - winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN)); - } else { - winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN)); - } - } + pub fn amount_on(&self, target: &BetTarget) -> u64 { + self.chips + .iter() + .filter(|chip| &chip.target == target) + .fold(0u64, |total, chip| total.saturating_add(chip.amount)) + } - for bet in &self.numbers { - if bet.number == number { - winnings = winnings.saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); - } + /// Put a chip on a target, merging with an existing one. + pub fn add(&mut self, target: BetTarget, amount: u64) { + if let Some(chip) = self.chips.iter_mut().find(|chip| chip.target == target) { + chip.amount = chip.amount.saturating_add(amount); + } else if amount > 0 { + self.chips.push(Chip { target, amount }); } + } - winnings + /// Total returned for a winning number, including returned stakes. + pub fn winnings(&self, number: u8) -> u64 { + self.chips.iter().fold(0u64, |total, chip| { + total.saturating_add( + chip.amount + .saturating_mul(chip.target.return_multiplier(number)), + ) + }) } pub fn validate(&self, balance: u64) -> Result<(), String> { @@ -117,14 +232,22 @@ impl Bets { if self.total() > balance { return Err("Insufficient balance".to_string()); } - for bet in &self.numbers { - if bet.number > 36 { - return Err(format!("Invalid number: {}", bet.number)); + for chip in &self.chips { + if !chip.target.validates() { + return Err(format!("Invalid bet: {}", chip.target.label())); } - if bet.amount == 0 { + if chip.amount == 0 { return Err("A bet amount must be positive".to_string()); } } Ok(()) } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "{\"chips\":[]}".to_string()) + } + + pub fn from_json(raw: &str) -> Result { + serde_json::from_str(raw) + } } 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) { [], ) .unwrap(); - // Add column if not exists (for migration) - let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []); - let _ = conn.execute( - "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", - [], - ); conn.execute( "CREATE TABLE IF NOT EXISTS plays ( 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 { } fn get_description(&self) -> &'static str { - "European roulette. Bet on red/black, even/odd, or a straight number." + "European roulette. Straights, splits, corners, dozens, columns and outside bets." } 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() { } #[test] -fn roulette_split_bets_spin_and_show_the_result() { +fn roulette_cursor_places_bets_and_spins() { let mut h = Harness::new(); h.enter_roulette(); - assert_eq!(h.board().len(), 8); + assert_eq!(h.board().len(), 12); assert_eq!(h.board()[0], "=== ROULETTE ==="); assert!(h.board()[1].contains("Balance: 1000 PLN")); assert!(h.board()[1].contains("Chip: 10 PLN")); - assert!(h.board()[2].contains("Bets: (none)")); + assert!(h.board()[9].contains("Bets: (none)")); + // The cursor starts on number 1, the bottom-left corner. + assert!(h.board()[5].contains("[ 1 ]")); + + // Up twice: straight 2, then the split between 2 and 3. + h.key(Key::Up); + h.key(Key::Up); + assert!(h.board()[4].contains("[ 2 ]")); + assert!(h.board()[3].contains("[ 3 ]")); - h.key(Key::Char('r')); - h.key(Key::Char('e')); - h.key(Key::Char('1')); - h.key(Key::Char('7')); h.key(Key::Enter); + assert!(h.board()[9].contains("2/3 10")); + assert!(h.board()[9].contains("Total: 10 PLN")); - assert!(h.board()[2].contains("RED 10")); - assert!(h.board()[2].contains("EVEN 10")); - assert!(h.board()[2].contains("#17 10")); - assert!(h.board()[2].contains("Total: 30 PLN")); + // Back down to number 1 and place a straight. + h.key(Key::Down); + h.key(Key::Down); + h.key(Key::Enter); + assert!(h.board()[9].contains("1 10")); + assert!(h.board()[9].contains("Total: 20 PLN")); h.key(Key::Char(' ')); let request = h @@ -620,18 +628,41 @@ fn roulette_split_bets_spin_and_show_the_result() { .expect("roulette request"); assert_eq!( request, - "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}" + "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}" ); - let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; + let bets = r#"{"chips":[{"target":{"bet":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"amount":10}]}"#; h.reply(&format!( - "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}" + "ROULETTE_RESULT session-1 180 20 1160 3 red {bets}" )); - assert_eq!(h.board().len(), 8); - assert!(h.board()[1].contains("Balance: 1330 PLN")); - assert!(h.board()[4].contains("Winning: 17 BLACK ODD")); - assert!(h.board()[5].contains("Won 360 PLN")); + assert_eq!(h.board().len(), 12); + assert!(h.board()[1].contains("Balance: 1160 PLN")); + assert!(h.board()[10].contains("Won 180 PLN at 3 RED ODD")); + assert!(h.board()[10].contains("+160")); +} + +#[test] +fn roulette_cursor_reaches_the_outside_rows() { + let mut h = Harness::new(); + h.enter_roulette(); + + h.key(Key::Down); + assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12")); + h.key(Key::Down); + assert!(h.board()[7].starts_with("[") && h.board()[7].contains("2:1")); + h.key(Key::Down); + assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18")); + + h.key(Key::Enter); + assert!(h.board()[9].contains("1-18 10")); + + // Back up to the felt: the bottom edge of the first dozen is the 1/4 split. + h.key(Key::Up); + h.key(Key::Up); + h.key(Key::Up); + assert!(h.board()[5].contains("[ 1 ]")); + assert!(h.board()[5].contains("[ 4 ]")); } #[test] @@ -640,7 +671,7 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() { h.enter_roulette(); h.key(Key::Char(' ')); - assert!(h.board()[4].contains("ERROR: Place at least one bet")); + assert!(h.board()[10].contains("ERROR: Place at least one bet")); assert!(!h .sent .iter() @@ -648,22 +679,9 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() { h.key(Key::Char('=')); assert!(h.board()[1].contains("Chip: 20 PLN")); - h.key(Key::Char('r')); - assert!(h.board()[2].contains("RED 20")); - assert!(h.board()[2].contains("Total: 20 PLN")); -} - -#[test] -fn roulette_rejects_an_out_of_range_number() { - let mut h = Harness::new(); - h.enter_roulette(); - - h.key(Key::Char('9')); - h.key(Key::Char('9')); h.key(Key::Enter); - - assert!(h.board()[4].contains("ERROR: Number must be 0-36")); - assert!(h.board()[2].contains("Bets: (none)")); + assert!(h.board()[9].contains("1 20")); + assert!(h.board()[9].contains("Total: 20 PLN")); } 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; use zagan_bet::client::ui::{ command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, - visual_line_count, + terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; #[test] @@ -255,3 +255,53 @@ fn hosts_without_a_fingerprint_are_never_merged() { 2 ); } + +#[test] +fn a_terminal_smaller_than_every_game_needs_is_refused() { + assert!(terminal_too_small(80, 24).is_none()); + assert!(terminal_too_small(120, 40).is_none()); + + assert!(terminal_too_small(79, 24).is_some()); + assert!(terminal_too_small(80, 23).is_some()); + + let message = terminal_too_small(40, 10).unwrap(); + assert!(message.contains("80x24")); + assert!(message.contains("40x10")); +} + +#[test] +fn the_roulette_felt_fits_the_minimum_terminal() { + use zagan_bet::client::games::roulette::{Cursor, RouletteGame, ROULETTE_BOARD_LINES}; + use zagan_bet::client::games::traits::{Game, GamePhase, GameState}; + use zagan_bet::roulette::Bets; + use std::collections::HashMap; + + let mut data = HashMap::new(); + data.insert("balance".to_string(), "1000".to_string()); + data.insert("chip".to_string(), "10".to_string()); + data.insert("status".to_string(), "place".to_string()); + data.insert("bets".to_string(), Bets::default().to_json()); + data.insert("cursor".to_string(), Cursor::Zero.encoded()); + let state = GameState { + game_type: "roulette".to_string(), + bet_amount: 0, + phase: GamePhase::WaitingToStart, + data, + }; + + let board = RouletteGame::new().render(&state); + let lines: Vec<&str> = board.split("\r\n").collect(); + assert_eq!(lines.len(), ROULETTE_BOARD_LINES); + assert_eq!(RouletteGame::new().render_lines(&state), ROULETTE_BOARD_LINES); + for line in lines { + assert!( + display_width(line) <= MIN_COLUMNS as usize, + "roulette line is {} wide: {line:?}", + display_width(line) + ); + } + assert!( + ROULETTE_BOARD_LINES + 4 <= MIN_ROWS as usize, + "the board must leave room for the prompt and a few transcript lines" + ); +} 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 @@ -use zagan_bet::roulette::{color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL}; +use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::roulette::{ + color, is_black, is_even, is_odd, is_red, rtp, BetTarget, Bets, Outside, WHEEL, +}; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::traits::Game; @@ -31,102 +34,184 @@ fn european_rtp_is_36_over_37() { } #[test] -fn bets_total_and_winning_number_payouts() { - let bets = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 17, - amount: 5, - }], +fn inside_bets_pay_their_true_odds() { + let straight = BetTarget::Straight { number: 17 }; + assert_eq!(straight.return_multiplier(17), 36); + assert_eq!(straight.return_multiplier(18), 0); + + let split = BetTarget::Split { a: 1, b: 2 }; + assert_eq!(split.return_multiplier(1), 18); + assert_eq!(split.return_multiplier(2), 18); + assert_eq!(split.return_multiplier(3), 0); + assert_eq!(split.numbers(), vec![1, 2]); + + let corner = BetTarget::Corner { + numbers: [1, 2, 4, 5], }; + for number in [1, 2, 4, 5] { + assert_eq!(corner.return_multiplier(number), 9); + } + assert_eq!(corner.return_multiplier(3), 0); + assert_eq!(corner.numbers(), vec![1, 2, 4, 5]); +} + +#[test] +fn dozens_and_columns_pay_three_for_one_and_zero_loses() { + let first_dozen = BetTarget::Dozen { index: 0 }; + assert_eq!(first_dozen.return_multiplier(1), 3); + assert_eq!(first_dozen.return_multiplier(12), 3); + assert_eq!(first_dozen.return_multiplier(13), 0); + assert_eq!(first_dozen.return_multiplier(0), 0); + + let second_dozen = BetTarget::Dozen { index: 1 }; + assert_eq!(second_dozen.return_multiplier(13), 3); + assert_eq!(second_dozen.return_multiplier(24), 3); + + let first_column = BetTarget::Column { index: 0 }; + assert_eq!(first_column.return_multiplier(1), 3); + assert_eq!(first_column.return_multiplier(34), 3); + assert_eq!(first_column.return_multiplier(2), 0); + + let third_column = BetTarget::Column { index: 2 }; + assert_eq!(third_column.return_multiplier(36), 3); + assert_eq!(third_column.return_multiplier(0), 0, "0 is not in a column"); - assert_eq!(bets.total(), 45); - // 17 is black and odd: 10*2 + 10*2 + 5*36. - assert_eq!(bets.winnings(17), 220); - // 18 is red and even. - assert_eq!(bets.winnings(18), 40); + for index in 0..3 { + let dozen = BetTarget::Dozen { index }; + let winning = index as u8 * 12 + 1; + assert_eq!(dozen.return_multiplier(winning), 3); + } } #[test] -fn zero_loses_all_outside_bets_but_pays_a_straight_zero() { - let outside = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![], - }; - assert_eq!(outside.winnings(0), 0); - - let straight = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 0, - amount: 5, - }], - }; - assert_eq!(straight.total(), 45); - assert_eq!(straight.winnings(0), 180); +fn outside_bets_pay_even_money_and_zero_loses_everything() { + let cases = [ + (Outside::Low, 18u8, 19u8), + (Outside::High, 19, 18), + (Outside::Even, 2, 3), + (Outside::Odd, 3, 2), + (Outside::Red, 1, 2), + (Outside::Black, 2, 1), + ]; + for (kind, winning, losing) in cases { + let bet = BetTarget::Outside { kind }; + assert_eq!(bet.return_multiplier(winning), 2, "{kind:?} should win"); + assert_eq!(bet.return_multiplier(losing), 0, "{kind:?} should lose"); + assert_eq!(bet.return_multiplier(0), 0, "0 loses every outside bet"); + } +} + +#[test] +fn chips_on_the_same_target_merge() { + let mut bets = Bets::default(); + assert!(bets.is_empty()); + + bets.add(BetTarget::Straight { number: 17 }, 10); + bets.add(BetTarget::Straight { number: 17 }, 5); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + + assert_eq!(bets.chips.len(), 2); + assert_eq!( + bets.amount_on(&BetTarget::Straight { number: 17 }), + 15 + ); + assert_eq!(bets.total(), 25); + assert_eq!(bets.winnings(17), 15 * 36); } #[test] -fn bets_validate_empty_balance_and_invalid_numbers() { +fn bets_settle_every_chip_independently() { + let mut bets = Bets::default(); + bets.add(BetTarget::Split { a: 1, b: 2 }, 10); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + bets.add(BetTarget::Straight { number: 1 }, 5); + + // 2 is black, so the red chip loses; the split pays 18x. + assert_eq!(bets.winnings(2), 180); + // 3 is red, but the straight is on 1, so only the red chip pays. + assert_eq!(bets.winnings(3), 20); + // 1 is red, matches the straight, and is half of the split. + assert_eq!(bets.winnings(1), 5 * 36 + 10 * 2 + 10 * 18); +} + +#[test] +fn a_straight_zero_is_paid_while_outside_bets_lose() { + let mut bets = Bets::default(); + bets.add(BetTarget::Straight { number: 0 }, 5); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + bets.add(BetTarget::Dozen { index: 0 }, 10); + + assert_eq!(bets.total(), 25); + assert_eq!(bets.winnings(0), 180); +} + +#[test] +fn bets_validate_balance_and_targets() { assert!(Bets::default().validate(1000).is_err()); - let too_big = Bets { - red: 2000, - ..Bets::default() - }; + let mut too_big = Bets::default(); + too_big.add(BetTarget::Outside { kind: Outside::Black }, 2000); assert!(too_big.validate(1000).is_err()); let bad_number = Bets { - numbers: vec![NumberBet { - number: 37, + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Straight { number: 37 }, amount: 10, }], - ..Bets::default() }; assert!(bad_number.validate(1000).is_err()); -} -#[test] -fn the_server_settles_a_split_bet() { - let bets = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 17, - amount: 5, + let bad_split = Bets { + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Split { a: 4, b: 4 }, + amount: 10, }], }; - let payload = serde_json::to_string(&bets).unwrap(); - let game = RouletteGame::with_result(17); - let state = game.prepare_state("alice", bets.total()); + assert!(bad_split.validate(1000).is_err()); + + let bad_corner = Bets { + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Corner { + numbers: [1, 2, 3, 3], + }, + amount: 10, + }], + }; + assert!(bad_corner.validate(1000).is_err()); + let bad_dozen = Bets { + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Dozen { index: 3 }, + amount: 10, + }], + }; + assert!(bad_dozen.validate(1000).is_err()); +} + +#[test] +fn the_server_settles_split_and_outside_chips() { + let mut bets = Bets::default(); + bets.add(BetTarget::Split { a: 1, b: 2 }, 10); + bets.add(BetTarget::Outside { kind: Outside::Even }, 10); + let payload = bets.to_json(); + + let game = RouletteGame::with_result(2); + let state = game.prepare_state("alice", bets.total()); let state = game.process_action(&state, "spin", &[&payload]).unwrap(); let result = game.calculate_result(&state).unwrap(); - assert_eq!(result.winnings, 220); - assert_eq!(result.result_data.get("number").unwrap(), "17"); + // 2 is even: 10*18 (split) + 10*2 (even money). + assert_eq!(result.winnings, 200); + assert_eq!(result.result_data.get("number").unwrap(), "2"); assert_eq!(result.result_data.get("color").unwrap(), "black"); assert_eq!(result.result_data.get("bets").unwrap(), &payload); } #[test] fn the_server_rejects_a_total_that_does_not_match_the_bets() { - let payload = serde_json::to_string(&Bets { - red: 10, - ..Bets::default() - }) - .unwrap(); + let mut bets = Bets::default(); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + let payload = bets.to_json(); let game = RouletteGame::with_result(17); let state = game.prepare_state("alice", 99); @@ -134,31 +219,116 @@ fn the_server_rejects_a_total_that_does_not_match_the_bets() { } #[test] -fn the_server_rejects_an_invalid_number() { - let payload = serde_json::to_string(&Bets { - numbers: vec![NumberBet { - number: 37, - amount: 10, - }], - ..Bets::default() - }) - .unwrap(); +fn the_server_rejects_an_invalid_target() { + let payload = r#"{"chips":[{"target":{"bet":"straight","number":37},"amount":10}]}"#; let game = RouletteGame::with_result(17); let state = game.prepare_state("alice", 10); - assert!(game.process_action(&state, "spin", &[&payload]).is_err()); + assert!(game.process_action(&state, "spin", &[payload]).is_err()); +} + +// --- betting cursor ------------------------------------------------------- + +fn target_at(x: usize, y: usize) -> BetTarget { + Cursor::Lattice { x, y }.target().unwrap() +} + +#[test] +fn lattice_points_become_straights_splits_and_corners() { + assert_eq!( + target_at(0, 3), + BetTarget::Straight { number: 1 }, + "bottom-left corner is a straight" + ); + assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 }); + assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 }); + assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 }); + + assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 }); + assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 }); + assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 }); + + assert_eq!( + target_at(1, 1), + BetTarget::Corner { + numbers: [2, 3, 5, 6] + } + ); + assert_eq!( + target_at(6, 2), + BetTarget::Corner { + numbers: [16, 17, 19, 20] + } + ); +} + +#[test] +fn the_cursor_walks_the_felt_and_the_outside_rows() { + let bottom = Cursor::Lattice { x: 0, y: 3 }; + assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); + assert_eq!( + Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down), + Cursor::Dozen(2) + ); + assert_eq!( + Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up), + Cursor::Zero + ); + assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 }); + + assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 }); + assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); + assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); + assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); + assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); + + // Edges stay put instead of wrapping. + assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); + assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); + assert_eq!( + Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left), + Cursor::Lattice { x: 0, y: 2 } + ); +} + +#[test] +fn every_cursor_round_trips_through_its_encoded_form() { + let cursors = [ + Cursor::Zero, + Cursor::Lattice { x: 7, y: 2 }, + Cursor::Dozen(1), + Cursor::Column(2), + Cursor::Outside(4), + ]; + for cursor in cursors { + assert_eq!(Cursor::parse(&cursor.encoded()), cursor); + } + assert_eq!(Cursor::parse("nonsense"), Cursor::default()); + assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); + assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); +} + +#[test] +fn the_outside_cursor_targets_match_the_labels() { + let labels: Vec = (0..6) + .map(|index| { + let target = Cursor::Outside(index).target().unwrap(); + target.label() + }) + .collect(); + assert_eq!(labels, vec!["1-18", "EVEN", "RED", "BLACK", "ODD", "19-36"]); } #[test] fn parses_a_roulette_result() { - let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; - let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"); + let bets = r#"{"chips":[{"target":{"bet":"outside","kind":"red"},"amount":30}]}"#; + let response = format!("ROULETTE_RESULT session-1 60 30 1030 17 black {bets}"); let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap(); assert_eq!(result.session_id, "session-1"); - assert_eq!(result.winnings, 360); + assert_eq!(result.winnings, 60); assert_eq!(result.bet_amount, 30); - assert_eq!(result.balance, 1330); + assert_eq!(result.balance, 1030); assert_eq!(result.number, 17); assert_eq!(result.color, "black"); assert_eq!(result.bets, bets); @@ -168,7 +338,7 @@ fn parses_a_roulette_result() { fn rejects_bad_roulette_results() { use zagan_bet::client::games::response::parse_roulette_response; - let valid_bets = r#"{"red":10}"#; + let valid_bets = r#"{"chips":[]}"#; assert!( parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err() ); 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; use rusqlite::Connection; use zagan_bet::blackjack::BlackjackState; use zagan_bet::crash::CrashState; -use zagan_bet::roulette::Bets; +use zagan_bet::roulette::{BetTarget, Bets, Outside}; use zagan_bet::server::core::{ConnectionState, ServerCore}; use zagan_bet::server::database; use zagan_bet::server::games::crash::ManualClock; @@ -244,7 +244,13 @@ fn roulette_settles_split_bets_and_records_details() { register(&mut core, &mut state, "alice"); login(&mut core, &mut state, "alice"); - let bets = r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#; + let mut bets = Bets::default(); + bets.add(BetTarget::Outside { kind: Outside::Red }, 10); + bets.add(BetTarget::Outside { kind: Outside::Black }, 10); + bets.add(BetTarget::Outside { kind: Outside::Even }, 10); + bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); + bets.add(BetTarget::Straight { number: 17 }, 5); + let bets = bets.to_json(); let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); assert!( diff --git a/tests/storage.rs b/tests/storage.rs index 19936c2..e6ad2d1 100644 --- a/tests/storage.rs +++ b/tests/storage.rs @@ -88,55 +88,6 @@ fn forgetting_one_address_keeps_the_shared_credentials() { assert_eq!(client_database::get_credentials(&conn, "server-a"), None); } -#[test] -fn legacy_host_keyed_credentials_are_rekeyed_by_fingerprint() { - let (_, conn) = tmp_db("creds-migrate"); - conn.execute_batch( - "CREATE TABLE hosts ( - host TEXT PRIMARY KEY, - last_used DATETIME DEFAULT CURRENT_TIMESTAMP, - fingerprint TEXT - ); - CREATE TABLE credentials ( - host TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL - ); - INSERT INTO hosts (host, last_used, fingerprint) - VALUES ('a:1', '2020-01-01 00:00:00', 'server-a'); - INSERT INTO hosts (host, last_used, fingerprint) - VALUES ('b:2', '2021-01-01 00:00:00', 'server-a'); - INSERT INTO hosts (host, last_used, fingerprint) - VALUES ('c:3', '2022-01-01 00:00:00', NULL); - INSERT INTO credentials VALUES ('a:1', 'alice', 'old'); - INSERT INTO credentials VALUES ('b:2', 'alice', 'recent'); - INSERT INTO credentials VALUES ('c:3', 'carol', 'untrusted');", - ) - .unwrap(); - - client_database::init_db(&conn); - - assert_eq!( - client_database::get_credentials(&conn, "server-a"), - Some(("alice".to_string(), "recent".to_string())), - "the most recently used address wins when legacy rows disagree" - ); - assert_eq!( - client_database::get_credentials(&conn, "carol"), - None, - "a host with no trusted key cannot be re-keyed" - ); - - // The old table is gone: the column set is the fingerprint schema. - let mut statement = conn.prepare("PRAGMA table_info(credentials)").unwrap(); - let columns: Vec = statement - .query_map([], |row| row.get(1)) - .unwrap() - .filter_map(|row| row.ok()) - .collect(); - assert_eq!(columns, vec!["fingerprint", "username", "password"]); -} - #[test] fn client_last_host_tracking() { let (_, conn) = tmp_db("hosts"); -- cgit v1.2.3