summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/bin/client/connection.rs2
-rw-r--r--src/bin/client/main.rs15
-rw-r--r--src/bin/client/repl.rs2
-rw-r--r--src/client/core.rs102
-rw-r--r--src/client/database.rs41
-rw-r--r--src/client/games/manager.rs17
-rw-r--r--src/client/games/roulette.rs346
-rw-r--r--src/client/ui.rs17
-rw-r--r--src/roulette.rs215
-rw-r--r--src/server/database.rs6
-rw-r--r--src/server/games/roulette.rs2
11 files changed, 526 insertions, 239 deletions
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index ec5642c..7516836 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -49,7 +49,7 @@ pub fn connect(
49 Ok(Ok((tls, fp))) => (tls, fp), 49 Ok(Ok((tls, fp))) => (tls, fp),
50 Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), 50 Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))),
51 Err(mpsc::RecvTimeoutError::Timeout) => { 51 Err(mpsc::RecvTimeoutError::Timeout) => {
52 return Err("Connection timed out after 3 seconds".into()); 52 return Err("Connection timed out after 10 seconds".into());
53 } 53 }
54 Err(_) => return Err("Connection attempt failed".into()), 54 Err(_) => return Err("Connection attempt failed".into()),
55 }; 55 };
diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs
index 1b701d8..9018026 100644
--- a/src/bin/client/main.rs
+++ b/src/bin/client/main.rs
@@ -10,6 +10,21 @@ fn main() {
10 let args: Vec<String> = std::env::args().collect(); 10 let args: Vec<String> = std::env::args().collect();
11 let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); 11 let unsafe_mode = args.iter().any(|arg| arg == "--unsafe");
12 12
13 // Every game board is drawn in place, so refuse a terminal that cannot fit
14 // them before touching the network or the terminal mode.
15 let size = crossterm::terminal::size();
16 let (columns, rows) = match &size {
17 Ok((columns, rows)) => (*columns, *rows),
18 Err(_) => (0, 0),
19 };
20 if let Some(message) = zagan_bet::client::ui::terminal_too_small(columns, rows) {
21 eprintln!("{message}");
22 if size.is_err() {
23 eprintln!("Could not read the terminal size (is this a real terminal?).");
24 }
25 std::process::exit(1);
26 }
27
13 if unsafe_mode { 28 if unsafe_mode {
14 println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); 29 println!("WARNING: Running in UNSAFE mode - certificate verification disabled!");
15 } 30 }
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index d973596..2cf121d 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -712,6 +712,8 @@ fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> {
712 KeyCode::Backspace => Some(Key::Backspace), 712 KeyCode::Backspace => Some(Key::Backspace),
713 KeyCode::Up => Some(Key::Up), 713 KeyCode::Up => Some(Key::Up),
714 KeyCode::Down => Some(Key::Down), 714 KeyCode::Down => Some(Key::Down),
715 KeyCode::Left => Some(Key::Left),
716 KeyCode::Right => Some(Key::Right),
715 KeyCode::Tab => Some(Key::Tab), 717 KeyCode::Tab => Some(Key::Tab),
716 KeyCode::Esc => Some(Key::Esc), 718 KeyCode::Esc => Some(Key::Esc),
717 _ => None, 719 _ => None,
diff --git a/src/client/core.rs b/src/client/core.rs
index 06d9c42..8411d47 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -7,7 +7,8 @@ use crate::client::ui::{
7 record_history, 7 record_history,
8}; 8};
9use crate::crash::{multiplier_at, CrashState}; 9use crate::crash::{multiplier_at, CrashState};
10use crate::roulette::{Bets, NumberBet}; 10use crate::client::games::roulette::Direction;
11use crate::roulette::Bets;
11use std::time::{Duration, Instant}; 12use std::time::{Duration, Instant};
12 13
13#[derive(Debug, Clone, Copy, PartialEq, Eq)] 14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,6 +18,8 @@ pub enum Key {
17 Backspace, 18 Backspace,
18 Up, 19 Up,
19 Down, 20 Down,
21 Left,
22 Right,
20 Tab, 23 Tab,
21 Esc, 24 Esc,
22 CtrlC, 25 CtrlC,
@@ -76,14 +79,6 @@ enum Pending {
76 Info, 79 Info,
77} 80}
78 81
79#[derive(Debug, Clone, Copy)]
80enum RouletteSide {
81 Red,
82 Black,
83 Even,
84 Odd,
85}
86
87pub struct ClientCore { 82pub struct ClientCore {
88 input: String, 83 input: String,
89 history: Vec<String>, 84 history: Vec<String>,
@@ -171,6 +166,7 @@ impl ClientCore {
171 } 166 }
172 Key::Up => self.history_previous(), 167 Key::Up => self.history_previous(),
173 Key::Down => self.history_next(), 168 Key::Down => self.history_next(),
169 Key::Left | Key::Right => Vec::new(),
174 Key::Tab => self.complete(), 170 Key::Tab => self.complete(),
175 Key::Esc => Vec::new(), 171 Key::Esc => Vec::new(),
176 } 172 }
@@ -355,6 +351,19 @@ impl ClientCore {
355 } 351 }
356 352
357 fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { 353 fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> {
354 let direction = match key {
355 Key::Up => Some(Direction::Up),
356 Key::Down => Some(Direction::Down),
357 Key::Left => Some(Direction::Left),
358 Key::Right => Some(Direction::Right),
359 _ => None,
360 };
361 if let Some(direction) = direction {
362 let cursor = self.game.roulette_cursor().moved(direction);
363 self.game.set_roulette_cursor(&cursor);
364 return self.board_effects();
365 }
366
358 match key { 367 match key {
359 Key::CtrlC => vec![Effect::Quit], 368 Key::CtrlC => vec![Effect::Quit],
360 Key::Char('q') => { 369 Key::Char('q') => {
@@ -364,18 +373,9 @@ impl ClientCore {
364 Effect::Print(vec!["Game exited.".to_string()]), 373 Effect::Print(vec!["Game exited.".to_string()]),
365 ] 374 ]
366 } 375 }
367 Key::Esc => {
368 self.game.set_roulette_number_input("");
369 self.board_effects()
370 }
371 Key::Char(' ') => self.spin_roulette(), 376 Key::Char(' ') => self.spin_roulette(),
372 Key::Char('r') => self.add_roulette_side(RouletteSide::Red),
373 Key::Char('b') => self.add_roulette_side(RouletteSide::Black),
374 Key::Char('e') => self.add_roulette_side(RouletteSide::Even),
375 Key::Char('o') => self.add_roulette_side(RouletteSide::Odd),
376 Key::Char('c') => { 377 Key::Char('c') => {
377 self.game.set_roulette_bets(&Bets::default()); 378 self.game.set_roulette_bets(&Bets::default());
378 self.game.set_roulette_number_input("");
379 self.game.set_roulette_status("place"); 379 self.game.set_roulette_status("place");
380 self.board_effects() 380 self.board_effects()
381 } 381 }
@@ -389,63 +389,17 @@ impl ClientCore {
389 self.game.set_roulette_chip(chip); 389 self.game.set_roulette_chip(chip);
390 self.board_effects() 390 self.board_effects()
391 } 391 }
392 Key::Char(c) if c.is_ascii_digit() => { 392 Key::Enter => self.place_roulette_chip(),
393 let mut input = self.roulette_input();
394 if input.len() < 2 {
395 input.push(c);
396 self.game.set_roulette_number_input(&input);
397 }
398 self.board_effects()
399 }
400 Key::Backspace => {
401 let mut input = self.roulette_input();
402 input.pop();
403 self.game.set_roulette_number_input(&input);
404 self.board_effects()
405 }
406 Key::Enter => {
407 let input = self.roulette_input();
408 if !input.is_empty() {
409 match input.parse::<u8>() {
410 Ok(number) if number <= 36 => {
411 self.add_roulette_number(number);
412 self.game.set_roulette_number_input("");
413 }
414 _ => {
415 self.game.set_roulette_number_input("");
416 self.game.set_error("Number must be 0-36");
417 }
418 }
419 }
420 self.board_effects()
421 }
422 _ => Vec::new(), 393 _ => Vec::new(),
423 } 394 }
424 } 395 }
425 396
426 fn add_roulette_side(&mut self, side: RouletteSide) -> Vec<Effect> { 397 fn place_roulette_chip(&mut self) -> Vec<Effect> {
427 let chip = self.game.roulette_chip(); 398 let Some(target) = self.game.roulette_cursor().target() else {
428 let mut bets = self.game.roulette_bets(); 399 return Vec::new();
429 match side { 400 };
430 RouletteSide::Red => bets.red = bets.red.saturating_add(chip),
431 RouletteSide::Black => bets.black = bets.black.saturating_add(chip),
432 RouletteSide::Even => bets.even = bets.even.saturating_add(chip),
433 RouletteSide::Odd => bets.odd = bets.odd.saturating_add(chip),
434 }
435 self.place_roulette_bets(bets)
436 }
437
438 fn add_roulette_number(&mut self, number: u8) -> Vec<Effect> {
439 let chip = self.game.roulette_chip();
440 let mut bets = self.game.roulette_bets(); 401 let mut bets = self.game.roulette_bets();
441 if let Some(existing) = bets.numbers.iter_mut().find(|bet| bet.number == number) { 402 bets.add(target, self.game.roulette_chip());
442 existing.amount = existing.amount.saturating_add(chip);
443 } else {
444 bets.numbers.push(NumberBet {
445 number,
446 amount: chip,
447 });
448 }
449 self.place_roulette_bets(bets) 403 self.place_roulette_bets(bets)
450 } 404 }
451 405
@@ -487,14 +441,6 @@ impl ClientCore {
487 effects 441 effects
488 } 442 }
489 443
490 fn roulette_input(&self) -> String {
491 self.game
492 .get_current_state()
493 .and_then(|state| state.data.get("number_input"))
494 .cloned()
495 .unwrap_or_default()
496 }
497
498 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> { 444 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> {
499 let status = self 445 let status = self
500 .game 446 .game
diff --git a/src/client/database.rs b/src/client/database.rs
index 04a271a..c4601b5 100644
--- a/src/client/database.rs
+++ b/src/client/database.rs
@@ -20,14 +20,9 @@ pub fn init_db(conn: &Connection) {
20 [], 20 [],
21 ) 21 )
22 .unwrap(); 22 .unwrap();
23 // Migration for databases created before TOFU trust.
24 let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []);
25 23
26 // Credentials belong to a server identity (the certificate key), not to a 24 // Credentials belong to a server identity (the certificate key), not to a
27 // single address, so every address that presents the same key shares them. 25 // single address, so every address that presents the same key shares them.
28 if has_column(conn, "credentials", "host") {
29 migrate_credentials_to_fingerprints(conn);
30 }
31 conn.execute( 26 conn.execute(
32 "CREATE TABLE IF NOT EXISTS credentials ( 27 "CREATE TABLE IF NOT EXISTS credentials (
33 fingerprint TEXT PRIMARY KEY, 28 fingerprint TEXT PRIMARY KEY,
@@ -39,42 +34,6 @@ pub fn init_db(conn: &Connection) {
39 .unwrap(); 34 .unwrap();
40} 35}
41 36
42fn has_column(conn: &Connection, table: &str, column: &str) -> bool {
43 let Ok(mut statement) = conn.prepare(&format!("PRAGMA table_info({table})")) else {
44 return false;
45 };
46 let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else {
47 return false;
48 };
49 rows.flatten().any(|name| name == column)
50}
51
52/// Re-key host-bound credentials by the fingerprint of their host.
53///
54/// Rows whose host was already forgotten have no identity to attach to and are
55/// dropped; those addresses have to re-authenticate once.
56fn migrate_credentials_to_fingerprints(conn: &Connection) {
57 let tx = conn.unchecked_transaction().unwrap();
58 tx.execute_batch(
59 "DROP TABLE IF EXISTS credentials_by_identity;
60 CREATE TABLE credentials_by_identity (
61 fingerprint TEXT PRIMARY KEY,
62 username TEXT NOT NULL,
63 password TEXT NOT NULL
64 );
65 INSERT OR REPLACE INTO credentials_by_identity (fingerprint, username, password)
66 SELECT h.fingerprint, c.username, c.password
67 FROM credentials c
68 JOIN hosts h ON h.host = c.host
69 WHERE h.fingerprint IS NOT NULL
70 ORDER BY h.last_used ASC, h.host ASC;
71 DROP TABLE credentials;
72 ALTER TABLE credentials_by_identity RENAME TO credentials;",
73 )
74 .unwrap();
75 tx.commit().unwrap();
76}
77
78pub fn get_last_host(conn: &Connection) -> Option<String> { 37pub fn get_last_host(conn: &Connection) -> Option<String> {
79 let mut stmt = conn 38 let mut stmt = conn
80 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") 39 .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1")
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index b20ff44..bb2a8b5 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -55,7 +55,7 @@ impl GameManager {
55 ); 55 );
56 data.insert("chip".to_string(), "10".to_string()); 56 data.insert("chip".to_string(), "10".to_string());
57 data.insert("status".to_string(), "place".to_string()); 57 data.insert("status".to_string(), "place".to_string());
58 data.insert("number_input".to_string(), String::new()); 58 data.insert("cursor".to_string(), "lattice:0:3".to_string());
59 0 59 0
60 } else if game_type == "blackjack" { 60 } else if game_type == "blackjack" {
61 data.insert("status".to_string(), "ready".to_string()); 61 data.insert("status".to_string(), "ready".to_string());
@@ -234,11 +234,19 @@ impl GameManager {
234 } 234 }
235 } 235 }
236 236
237 pub fn set_roulette_number_input(&mut self, value: &str) { 237 pub fn roulette_cursor(&self) -> crate::client::games::roulette::Cursor {
238 self.current_session
239 .as_ref()
240 .and_then(|session| session.data.get("cursor"))
241 .map(|raw| crate::client::games::roulette::Cursor::parse(raw))
242 .unwrap_or_default()
243 }
244
245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) {
238 if let Some(session) = self.current_session.as_mut() { 246 if let Some(session) = self.current_session.as_mut() {
239 session 247 session
240 .data 248 .data
241 .insert("number_input".to_string(), value.to_string()); 249 .insert("cursor".to_string(), cursor.encoded());
242 } 250 }
243 } 251 }
244 252
@@ -274,9 +282,6 @@ impl GameManager {
274 session 282 session
275 .data 283 .data
276 .insert("status".to_string(), "result".to_string()); 284 .insert("status".to_string(), "result".to_string());
277 session
278 .data
279 .insert("number_input".to_string(), String::new());
280 } 285 }
281 286
282 pub fn apply_blackjack_state(&mut self, state: BlackjackState) { 287 pub fn apply_blackjack_state(&mut self, state: BlackjackState) {
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
index e80eca8..3cb1c58 100644
--- a/src/client/games/roulette.rs
+++ b/src/client/games/roulette.rs
@@ -1,10 +1,178 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::roulette::{is_even, is_odd, Bets}; 2use crate::roulette::{is_even, is_odd, BetTarget, Bets, Outside};
3 3
4pub const ROULETTE_BOARD_LINES: usize = 8; 4pub const ROULETTE_BOARD_LINES: usize = 12;
5
6/// Width of one number cell on the felt.
7const CELL: usize = 5;
8const NUM_COLUMNS: usize = 12;
9const NUM_WIDTH: usize = CELL * NUM_COLUMNS;
10const THIRD_WIDTH: usize = NUM_WIDTH / 3;
11const OUTSIDE_WIDTH: usize = NUM_WIDTH / 6;
12const OUTSIDE_ORDER: [Outside; 6] = [
13 Outside::Low,
14 Outside::Even,
15 Outside::Red,
16 Outside::Black,
17 Outside::Odd,
18 Outside::High,
19];
5 20
6pub struct RouletteGame; 21pub struct RouletteGame;
7 22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Direction {
25 Up,
26 Down,
27 Left,
28 Right,
29}
30
31/// Where the betting cursor is.
32///
33/// Over the numbers the cursor sits on a *lattice point*, the way a chip sits
34/// between numbers on a real felt: an inner point covers four numbers (corner),
35/// an edge point covers two (split), and an outer corner is a straight number.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Cursor {
38 Zero,
39 Lattice { x: usize, y: usize },
40 Dozen(usize),
41 Column(usize),
42 Outside(usize),
43}
44
45impl Default for Cursor {
46 fn default() -> Self {
47 // Bottom-left number (1).
48 Cursor::Lattice { x: 0, y: 3 }
49 }
50}
51
52impl Cursor {
53 pub fn parse(raw: &str) -> Self {
54 let parts: Vec<&str> = raw.split(':').collect();
55 match parts.as_slice() {
56 ["zero"] => Cursor::Zero,
57 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) {
58 (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y },
59 _ => Cursor::default(),
60 },
61 ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(),
62 ["column", index] => parse_index(index).map(Cursor::Column).unwrap_or_default(),
63 ["outside", index] => match index.parse::<usize>() {
64 Ok(index) if index < OUTSIDE_ORDER.len() => Cursor::Outside(index),
65 _ => Cursor::default(),
66 },
67 _ => Cursor::default(),
68 }
69 }
70
71 pub fn encoded(&self) -> String {
72 match self {
73 Cursor::Zero => "zero".to_string(),
74 Cursor::Lattice { x, y } => format!("lattice:{x}:{y}"),
75 Cursor::Dozen(index) => format!("dozen:{index}"),
76 Cursor::Column(index) => format!("column:{index}"),
77 Cursor::Outside(index) => format!("outside:{index}"),
78 }
79 }
80
81 pub fn target(&self) -> Option<BetTarget> {
82 match self {
83 Cursor::Zero => Some(BetTarget::Straight { number: 0 }),
84 Cursor::Lattice { x, y } => lattice_target(*x, *y),
85 Cursor::Dozen(index) => Some(BetTarget::Dozen {
86 index: *index as u8,
87 }),
88 Cursor::Column(index) => Some(BetTarget::Column {
89 index: *index as u8,
90 }),
91 Cursor::Outside(index) => Some(BetTarget::Outside {
92 kind: OUTSIDE_ORDER[*index],
93 }),
94 }
95 }
96
97 /// Numbers the cursor would cover, for highlighting the felt.
98 pub fn numbers(&self) -> Vec<u8> {
99 self.target().map(|target| target.numbers()).unwrap_or_default()
100 }
101
102 pub fn moved(&self, direction: Direction) -> Cursor {
103 match (*self, direction) {
104 (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 },
105 (Cursor::Zero, _) => Cursor::Zero,
106
107 (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice {
108 x: x.saturating_sub(1),
109 y,
110 },
111 (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice {
112 x: (x + 1).min(NUM_COLUMNS),
113 y,
114 },
115 (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero,
116 (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 },
117 (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)),
118 (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 },
119
120 (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)),
121 (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)),
122 (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice {
123 x: 4 * index + 1,
124 y: 3,
125 },
126 (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index),
127
128 (Cursor::Column(index), Direction::Left) => Cursor::Column(index.saturating_sub(1)),
129 (Cursor::Column(index), Direction::Right) => Cursor::Column((index + 1).min(2)),
130 (Cursor::Column(index), Direction::Up) => Cursor::Dozen(index),
131 (Cursor::Column(index), Direction::Down) => Cursor::Outside(index * 2),
132
133 (Cursor::Outside(index), Direction::Left) => {
134 Cursor::Outside(index.saturating_sub(1))
135 }
136 (Cursor::Outside(index), Direction::Right) => {
137 Cursor::Outside((index + 1).min(OUTSIDE_ORDER.len() - 1))
138 }
139 (Cursor::Outside(index), Direction::Up) => Cursor::Column(index / 2),
140 (Cursor::Outside(index), Direction::Down) => Cursor::Outside(index),
141 }
142 }
143}
144
145fn parse_index(raw: &str) -> Option<usize> {
146 raw.parse::<usize>().ok().filter(|index| *index < 3)
147}
148
149/// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner).
150fn lattice_target(x: usize, y: usize) -> Option<BetTarget> {
151 let cell = |column: isize, row: isize| -> Option<u8> {
152 if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) {
153 return None;
154 }
155 Some((3 * column + (3 - row)) as u8)
156 };
157
158 let x = x as isize;
159 let y = y as isize;
160 let mut numbers: Vec<u8> = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)]
161 .into_iter()
162 .filter_map(|(column, row)| cell(column, row))
163 .collect();
164 numbers.sort_unstable();
165
166 match numbers.as_slice() {
167 [number] => Some(BetTarget::Straight { number: *number }),
168 [a, b] => Some(BetTarget::Split { a: *a, b: *b }),
169 [a, b, c, d] => Some(BetTarget::Corner {
170 numbers: [*a, *b, *c, *d],
171 }),
172 _ => None,
173 }
174}
175
8impl RouletteGame { 176impl RouletteGame {
9 pub fn new() -> Self { 177 pub fn new() -> Self {
10 Self 178 Self
@@ -14,40 +182,99 @@ impl RouletteGame {
14 state 182 state
15 .data 183 .data
16 .get("bets") 184 .get("bets")
17 .and_then(|raw| serde_json::from_str(raw).ok()) 185 .and_then(|raw| Bets::from_json(raw).ok())
18 .unwrap_or_default() 186 .unwrap_or_default()
19 } 187 }
20 188
21 fn bets_line(bets: &Bets) -> String { 189 fn cursor(state: &GameState) -> Cursor {
22 let mut parts = Vec::new(); 190 state
23 if bets.red > 0 { 191 .data
24 parts.push(format!("RED {}", bets.red)); 192 .get("cursor")
25 } 193 .map(|raw| Cursor::parse(raw))
26 if bets.black > 0 { 194 .unwrap_or_default()
27 parts.push(format!("BLACK {}", bets.black)); 195 }
28 }
29 if bets.even > 0 {
30 parts.push(format!("EVEN {}", bets.even));
31 }
32 if bets.odd > 0 {
33 parts.push(format!("ODD {}", bets.odd));
34 }
35 for bet in &bets.numbers {
36 parts.push(format!("#{} {}", bet.number, bet.amount));
37 }
38 196
39 let list = if parts.is_empty() { 197 fn zero_cell(cursor: Cursor) -> String {
40 "(none)".to_string() 198 let cell = if cursor == Cursor::Zero {
199 "[ 0 ]".to_string()
41 } else { 200 } else {
42 parts.join(" ") 201 format!("{:^CELL$}", 0)
43 }; 202 };
44 format!("Bets: {} Total: {} PLN", list, bets.total()) 203 format!("{cell:<NUM_WIDTH$}")
204 }
205
206 fn number_row(row: usize, covered: &[u8]) -> String {
207 (0..NUM_COLUMNS)
208 .map(|column| {
209 let number = (3 * column + (3 - row)) as u8;
210 if covered.contains(&number) {
211 format!("[{number:^3}]")
212 } else {
213 format!("{number:^CELL$}")
214 }
215 })
216 .collect()
217 }
218
219 fn wide_cell(label: &str, width: usize, active: bool) -> String {
220 if active {
221 format!("[{:^inner$}]", label, inner = width - 2)
222 } else {
223 format!("{label:^width$}")
224 }
225 }
226
227 fn dozens_row(cursor: Cursor) -> String {
228 ["1st 12", "2nd 12", "3rd 12"]
229 .iter()
230 .enumerate()
231 .map(|(index, label)| {
232 Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Dozen(index))
233 })
234 .collect()
235 }
236
237 fn columns_row(cursor: Cursor) -> String {
238 ["col 1 2:1", "col 2 2:1", "col 3 2:1"]
239 .iter()
240 .enumerate()
241 .map(|(index, label)| {
242 Self::wide_cell(label, THIRD_WIDTH, cursor == Cursor::Column(index))
243 })
244 .collect()
245 }
246
247 fn outside_row(cursor: Cursor) -> String {
248 OUTSIDE_ORDER
249 .iter()
250 .enumerate()
251 .map(|(index, kind)| {
252 Self::wide_cell(kind.label(), OUTSIDE_WIDTH, cursor == Cursor::Outside(index))
253 })
254 .collect()
45 } 255 }
46 256
47 fn result_lines(state: &GameState) -> (String, String) { 257 fn bets_line(bets: &Bets) -> String {
258 let mut parts: Vec<String> = bets
259 .chips
260 .iter()
261 .filter(|chip| chip.amount > 0)
262 .map(|chip| format!("{} {}", chip.target.label(), chip.amount))
263 .collect();
264 if parts.is_empty() {
265 parts.push("(none)".to_string());
266 }
267 let mut list = parts.join(" ");
268 if list.chars().count() > 40 {
269 list = list.chars().take(39).collect::<String>() + "…";
270 }
271 format!("Bets: {list} Total: {} PLN", bets.total())
272 }
273
274 fn status_line(state: &GameState) -> String {
48 if let Some(error) = state.data.get("error") { 275 if let Some(error) = state.data.get("error") {
49 if !error.is_empty() { 276 if !error.is_empty() {
50 return (format!("ERROR: {error}"), String::new()); 277 return format!("ERROR: {error}");
51 } 278 }
52 } 279 }
53 280
@@ -77,15 +304,16 @@ impl RouletteGame {
77 .get("winnings") 304 .get("winnings")
78 .map(String::as_str) 305 .map(String::as_str)
79 .unwrap_or("0"); 306 .unwrap_or("0");
80 let result = if winnings == "0" { 307 let stake = state.bet_amount;
81 "No win.".to_string() 308 if winnings == "0" {
309 format!("No win at {number} {color}{parity}")
82 } else { 310 } else {
83 format!("Won {winnings} PLN") 311 let profit = winnings.parse::<u64>().unwrap_or(0).saturating_sub(stake);
84 }; 312 format!("Won {winnings} PLN at {number} {color}{parity} (+{profit})")
85 (format!("Winning: {number} {color}{parity}"), result) 313 }
86 } 314 }
87 Some("spinning") => ("Spinning...".to_string(), String::new()), 315 Some("spinning") => "Spinning...".to_string(),
88 _ => ("Place bets, then SPACE to spin.".to_string(), String::new()), 316 _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(),
89 } 317 }
90 } 318 }
91} 319}
@@ -99,27 +327,25 @@ impl Game for RouletteGame {
99 let bets = Self::bets(state); 327 let bets = Self::bets(state);
100 let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); 328 let chip = state.data.get("chip").map(String::as_str).unwrap_or("10");
101 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); 329 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?");
102 let input = state 330 let cursor = Self::cursor(state);
103 .data 331 let covered = cursor.numbers();
104 .get("number_input")
105 .map(String::as_str)
106 .unwrap_or("");
107 let input_line = if input.is_empty() {
108 String::new()
109 } else {
110 format!("Number: {input}_")
111 };
112 let (status, result) = Self::result_lines(state);
113 332
114 format!( 333 let lines = [
115 "=== ROULETTE ===\r\nBalance: {} PLN Chip: {} PLN\r\n{}\r\n{}\r\n{}\r\n{}\r\n[R]ed [B]lack [E]ven [O]dd [#]Number [C]lear\r\n[+/-] Chip [SPACE] Spin [Q] Quit", 334 "=== ROULETTE ===".to_string(),
116 balance, 335 format!("Balance: {balance} PLN Chip: {chip} PLN"),
117 chip, 336 Self::zero_cell(cursor),
337 Self::number_row(0, &covered),
338 Self::number_row(1, &covered),
339 Self::number_row(2, &covered),
340 Self::dozens_row(cursor),
341 Self::columns_row(cursor),
342 Self::outside_row(cursor),
118 Self::bets_line(&bets), 343 Self::bets_line(&bets),
119 input_line, 344 Self::status_line(state),
120 status, 345 "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit"
121 result 346 .to_string(),
122 ) 347 ];
348 lines.join("\r\n")
123 } 349 }
124 350
125 fn render_lines(&self, _state: &GameState) -> usize { 351 fn render_lines(&self, _state: &GameState) -> usize {
@@ -129,20 +355,20 @@ impl Game for RouletteGame {
129 fn get_help(&self) -> String { 355 fn get_help(&self) -> String {
130 "ROULETTE (European)\n\ 356 "ROULETTE (European)\n\
131 ==================\n\ 357 ==================\n\
132 37 pockets: 0 plus 1-36. 0 is green and loses all outside bets.\n\ 358 37 pockets: 0 plus 1-36. 0 is green and loses every outside bet.\n\
133 \n\ 359 \n\
134 Betting:\n\ 360 Betting with the cursor:\n\
135 R / B / E / O - add the current chip to red/black/even/odd\n\ 361 arrows - move over the felt; between numbers picks a split or corner\n\
136 digits + Enter - add the current chip to that number\n\ 362 ENTER - place the current chip where the cursor is\n\
137 C - clear all bets\n\ 363 C - clear all bets\n\
138 + or = / - - change the chip size\n\ 364 + or = / - - change the chip size\n\
139 SPACE - spin when at least one bet is placed\n\ 365 SPACE - spin when at least one bet is placed\n\
140 Q - exit\n\ 366 Q - exit\n\
141 \n\ 367 \n\
142 Payouts:\n\ 368 Payouts (returned per unit staked):\n\
143 straight number: 35:1 (36x returned)\n\ 369 straight 36x, split 18x, corner 9x\n\
144 red/black: 1:1 (2x returned)\n\ 370 dozen 3x, column 3x\n\
145 even/odd: 1:1 (2x returned)\n\ 371 red/black, even/odd, 1-18/19-36: 2x\n\
146 Multiple bets are settled independently and added together." 372 Multiple bets are settled independently and added together."
147 .to_string() 373 .to_string()
148 } 374 }
diff --git a/src/client/ui.rs b/src/client/ui.rs
index 9d65087..6161166 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -244,6 +244,23 @@ pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> {
244 244
245/// Format remembered hosts so a 64-character fingerprint does not wrap on an 245/// Format remembered hosts so a 64-character fingerprint does not wrap on an
246/// 80-column terminal. 246/// 80-column terminal.
247/// Smallest terminal every game fits in. Roulette's felt is 12 lines and the
248/// controls are ~72 columns, so anything smaller would wrap or hide the board.
249pub const MIN_COLUMNS: u16 = 80;
250pub const MIN_ROWS: u16 = 24;
251
252/// Refuse to start when the terminal cannot show every game.
253pub fn terminal_too_small(columns: u16, rows: u16) -> Option<String> {
254 if columns >= MIN_COLUMNS && rows >= MIN_ROWS {
255 return None;
256 }
257 Some(format!(
258 "zagan.bet needs a terminal of at least {MIN_COLUMNS}x{MIN_ROWS} cells, \
259 but this one is {columns}x{rows}.\n\
260 Resize it and start again."
261 ))
262}
263
247/// Render remembered hosts. 264/// Render remembered hosts.
248/// 265///
249/// Two addresses that present the same certificate key are the same server, so 266/// Two addresses that present the same certificate key are the same server, so
diff --git a/src/roulette.rs b/src/roulette.rs
index 426ecc3..e7b3aad 100644
--- a/src/roulette.rs
+++ b/src/roulette.rs
@@ -14,6 +14,12 @@ pub const RED_NUMBERS: [u8; 18] = [
14 14
15/// Total return on a winning straight (35:1 plus the stake). 15/// Total return on a winning straight (35:1 plus the stake).
16pub const STRAIGHT_RETURN: u64 = 36; 16pub const STRAIGHT_RETURN: u64 = 36;
17/// Total return on a winning split (17:1 plus the stake).
18pub const SPLIT_RETURN: u64 = 18;
19/// Total return on a winning corner (8:1 plus the stake).
20pub const CORNER_RETURN: u64 = 9;
21/// Total return on a winning dozen or column (2:1 plus the stake).
22pub const THIRD_RETURN: u64 = 3;
17/// Total return on a winning outside bet (1:1 plus the stake). 23/// Total return on a winning outside bet (1:1 plus the stake).
18pub const EVEN_MONEY_RETURN: u64 = 2; 24pub const EVEN_MONEY_RETURN: u64 = 2;
19 25
@@ -47,67 +53,176 @@ pub fn rtp() -> f64 {
47 36.0 / 37.0 53 36.0 / 37.0
48} 54}
49 55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum Outside {
59 Low,
60 High,
61 Even,
62 Odd,
63 Red,
64 Black,
65}
66
67impl Outside {
68 pub fn wins(self, number: u8) -> bool {
69 match self {
70 Outside::Low => (1..=18).contains(&number),
71 Outside::High => (19..=36).contains(&number),
72 Outside::Even => is_even(number),
73 Outside::Odd => is_odd(number),
74 Outside::Red => is_red(number),
75 Outside::Black => is_black(number),
76 }
77 }
78
79 pub fn label(self) -> &'static str {
80 match self {
81 Outside::Low => "1-18",
82 Outside::High => "19-36",
83 Outside::Even => "EVEN",
84 Outside::Odd => "ODD",
85 Outside::Red => "RED",
86 Outside::Black => "BLACK",
87 }
88 }
89}
90
91/// One place on the felt where a chip can sit.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "bet", rename_all = "snake_case")]
94pub enum BetTarget {
95 Straight { number: u8 },
96 Split { a: u8, b: u8 },
97 Corner { numbers: [u8; 4] },
98 /// 0 = 1-12, 1 = 13-24, 2 = 25-36.
99 Dozen { index: u8 },
100 /// 0 = 1,4,...,34; 1 = 2,5,...,35; 2 = 3,6,...,36.
101 Column { index: u8 },
102 Outside { kind: Outside },
103}
104
105impl BetTarget {
106 /// Numbers this target covers on the number grid (empty for outside bets).
107 pub fn numbers(&self) -> Vec<u8> {
108 match self {
109 BetTarget::Straight { number } => vec![*number],
110 BetTarget::Split { a, b } => vec![*a, *b],
111 BetTarget::Corner { numbers } => numbers.to_vec(),
112 _ => Vec::new(),
113 }
114 }
115
116 /// Total returned per unit staked, or 0 when the bet loses.
117 pub fn return_multiplier(&self, number: u8) -> u64 {
118 match self {
119 BetTarget::Straight { number: straight } => {
120 u64::from(*straight == number) * STRAIGHT_RETURN
121 }
122 BetTarget::Split { a, b } => u64::from(*a == number || *b == number) * SPLIT_RETURN,
123 BetTarget::Corner { numbers } => {
124 u64::from(numbers.contains(&number)) * CORNER_RETURN
125 }
126 BetTarget::Dozen { index } => {
127 let wins = number != 0 && (number - 1) / 12 == *index;
128 u64::from(wins) * THIRD_RETURN
129 }
130 BetTarget::Column { index } => {
131 let wins = match index {
132 0 => number % 3 == 1,
133 1 => number % 3 == 2,
134 2 => number != 0 && number % 3 == 0,
135 _ => false,
136 };
137 u64::from(wins) * THIRD_RETURN
138 }
139 BetTarget::Outside { kind } => u64::from(kind.wins(number)) * EVEN_MONEY_RETURN,
140 }
141 }
142
143 pub fn label(&self) -> String {
144 match self {
145 BetTarget::Straight { number } => number.to_string(),
146 BetTarget::Split { a, b } => format!("{a}/{b}"),
147 BetTarget::Corner { numbers } => numbers
148 .iter()
149 .map(u8::to_string)
150 .collect::<Vec<_>>()
151 .join("/"),
152 BetTarget::Dozen { index } => match index {
153 0 => "1st12".to_string(),
154 1 => "2nd12".to_string(),
155 _ => "3rd12".to_string(),
156 },
157 BetTarget::Column { index } => format!("col{}", index + 1),
158 BetTarget::Outside { kind } => kind.label().to_string(),
159 }
160 }
161
162 fn validates(&self) -> bool {
163 match self {
164 BetTarget::Straight { number } => *number <= 36,
165 BetTarget::Split { a, b } => *a <= 36 && *b <= 36 && a != b,
166 BetTarget::Corner { numbers } => {
167 numbers.iter().all(|number| *number <= 36)
168 && numbers
169 .iter()
170 .enumerate()
171 .all(|(index, number)| !numbers[..index].contains(number))
172 }
173 BetTarget::Dozen { index } | BetTarget::Column { index } => *index < 3,
174 BetTarget::Outside { .. } => true,
175 }
176 }
177}
178
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct NumberBet { 180pub struct Chip {
52 pub number: u8, 181 pub target: BetTarget,
53 pub amount: u64, 182 pub amount: u64,
54} 183}
55 184
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] 185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Bets { 186pub struct Bets {
58 #[serde(default)] 187 #[serde(default)]
59 pub red: u64, 188 pub chips: Vec<Chip>,
60 #[serde(default)]
61 pub black: u64,
62 #[serde(default)]
63 pub even: u64,
64 #[serde(default)]
65 pub odd: u64,
66 #[serde(default)]
67 pub numbers: Vec<NumberBet>,
68} 189}
69 190
70impl Bets { 191impl Bets {
71 pub fn total(&self) -> u64 { 192 pub fn total(&self) -> u64 {
72 let mut total = self 193 self.chips
73 .red 194 .iter()
74 .saturating_add(self.black) 195 .fold(0u64, |total, chip| total.saturating_add(chip.amount))
75 .saturating_add(self.even)
76 .saturating_add(self.odd);
77 for bet in &self.numbers {
78 total = total.saturating_add(bet.amount);
79 }
80 total
81 } 196 }
82 197
83 pub fn is_empty(&self) -> bool { 198 pub fn is_empty(&self) -> bool {
84 self.total() == 0 199 self.chips.iter().all(|chip| chip.amount == 0)
85 } 200 }
86 201
87 /// Total returned for a winning number, including returned stakes. 202 pub fn amount_on(&self, target: &BetTarget) -> u64 {
88 pub fn winnings(&self, number: u8) -> u64 { 203 self.chips
89 let mut winnings = 0u64; 204 .iter()
90 205 .filter(|chip| &chip.target == target)
91 if number != 0 { 206 .fold(0u64, |total, chip| total.saturating_add(chip.amount))
92 if is_red(number) { 207 }
93 winnings = winnings.saturating_add(self.red.saturating_mul(EVEN_MONEY_RETURN));
94 } else {
95 winnings = winnings.saturating_add(self.black.saturating_mul(EVEN_MONEY_RETURN));
96 }
97 if is_even(number) {
98 winnings = winnings.saturating_add(self.even.saturating_mul(EVEN_MONEY_RETURN));
99 } else {
100 winnings = winnings.saturating_add(self.odd.saturating_mul(EVEN_MONEY_RETURN));
101 }
102 }
103 208
104 for bet in &self.numbers { 209 /// Put a chip on a target, merging with an existing one.
105 if bet.number == number { 210 pub fn add(&mut self, target: BetTarget, amount: u64) {
106 winnings = winnings.saturating_add(bet.amount.saturating_mul(STRAIGHT_RETURN)); 211 if let Some(chip) = self.chips.iter_mut().find(|chip| chip.target == target) {
107 } 212 chip.amount = chip.amount.saturating_add(amount);
213 } else if amount > 0 {
214 self.chips.push(Chip { target, amount });
108 } 215 }
216 }
109 217
110 winnings 218 /// Total returned for a winning number, including returned stakes.
219 pub fn winnings(&self, number: u8) -> u64 {
220 self.chips.iter().fold(0u64, |total, chip| {
221 total.saturating_add(
222 chip.amount
223 .saturating_mul(chip.target.return_multiplier(number)),
224 )
225 })
111 } 226 }
112 227
113 pub fn validate(&self, balance: u64) -> Result<(), String> { 228 pub fn validate(&self, balance: u64) -> Result<(), String> {
@@ -117,14 +232,22 @@ impl Bets {
117 if self.total() > balance { 232 if self.total() > balance {
118 return Err("Insufficient balance".to_string()); 233 return Err("Insufficient balance".to_string());
119 } 234 }
120 for bet in &self.numbers { 235 for chip in &self.chips {
121 if bet.number > 36 { 236 if !chip.target.validates() {
122 return Err(format!("Invalid number: {}", bet.number)); 237 return Err(format!("Invalid bet: {}", chip.target.label()));
123 } 238 }
124 if bet.amount == 0 { 239 if chip.amount == 0 {
125 return Err("A bet amount must be positive".to_string()); 240 return Err("A bet amount must be positive".to_string());
126 } 241 }
127 } 242 }
128 Ok(()) 243 Ok(())
129 } 244 }
245
246 pub fn to_json(&self) -> String {
247 serde_json::to_string(self).unwrap_or_else(|_| "{\"chips\":[]}".to_string())
248 }
249
250 pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
251 serde_json::from_str(raw)
252 }
130} 253}
diff --git a/src/server/database.rs b/src/server/database.rs
index 3cd86e4..6dd71a3 100644
--- a/src/server/database.rs
+++ b/src/server/database.rs
@@ -11,12 +11,6 @@ pub fn init_db(conn: &Connection) {
11 [], 11 [],
12 ) 12 )
13 .unwrap(); 13 .unwrap();
14 // Add column if not exists (for migration)
15 let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []);
16 let _ = conn.execute(
17 "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000",
18 [],
19 );
20 conn.execute( 14 conn.execute(
21 "CREATE TABLE IF NOT EXISTS plays ( 15 "CREATE TABLE IF NOT EXISTS plays (
22 session_id TEXT PRIMARY KEY, 16 session_id TEXT PRIMARY KEY,
diff --git a/src/server/games/roulette.rs b/src/server/games/roulette.rs
index 0a58323..de47dee 100644
--- a/src/server/games/roulette.rs
+++ b/src/server/games/roulette.rs
@@ -36,7 +36,7 @@ impl Game for RouletteGame {
36 } 36 }
37 37
38 fn get_description(&self) -> &'static str { 38 fn get_description(&self) -> &'static str {
39 "European roulette. Bet on red/black, even/odd, or a straight number." 39 "European roulette. Straights, splits, corners, dozens, columns and outside bets."
40 } 40 }
41 41
42 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { 42 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState {