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 --- 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 +++ 5 files changed, 338 insertions(+), 185 deletions(-) (limited to 'src/client') 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 -- cgit v1.2.3