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