summaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
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};
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