summaryrefslogtreecommitdiffstats
path: root/src/client/games
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
commit49df948de25d28601469e9ca993f33530989ebd0 (patch)
tree291aae0f57d0d7d12a27eb28038e43f942d6b991 /src/client/games
parentv2.4.0: key stored credentials by certificate fingerprint (diff)
downloadzagan-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/games')
-rw-r--r--src/client/games/manager.rs17
-rw-r--r--src/client/games/roulette.rs346
2 files changed, 297 insertions, 66 deletions
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 }