summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 20:11:41 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 20:11:41 +0100
commite920b20fafe375eec15b24eb8cde4b1455dd01af (patch)
tree308d2f7999177a753528779f6ec7926f3fa50818
parentv4.1.0: add the egui workspace member and the roulette style spike (diff)
downloadzagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.tar.gz
zagan-bet-e920b20fafe375eec15b24eb8cde4b1455dd01af.zip
v4.1.1: pointer-first betting and visible payouts in the GUI
The felt snaps the hover to the shared fine lattice: inner half of a cell is a straight, an edge a split, a crossing a corner. A translucent chip previews the target and the covered cells tint. Left click adds the selected chip, right or middle click takes one back through the new Bets::reduce; C or CLEAR empties the slip, and SPIN stays disabled with no chips. The result now pays out: the wheel leaves the winning pocket under the pointer, the winning cell and pocket get a gold outline, and the sidebar reports the amount returned and the net for the round (NO WIN on a loss). Placing a chip after a result starts the next round without losing the wheel position. The column row now spans the full width like a real table instead of hugging the left edge. Adds tests for lattice snapping, target naming and taking chips back.
-rw-r--r--AGENTS.md4
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md12
-rw-r--r--docs/TESTING.md34
-rw-r--r--docs/screenshots/gui-roulette-1080p.pngbin126927 -> 128158 bytes
-rw-r--r--docs/screenshots/gui-roulette-result-1080p.pngbin127523 -> 127283 bytes
-rw-r--r--gui/Cargo.toml2
-rw-r--r--gui/src/app.rs253
-rw-r--r--gui/src/roulette.rs161
-rw-r--r--gui/src/theme.rs6
-rw-r--r--gui/tests/layout.rs76
-rw-r--r--src/client/games/roulette.rs8
-rw-r--r--src/roulette.rs10
-rw-r--r--tests/roulette.rs20
14 files changed, 402 insertions, 186 deletions
diff --git a/AGENTS.md b/AGENTS.md
index c3b555d..6207d7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,6 +73,10 @@
73 look; keep a style module and a reference screen. The palette is derived 73 look; keep a style module and a reference screen. The palette is derived
74 from the terminal's ANSI colours, and felt green is only ever the roulette 74 from the terminal's ANSI colours, and felt green is only ever the roulette
75 table. 75 table.
76- The GUI felt is pointer-first: the hover snaps to the shared fine lattice
77 (cell centre, edge, crossing), left click adds the chip and right or middle
78 click takes one back. The terminal keeps its arrow-key cursor; the GUI does
79 not paint it.
76 80
77## Client TUI 81## Client TUI
78- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, 82- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`,
diff --git a/Cargo.toml b/Cargo.toml
index ed9d4f3..53acc77 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ members = ["gui"]
3 3
4[package] 4[package]
5name = "zagan-bet" 5name = "zagan-bet"
6version = "4.1.0" 6version = "4.1.1"
7edition = "2024" 7edition = "2024"
8license = "GPL-3.0-or-later" 8license = "GPL-3.0-or-later"
9 9
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md
index 8c3db0a..bb63530 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -79,11 +79,17 @@ crash board from its local clock.
79 green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type 79 green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type
80 scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers, 80 scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers,
81 chips and labels. Licences sit next to the files in `gui/assets/fonts/`. 81 chips and labels. Licences sit next to the files in `gui/assets/fonts/`.
82- `gui/src/roulette.rs` draws the felt, chips, the fine lattice cursor and the 82- `gui/src/roulette.rs` draws the felt, chips and wheel from a `RouletteView`;
83 wheel from a `RouletteView`; it owns the geometry, not the rules. 83 it owns the geometry, not the rules. The felt is pointer-first: the hover
84 snaps to the fine lattice, so the inner half of a cell is a straight, an edge
85 is a split and a crossing is a corner. Left click adds the selected chip,
86 right or middle click takes one back, and a translucent chip previews the
87 target under the pointer.
84- `gui/src/app.rs` composes the persistent header (connection, account, 88- `gui/src/app.rs` composes the persistent header (connection, account,
85 balance) with the table and the betting sidebar. The demo state animates a 89 balance) with the table and the betting sidebar. The demo state animates a
86 2.4 s wheel spin and highlights the winning pocket and cell. 90 2.4 s wheel spin, highlights the winning pocket and cell, and reports the
91 amount paid and the net for the round. SPIN and CLEAR sit side by side so
92 clearing is never below the fold.
87- `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle, 93- `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle,
88 table aspect, bet names). UI wiring tests arrive with `egui_kittest` in 94 table aspect, bet names). UI wiring tests arrive with `egui_kittest` in
89 Phase 4. 95 Phase 4.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index f42b84a..32fc628 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -125,21 +125,27 @@ Windows 10 before approving the look.
1251. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header 1251. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header
126 keeps its right margin at both sizes and shows ONLINE, host, ping, account 126 keeps its right margin at both sizes and shows ONLINE, host, ping, account
127 and balance; the table is centred and the sidebar is full height. 127 and balance; the table is centred and the sidebar is full height.
1282. The felt shows 0, the 3x12 numbers, dozens, columns and the six outside 1282. The felt shows 0, the 3x12 numbers, dozens, columns (each a quarter of the
129 cells. Chips sit on their targets: a straight in the cell, a corner on the 129 row) and the six outside cells. Chips sit on their targets: a straight in
130 lattice crossing, an outside bet in its box. 130 the cell, a corner on the lattice crossing, an outside bet in its box.
1313. Click a pocket: a chip of the selected denomination appears and the slip 1313. Hover a number: a translucent chip of the selected denomination appears.
132 gains a row. Chips are 10/25/50/100; the selected one has a cyan ring. 132 In the inner half of a cell it is a straight (one cell tints); near an edge
1334. Arrow keys move the cursor. Over the numbers the cyan marker sits on the 133 it snaps to a split (two cells tint) and near a corner to a corner (four
134 cell or lattice point and the covered cells tint; leaving the table walks 134 cells tint). Hovering the dozens, columns and outside boxes moves the
135 dozens, columns and outside. 135 preview there.
1364. Left click adds the chip where the preview is; right or middle click takes
137 one chip of that value back, dropping the row when it reaches zero. The
138 slip and total follow every change. C or CLEAR empties the slip.
1365. Space or SPIN animates the wheel for about 2.4 s and stops with the winning 1395. Space or SPIN animates the wheel for about 2.4 s and stops with the winning
137 pocket under the pointer. The winning cell gets a gold outline, RESULT names 140 pocket under the pointer. The winning cell gets a gold outline and LAST
138 the number and colour, and LAST gains a dot. SPIN is disabled during the 141 gains a dot. RESULT names the number and colour, WON shows the amount paid
139 spin. 142 and the net for the round, and a losing spin says NO WIN. SPIN, CLEAR and
1406. Clear bets empties the slip and the total. Resize to 1100x720: the sidebar 143 the chips are disabled during the spin.
141 scrolls, nothing overlaps the header, and the table keeps its aspect ratio. 1446. Placing a chip after a result starts the next round: the payout row goes
1427. Compare the 1080p window against the approved reference screenshot. 145 back to PLACE YOUR BETS while the wheel stays where it stopped. Resize to
146 1100x720: the sidebar scrolls, nothing overlaps the header, and the table
147 keeps its aspect ratio.
1487. Compare the 1080p window against the approved reference screenshots.
143 149
144## Manual roulette checklist 150## Manual roulette checklist
145 151
diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png
index b6c9b17..2f30aa7 100644
--- a/docs/screenshots/gui-roulette-1080p.png
+++ b/docs/screenshots/gui-roulette-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-roulette-result-1080p.png b/docs/screenshots/gui-roulette-result-1080p.png
index 0f37133..a7d425c 100644
--- a/docs/screenshots/gui-roulette-result-1080p.png
+++ b/docs/screenshots/gui-roulette-result-1080p.png
Binary files differ
diff --git a/gui/Cargo.toml b/gui/Cargo.toml
index a2f4154..a0dfb86 100644
--- a/gui/Cargo.toml
+++ b/gui/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet-gui" 2name = "zagan-bet-gui"
3version = "4.1.0" 3version = "4.1.1"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet" 6description = "Desktop frontend for zagan-bet"
diff --git a/gui/src/app.rs b/gui/src/app.rs
index fde117a..b5a50b3 100644
--- a/gui/src/app.rs
+++ b/gui/src/app.rs
@@ -10,11 +10,12 @@ use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, 10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2, 11 RichText, Sense, Stroke, Ui, Vec2, vec2,
12}; 12};
13use zagan_bet::client::games::roulette::{Cursor, Direction}; 13use zagan_bet::client::games::roulette::Cursor;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; 15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION; 16use zagan_bet::version::VERSION;
17 17
18use crate::roulette::FeltAction;
18use crate::{roulette, theme}; 19use crate::{roulette, theme};
19 20
20const HEADER_HEIGHT: f32 = 64.0; 21const HEADER_HEIGHT: f32 = 64.0;
@@ -218,10 +219,11 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) {
218 219
219 ui.label(theme::label("Chip")); 220 ui.label(theme::label("Chip"));
220 ui.add_space(theme::SPACE_2); 221 ui.add_space(theme::SPACE_2);
222 let spinning = demo.phase == RoulettePhase::Spinning;
221 ui.horizontal(|ui| { 223 ui.horizontal(|ui| {
222 ui.spacing_mut().item_spacing.x = theme::SPACE_2; 224 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
223 for amount in CHIP_SIZES { 225 for amount in CHIP_SIZES {
224 if chip_button(ui, amount, demo.chip == amount).clicked() { 226 if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() {
225 demo.chip = amount; 227 demo.chip = amount;
226 } 228 }
227 } 229 }
@@ -247,80 +249,118 @@ fn sidebar(ui: &mut Ui, demo: &mut Demo) {
247 ui.add_space(theme::SPACE_2); 249 ui.add_space(theme::SPACE_2);
248 bet_row(ui, "Total", demo.bets.total()); 250 bet_row(ui, "Total", demo.bets.total());
249 251
250 ui.add_space(theme::SPACE_3); 252 ui.add_space(theme::SPACE_4);
251 let spinning = demo.phase == RoulettePhase::Spinning; 253 let actions = ui.available_width();
252 let spin = Button::new( 254 let clear_width = 108.0;
253 RichText::new("SPIN") 255 ui.horizontal(|ui| {
254 .font(theme::mono(theme::FONT_MONO)) 256 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
255 .color(theme::BG),
256 )
257 .fill(if spinning {
258 theme::WIN.gamma_multiply(0.45)
259 } else {
260 theme::WIN
261 })
262 .corner_radius(theme::RADIUS_MD)
263 .min_size(vec2(ui.available_width(), 44.0));
264 if ui.add_enabled(!spinning, spin).clicked() {
265 demo.start_spin(ui.ctx());
266 }
267 257
268 ui.add_space(theme::SPACE_2); 258 let spin = Button::new(
269 let clear = Button::new( 259 RichText::new("SPIN")
270 RichText::new("Clear bets") 260 .font(theme::mono(theme::FONT_MONO))
271 .font(theme::sans(12.0)) 261 .color(theme::BG),
272 .color(theme::TEXT_DIM), 262 )
273 ) 263 .fill(if spinning {
274 .frame(false); 264 theme::WIN.gamma_multiply(0.45)
275 if ui 265 } else {
276 .add_enabled(!demo.bets.is_empty() && !spinning, clear) 266 theme::WIN
277 .clicked() 267 })
278 { 268 .corner_radius(theme::RADIUS_MD)
279 demo.bets = Bets::default(); 269 .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0));
280 } 270 let can_spin = !spinning && !demo.bets.is_empty();
271 if ui.add_enabled(can_spin, spin).clicked() {
272 demo.start_spin(ui.ctx());
273 }
274
275 let clear = Button::new(
276 RichText::new("CLEAR")
277 .font(theme::mono(12.0))
278 .color(theme::TEXT_DIM),
279 )
280 .fill(theme::SURFACE_RAISED)
281 .stroke(Stroke::new(1.0, theme::LINE))
282 .corner_radius(theme::RADIUS_MD)
283 .min_size(vec2(clear_width, 44.0));
284 if ui
285 .add_enabled(!demo.bets.is_empty() && !spinning, clear)
286 .clicked()
287 {
288 demo.begin_placing();
289 demo.bets = Bets::default();
290 }
291 });
281} 292}
282 293
283fn result_line(ui: &mut Ui, demo: &Demo) { 294fn result_line(ui: &mut Ui, demo: &Demo) {
284 ui.horizontal(|ui| { 295 ui.horizontal(|ui| match (demo.phase, demo.number) {
285 match (demo.phase, demo.number) { 296 (RoulettePhase::Result, Some(number)) => {
286 (RoulettePhase::Result, Some(number)) => { 297 ui.label(theme::label("Result"));
287 ui.label(theme::label("Result")); 298 ui.label(
288 ui.label( 299 RichText::new(format!("{number} {}", wheel_color(number).to_uppercase()))
289 RichText::new(number.to_string()) 300 .font(theme::mono(14.0))
290 .font(theme::mono(15.0)) 301 .color(color_of(number)),
291 .color(color_of(number)), 302 );
292 ); 303 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
293 ui.label( 304 payout(ui, demo);
294 RichText::new(wheel_color(number).to_uppercase()) 305 });
295 .font(theme::mono(12.0)) 306 }
296 .color(theme::TEXT_DIM), 307 (RoulettePhase::Spinning, _) => {
297 ); 308 ui.label(
298 } 309 RichText::new("SPINNING…")
299 (RoulettePhase::Spinning, _) => { 310 .font(theme::mono(12.0))
300 ui.label( 311 .color(theme::GOLD),
301 RichText::new("SPINNING…") 312 );
302 .font(theme::mono(12.0)) 313 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
303 .color(theme::GOLD),
304 );
305 }
306 _ => {
307 ui.label( 314 ui.label(
308 RichText::new("Place your bets") 315 RichText::new(format!("{} chips", demo.bets.chips.len()))
309 .font(theme::sans(12.0)) 316 .font(theme::mono(10.0))
310 .color(theme::TEXT_DIM), 317 .color(theme::TEXT_FAINT),
311 ); 318 );
312 } 319 });
313 } 320 }
314 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 321 _ => {
315 ui.label( 322 ui.label(
316 RichText::new(format!("{} chips", demo.bets.chips.len())) 323 RichText::new("Place your bets")
317 .font(theme::mono(10.0)) 324 .font(theme::sans(12.0))
318 .color(theme::TEXT_FAINT), 325 .color(theme::TEXT_DIM),
319 ); 326 );
320 }); 327 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
328 ui.label(
329 RichText::new(format!("{} chips", demo.bets.chips.len()))
330 .font(theme::mono(10.0))
331 .color(theme::TEXT_FAINT),
332 );
333 });
334 }
321 }); 335 });
322} 336}
323 337
338/// What the round paid, and the net for the spin.
339fn payout(ui: &mut Ui, demo: &Demo) {
340 let winnings = demo.winnings.unwrap_or(0);
341 if winnings == 0 {
342 ui.label(
343 RichText::new("NO WIN")
344 .font(theme::mono(13.0))
345 .color(theme::TEXT_FAINT),
346 );
347 return;
348 }
349
350 let profit = winnings as i64 - demo.spent as i64;
351 ui.label(
352 RichText::new(format!("({})", theme::signed_money(profit)))
353 .font(theme::sans(11.0))
354 .color(if profit >= 0 { theme::WIN } else { theme::LOSS }),
355 );
356 ui.label(
357 RichText::new(theme::money(winnings as i64))
358 .font(theme::mono(15.0))
359 .color(theme::WIN),
360 );
361 ui.label(theme::label("Won"));
362}
363
324fn bet_row(ui: &mut Ui, name: &str, amount: u64) { 364fn bet_row(ui: &mut Ui, name: &str, amount: u64) {
325 ui.horizontal(|ui| { 365 ui.horizontal(|ui| {
326 ui.label( 366 ui.label(
@@ -338,8 +378,13 @@ fn bet_row(ui: &mut Ui, name: &str, amount: u64) {
338 }); 378 });
339} 379}
340 380
341fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { 381fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response {
342 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click()); 382 let sense = if enabled {
383 Sense::click()
384 } else {
385 Sense::hover()
386 };
387 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense);
343 let center = rect.center(); 388 let center = rect.center();
344 let radius = 20.0; 389 let radius = 20.0;
345 let painter = ui.painter(); 390 let painter = ui.painter();
@@ -350,7 +395,11 @@ fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response {
350 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), 395 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
351 ); 396 );
352 } 397 }
353 roulette::paint_chip(painter, center, radius, amount); 398 if enabled {
399 roulette::paint_chip(painter, center, radius, amount);
400 } else {
401 roulette::paint_chip_preview(painter, center, radius, amount);
402 }
354 response 403 response
355} 404}
356 405
@@ -395,11 +444,20 @@ fn table(ui: &mut Ui, demo: &mut Demo) {
395 let side_pad = ((available.x - size.x) * 0.5).max(0.0); 444 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
396 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); 445 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
397 let chip = demo.chip; 446 let chip = demo.chip;
447 let placing = demo.phase != RoulettePhase::Spinning;
398 ui.add_space(top_pad); 448 ui.add_space(top_pad);
399 ui.horizontal(|ui| { 449 ui.horizontal(|ui| {
400 ui.add_space(side_pad); 450 ui.add_space(side_pad);
401 if let Some(target) = roulette::felt(ui, size, &view) { 451 match roulette::felt(ui, size, &view) {
402 demo.bets.add(target, chip); 452 Some(FeltAction::Place(target)) if placing => {
453 demo.begin_placing();
454 demo.bets.add(target, chip);
455 }
456 Some(FeltAction::Reduce(target)) if placing => {
457 demo.begin_placing();
458 demo.bets.reduce(&target, chip);
459 }
460 _ => {}
403 } 461 }
404 }); 462 });
405 463
@@ -422,7 +480,7 @@ fn table(ui: &mut Ui, demo: &mut Demo) {
422 ui.add_space(theme::SPACE_2); 480 ui.add_space(theme::SPACE_2);
423 ui.label( 481 ui.label(
424 RichText::new( 482 RichText::new(
425 "Click a pocket to place a chip · arrow keys move the cursor · space spins", 483 "Click to place · right or middle click takes back · C clears · space spins",
426 ) 484 )
427 .font(theme::sans(12.0)) 485 .font(theme::sans(12.0))
428 .color(theme::TEXT_FAINT), 486 .color(theme::TEXT_FAINT),
@@ -442,7 +500,6 @@ struct Spin {
442 500
443struct Demo { 501struct Demo {
444 bets: Bets, 502 bets: Bets,
445 cursor: Cursor,
446 chip: u64, 503 chip: u64,
447 phase: RoulettePhase, 504 phase: RoulettePhase,
448 number: Option<u8>, 505 number: Option<u8>,
@@ -450,6 +507,8 @@ struct Demo {
450 spin: Option<Spin>, 507 spin: Option<Spin>,
451 spins: usize, 508 spins: usize,
452 last: Vec<u8>, 509 last: Vec<u8>,
510 winnings: Option<u64>,
511 spent: u64,
453 balance: i64, 512 balance: i64,
454 host: &'static str, 513 host: &'static str,
455 ping_ms: u32, 514 ping_ms: u32,
@@ -471,8 +530,6 @@ impl Demo {
471 530
472 Self { 531 Self {
473 bets, 532 bets,
474 // A split with no chip on it, so the fine lattice cursor shows.
475 cursor: Cursor::Lattice { x: 18, y: 3 },
476 chip: 25, 533 chip: 25,
477 phase: RoulettePhase::Placing, 534 phase: RoulettePhase::Placing,
478 number: None, 535 number: None,
@@ -480,6 +537,8 @@ impl Demo {
480 spin: None, 537 spin: None,
481 spins: 0, 538 spins: 0,
482 last: Vec::new(), 539 last: Vec::new(),
540 winnings: None,
541 spent: 0,
483 balance: 1000, 542 balance: 1000,
484 host: "127.0.0.1:11525", 543 host: "127.0.0.1:11525",
485 ping_ms: 41, 544 ping_ms: 41,
@@ -494,15 +553,24 @@ impl Demo {
494 balance: Some(self.balance), 553 balance: Some(self.balance),
495 chip: self.chip, 554 chip: self.chip,
496 bets: self.bets.clone(), 555 bets: self.bets.clone(),
497 cursor: self.cursor, 556 cursor: Cursor::default(),
498 phase: self.phase, 557 phase: self.phase,
499 number: self.number, 558 number: self.number,
500 color: self.number.map(|number| wheel_color(number).to_string()), 559 color: self.number.map(|number| wheel_color(number).to_string()),
501 winnings: None, 560 winnings: self.winnings,
502 error: None, 561 error: None,
503 } 562 }
504 } 563 }
505 564
565 /// A bet change after a result starts the next round: the wheel stays
566 /// where it stopped, but the payout and the winning highlight do not.
567 fn begin_placing(&mut self) {
568 if self.phase == RoulettePhase::Result {
569 self.phase = RoulettePhase::Placing;
570 self.winnings = None;
571 }
572 }
573
506 fn tick(&mut self, ctx: &Context) { 574 fn tick(&mut self, ctx: &Context) {
507 let now = ctx.input(|input| input.time); 575 let now = ctx.input(|input| input.time);
508 let mut finished = None; 576 let mut finished = None;
@@ -519,52 +587,37 @@ impl Demo {
519 self.spin = None; 587 self.spin = None;
520 self.phase = RoulettePhase::Result; 588 self.phase = RoulettePhase::Result;
521 self.number = Some(number); 589 self.number = Some(number);
590 self.winnings = Some(self.bets.winnings(number));
522 self.last.insert(0, number); 591 self.last.insert(0, number);
523 self.last.truncate(12); 592 self.last.truncate(12);
524 } 593 }
525 } 594 }
526 595
596 /// Pointer-first: the felt reads the mouse. The keyboard keeps the two
597 /// verbs that have no pointer equivalent in profile (spin and clear).
527 fn keys(&mut self, ctx: &Context) { 598 fn keys(&mut self, ctx: &Context) {
528 if self.phase == RoulettePhase::Spinning { 599 if self.phase == RoulettePhase::Spinning {
529 return; 600 return;
530 } 601 }
531 let (left, right, up, down) = ctx.input(|input| {
532 (
533 input.key_pressed(Key::ArrowLeft),
534 input.key_pressed(Key::ArrowRight),
535 input.key_pressed(Key::ArrowUp),
536 input.key_pressed(Key::ArrowDown),
537 )
538 });
539 for (pressed, direction) in [
540 (left, Direction::Left),
541 (right, Direction::Right),
542 (up, Direction::Up),
543 (down, Direction::Down),
544 ] {
545 if pressed {
546 self.cursor = self.cursor.moved(direction);
547 }
548 }
549 if ctx.memory(|memory| memory.focused().is_none()) { 602 if ctx.memory(|memory| memory.focused().is_none()) {
550 if ctx.input(|input| input.key_pressed(Key::Enter)) {
551 if let Some(target) = self.cursor.target() {
552 let chip = self.chip;
553 self.bets.add(target, chip);
554 }
555 }
556 if ctx.input(|input| input.key_pressed(Key::Space)) { 603 if ctx.input(|input| input.key_pressed(Key::Space)) {
557 self.start_spin(ctx); 604 self.start_spin(ctx);
558 } 605 }
606 if ctx.input(|input| input.key_pressed(Key::C)) {
607 self.begin_placing();
608 self.bets = Bets::default();
609 }
559 } 610 }
560 } 611 }
561 612
562 fn start_spin(&mut self, ctx: &Context) { 613 fn start_spin(&mut self, ctx: &Context) {
563 if self.phase == RoulettePhase::Spinning { 614 if self.phase == RoulettePhase::Spinning || self.bets.is_empty() {
564 return; 615 return;
565 } 616 }
566 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; 617 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()];
567 self.spins += 1; 618 self.spins += 1;
619 self.spent = self.bets.total();
620 self.winnings = None;
568 621
569 let segment = TAU / WHEEL.len() as f32; 622 let segment = TAU / WHEEL.len() as f32;
570 let index = WHEEL 623 let index = WHEEL
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs
index 22c46d6..f674251 100644
--- a/gui/src/roulette.rs
+++ b/gui/src/roulette.rs
@@ -7,10 +7,10 @@
7use std::f32::consts::{FRAC_PI_2, TAU}; 7use std::f32::consts::{FRAC_PI_2, TAU};
8 8
9use eframe::egui::{ 9use eframe::egui::{
10 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, 10 Align2, Color32, CornerRadius, Painter, PointerButton, Pos2, Rect, Sense, Shape, Stroke,
11 pos2, vec2, 11 StrokeKind, Ui, Vec2, pos2, vec2,
12}; 12};
13use zagan_bet::client::games::roulette::Cursor; 13use zagan_bet::client::games::roulette::target_at as lattice_target;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; 15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red};
16 16
@@ -42,18 +42,38 @@ pub fn size_for(available: Vec2) -> Vec2 {
42 vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) 42 vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0)
43} 43}
44 44
45/// The table painted at `size`; returns the target clicked this frame. 45/// What the pointer did to the felt this frame.
46pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { 46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum FeltAction {
48 /// Left click: add the selected chip.
49 Place(BetTarget),
50 /// Right or middle click: take one chip back.
51 Reduce(BetTarget),
52}
53
54/// The table painted at `size`; returns the bet the pointer changed.
55pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<FeltAction> {
47 let (rect, response) = ui.allocate_exact_size(size, Sense::click()); 56 let (rect, response) = ui.allocate_exact_size(size, Sense::click());
48 let felt = Felt::new(rect); 57 let felt = Felt::new(rect);
49 let hovered = response.hover_pos().and_then(|pos| felt.hit(pos)); 58 let hovered = if view.phase == RoulettePhase::Spinning {
59 None
60 } else {
61 response.hover_pos().and_then(|pos| felt.target_at(pos))
62 };
50 let painter = ui.painter(); 63 let painter = ui.painter();
51 64
52 painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); 65 painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK);
53 66
67 // Every cell the hovered bet covers tints, so a split or corner reads
68 // before the chip lands.
69 let covered = hovered.as_ref().map(BetTarget::numbers).unwrap_or_default();
54 for number in 0..=36u8 { 70 for number in 0..=36u8 {
55 let highlighted = hovered == Some(BetTarget::Straight { number }); 71 felt.paint_number(
56 felt.paint_number(painter, number, highlighted); 72 painter,
73 number,
74 covered.contains(&number),
75 hovered == Some(BetTarget::Straight { number }),
76 );
57 } 77 }
58 for index in 0..3 { 78 for index in 0..3 {
59 felt.paint_dozen( 79 felt.paint_dozen(
@@ -89,24 +109,38 @@ pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> {
89 } 109 }
90 } 110 }
91 111
92 felt.paint_cursor(painter, &view.cursor);
93
94 for chip in &view.bets.chips { 112 for chip in &view.bets.chips {
95 if let Some(center) = felt.anchor(&chip.target) { 113 if let Some(center) = felt.anchor(&chip.target) {
96 paint_chip(painter, center, felt.cell * 0.26, chip.amount); 114 paint_chip(painter, center, felt.cell_size() * 0.26, chip.amount);
115 }
116 }
117
118 if let Some(target) = &hovered {
119 if let Some(center) = felt.anchor(target) {
120 paint_chip_preview(painter, center, felt.cell_size() * 0.26, view.chip);
97 } 121 }
98 } 122 }
99 123
100 if response.clicked() { hovered } else { None } 124 if response.clicked_by(PointerButton::Secondary) || response.clicked_by(PointerButton::Middle) {
125 hovered.map(FeltAction::Reduce)
126 } else if response.clicked() {
127 hovered.map(FeltAction::Place)
128 } else {
129 None
130 }
101} 131}
102 132
103struct Felt { 133/// Geometry of one table: the felt is 13 cells wide (zero plus twelve
134/// columns) and 3 + 0.72 + 0.55 + 0.62 cells tall.
135///
136/// Public so the pointer snapping can be tested without a window.
137pub struct Felt {
104 cell: f32, 138 cell: f32,
105 origin: Pos2, 139 origin: Pos2,
106} 140}
107 141
108impl Felt { 142impl Felt {
109 fn new(board: Rect) -> Self { 143 pub fn new(board: Rect) -> Self {
110 let inner = board.shrink(BOARD_PAD); 144 let inner = board.shrink(BOARD_PAD);
111 Self { 145 Self {
112 cell: inner.width() / (COLUMNS as f32 + 1.0), 146 cell: inner.width() / (COLUMNS as f32 + 1.0),
@@ -114,7 +148,11 @@ impl Felt {
114 } 148 }
115 } 149 }
116 150
117 fn number_rect(&self, number: u8) -> Rect { 151 pub fn cell_size(&self) -> f32 {
152 self.cell
153 }
154
155 pub fn number_rect(&self, number: u8) -> Rect {
118 debug_assert!(number <= 36); 156 debug_assert!(number <= 36);
119 if number == 0 { 157 if number == 0 {
120 return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); 158 return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32));
@@ -138,8 +176,8 @@ impl Felt {
138 fn column_rect(&self, index: usize) -> Rect { 176 fn column_rect(&self, index: usize) -> Rect {
139 let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); 177 let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW);
140 Rect::from_min_size( 178 Rect::from_min_size(
141 pos2(self.origin.x + self.cell * (1.0 + index as f32), y), 179 pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y),
142 vec2(self.cell, self.cell * COLUMN_ROW), 180 vec2(self.cell * 4.0, self.cell * COLUMN_ROW),
143 ) 181 )
144 } 182 }
145 183
@@ -152,7 +190,7 @@ impl Felt {
152 } 190 }
153 191
154 /// Where a chip sits for this target. 192 /// Where a chip sits for this target.
155 fn anchor(&self, target: &BetTarget) -> Option<Pos2> { 193 pub fn anchor(&self, target: &BetTarget) -> Option<Pos2> {
156 Some(match target { 194 Some(match target {
157 BetTarget::Straight { number } => self.number_rect(*number).center(), 195 BetTarget::Straight { number } => self.number_rect(*number).center(),
158 BetTarget::Split { a, b } => { 196 BetTarget::Split { a, b } => {
@@ -178,20 +216,28 @@ impl Felt {
178 }) 216 })
179 } 217 }
180 218
181 /// The fine-grid point the cursor occupies over the numbers. 219 fn grid_origin(&self) -> Pos2 {
182 fn lattice(&self, x: usize, y: usize) -> Pos2 { 220 self.origin + vec2(self.cell, 0.0)
183 pos2(
184 self.origin.x + self.cell * (1.0 + x as f32 * 0.5),
185 self.origin.y + self.cell * (y as f32 * 0.5),
186 )
187 } 221 }
188 222
189 fn hit(&self, pos: Pos2) -> Option<BetTarget> { 223 /// The bet under the pointer, snapped to the fine lattice: a cell centre
190 for number in 0..=36u8 { 224 /// is a straight, a line is a split, a crossing is a corner.
191 if self.number_rect(number).contains(pos) { 225 pub fn target_at(&self, pos: Pos2) -> Option<BetTarget> {
192 return Some(BetTarget::Straight { number }); 226 if self.number_rect(0).contains(pos) {
193 } 227 return Some(BetTarget::Straight { number: 0 });
228 }
229
230 let grid = self.grid_origin();
231 let fx = (pos.x - grid.x) / self.cell;
232 let fy = (pos.y - grid.y) / self.cell;
233 if (0.0..COLUMNS as f32).contains(&fx) && (0.0..ROWS as f32).contains(&fy) {
234 // Thresholds sit a quarter cell from a centre, so the inner half
235 // of a cell is a straight and its edges are splits or corners.
236 let x = (fx * 2.0).round().clamp(1.0, 23.0) as usize;
237 let y = (fy * 2.0).round().clamp(1.0, 5.0) as usize;
238 return lattice_target(x, y);
194 } 239 }
240
195 for index in 0..3usize { 241 for index in 0..3usize {
196 if self.dozen_rect(index).contains(pos) { 242 if self.dozen_rect(index).contains(pos) {
197 return Some(BetTarget::Dozen { index: index as u8 }); 243 return Some(BetTarget::Dozen { index: index as u8 });
@@ -208,7 +254,7 @@ impl Felt {
208 None 254 None
209 } 255 }
210 256
211 fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { 257 fn paint_number(&self, painter: &Painter, number: u8, covered: bool, hovered: bool) {
212 let rect = self.number_rect(number); 258 let rect = self.number_rect(number);
213 let fill = if number == 0 { 259 let fill = if number == 0 {
214 theme::ROULETTE_ZERO 260 theme::ROULETTE_ZERO
@@ -224,7 +270,9 @@ impl Felt {
224 Stroke::new(1.0, theme::FELT_LINE), 270 Stroke::new(1.0, theme::FELT_LINE),
225 StrokeKind::Inside, 271 StrokeKind::Inside,
226 ); 272 );
227 if hovered { 273 if covered {
274 painter.rect_filled(rect, CornerRadius::ZERO, theme::ACCENT.gamma_multiply(0.16));
275 } else if hovered {
228 painter.rect_filled( 276 painter.rect_filled(
229 rect, 277 rect,
230 CornerRadius::ZERO, 278 CornerRadius::ZERO,
@@ -303,28 +351,6 @@ impl Felt {
303 } 351 }
304 } 352 }
305 353
306 fn paint_cursor(&self, painter: &Painter, cursor: &Cursor) {
307 let stroke = Stroke::new(2.0, theme::ACCENT);
308 match cursor {
309 Cursor::Zero => outline(painter, self.number_rect(0), stroke),
310 Cursor::Lattice { x, y } => {
311 for number in cursor.numbers() {
312 painter.rect_filled(
313 self.number_rect(number),
314 CornerRadius::ZERO,
315 theme::ACCENT.gamma_multiply(0.14),
316 );
317 }
318 let point = self.lattice(*x, *y);
319 painter.circle_filled(point, self.cell * 0.05, theme::ACCENT);
320 painter.circle_stroke(point, self.cell * 0.14, stroke);
321 }
322 Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke),
323 Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke),
324 Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke),
325 }
326 }
327
328 fn number_font(&self) -> f32 { 354 fn number_font(&self) -> f32 {
329 (self.cell * 0.40).clamp(12.0, 22.0) 355 (self.cell * 0.40).clamp(12.0, 22.0)
330 } 356 }
@@ -334,15 +360,6 @@ impl Felt {
334 } 360 }
335} 361}
336 362
337fn outline(painter: &Painter, rect: Rect, stroke: Stroke) {
338 painter.rect_stroke(
339 rect.shrink(1.0),
340 theme::RADIUS_SM,
341 stroke,
342 StrokeKind::Inside,
343 );
344}
345
346/// A single chip, also used by the sidebar's chip tray. 363/// A single chip, also used by the sidebar's chip tray.
347pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { 364pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) {
348 let fill = chip_color(amount); 365 let fill = chip_color(amount);
@@ -366,6 +383,23 @@ pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) {
366 ); 383 );
367} 384}
368 385
386/// The chip under the pointer, showing what a click would add.
387pub fn paint_chip_preview(painter: &Painter, center: Pos2, radius: f32, amount: u64) {
388 painter.circle_filled(center, radius, chip_color(amount).gamma_multiply(0.45));
389 painter.circle_stroke(
390 center,
391 radius,
392 Stroke::new(2.0, Color32::WHITE.gamma_multiply(0.85)),
393 );
394 painter.text(
395 center,
396 Align2::CENTER_CENTER,
397 amount,
398 theme::mono(radius * 0.85),
399 Color32::WHITE,
400 );
401}
402
369/// Colour per denomination, as a chip tray would be stocked. 403/// Colour per denomination, as a chip tray would be stocked.
370pub fn chip_color(amount: u64) -> Color32 { 404pub fn chip_color(amount: u64) -> Color32 {
371 match amount { 405 match amount {
@@ -444,9 +478,8 @@ pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) {
444 painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); 478 painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE));
445 479
446 let displayed = match view.phase { 480 let displayed = match view.phase {
447 RoulettePhase::Result => view.number,
448 RoulettePhase::Spinning => Some(number_under(rotation)), 481 RoulettePhase::Spinning => Some(number_under(rotation)),
449 RoulettePhase::Placing => None, 482 _ => view.number,
450 }; 483 };
451 match displayed { 484 match displayed {
452 Some(number) => { 485 Some(number) => {
diff --git a/gui/src/theme.rs b/gui/src/theme.rs
index 9270aa6..0e5a475 100644
--- a/gui/src/theme.rs
+++ b/gui/src/theme.rs
@@ -120,6 +120,12 @@ pub fn money(amount: i64) -> String {
120 format!("{} Kr", group_digits(amount)) 120 format!("{} Kr", group_digits(amount))
121} 121}
122 122
123/// Net amount with an explicit sign: `"+800 Kr"`, `"-90 Kr"`.
124pub fn signed_money(amount: i64) -> String {
125 let sign = if amount < 0 { "-" } else { "+" };
126 format!("{sign}{} Kr", group_digits(amount.abs()))
127}
128
123// --------------------------------------------------------------------------- 129// ---------------------------------------------------------------------------
124// Installation 130// Installation
125// --------------------------------------------------------------------------- 131// ---------------------------------------------------------------------------
diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs
index e0c0475..ab5a62e 100644
--- a/gui/tests/layout.rs
+++ b/gui/tests/layout.rs
@@ -4,8 +4,9 @@
4 4
5use std::f32::consts::TAU; 5use std::f32::consts::TAU;
6 6
7use eframe::egui::{Pos2, Rect, pos2, vec2};
7use zagan_bet::roulette::{BetTarget, Outside}; 8use zagan_bet::roulette::{BetTarget, Outside};
8use zagan_bet_gui::roulette::{number_under, size_for, target_name}; 9use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name};
9use zagan_bet_gui::theme::{group_digits, money}; 10use zagan_bet_gui::theme::{group_digits, money};
10 11
11#[test] 12#[test]
@@ -49,6 +50,79 @@ fn table_size_keeps_the_felt_aspect_and_fits() {
49 assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01); 50 assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01);
50} 51}
51 52
53fn felt() -> Felt {
54 Felt::new(Rect::from_min_size(
55 Pos2::ZERO,
56 size_for(vec2(1300.0, 900.0)),
57 ))
58}
59
60#[test]
61fn the_pointer_snaps_to_straights_splits_and_corners() {
62 let felt = felt();
63 let cell = felt.cell_size();
64 let center = felt.number_rect(17).center();
65
66 // The inner half of a cell is a straight, even slightly off centre.
67 assert_eq!(
68 felt.target_at(center + vec2(cell * 0.2, 0.0)),
69 Some(BetTarget::Straight { number: 17 })
70 );
71
72 // A quarter cell from the centre is the line: right of 17 is 20, above is 18.
73 assert_eq!(
74 felt.target_at(center + vec2(cell * 0.3, 0.0)),
75 Some(BetTarget::Split { a: 17, b: 20 })
76 );
77 assert_eq!(
78 felt.target_at(center + vec2(0.0, -cell * 0.3)),
79 Some(BetTarget::Split { a: 17, b: 18 })
80 );
81
82 // Near both lines at once is the corner of the four cells.
83 assert_eq!(
84 felt.target_at(center + vec2(cell * 0.3, -cell * 0.3)),
85 Some(BetTarget::Corner {
86 numbers: [17, 18, 20, 21]
87 })
88 );
89
90 // Zero is a straight and nothing else.
91 assert_eq!(
92 felt.target_at(felt.number_rect(0).center()),
93 Some(BetTarget::Straight { number: 0 })
94 );
95}
96
97#[test]
98fn the_pointer_finds_the_wide_bets_below_the_grid() {
99 let felt = felt();
100 let cell = felt.cell_size();
101 let x = felt.number_rect(17).center().x;
102 let grid_bottom = felt.number_rect(16).bottom();
103
104 // 17 sits in the second dozen (13-24).
105 assert_eq!(
106 felt.target_at(pos2(x, grid_bottom + cell * 0.3)),
107 Some(BetTarget::Dozen { index: 1 })
108 );
109
110 // Below the dozens is the column row; 17 is in column 2 (2,5,...,17).
111 assert_eq!(
112 felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.3))),
113 Some(BetTarget::Column { index: 1 })
114 );
115
116 // And below that, the outside row: 17's column lands on RED.
117 assert_eq!(
118 felt.target_at(pos2(x, grid_bottom + cell * (0.72 + 0.55 + 0.3))),
119 Some(BetTarget::Outside { kind: Outside::Red })
120 );
121
122 // Outside the board is not a bet.
123 assert_eq!(felt.target_at(pos2(-1.0, -1.0)), None);
124}
125
52#[test] 126#[test]
53fn targets_read_as_bet_slips() { 127fn targets_read_as_bet_slips() {
54 assert_eq!( 128 assert_eq!(
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
index ec9573c..f0ed816 100644
--- a/src/client/games/roulette.rs
+++ b/src/client/games/roulette.rs
@@ -95,7 +95,7 @@ impl Cursor {
95 pub fn target(&self) -> Option<BetTarget> { 95 pub fn target(&self) -> Option<BetTarget> {
96 match self { 96 match self {
97 Cursor::Zero => Some(BetTarget::Straight { number: 0 }), 97 Cursor::Zero => Some(BetTarget::Straight { number: 0 }),
98 Cursor::Lattice { x, y } => felt_target(*x, *y), 98 Cursor::Lattice { x, y } => target_at(*x, *y),
99 Cursor::Dozen(index) => Some(BetTarget::Dozen { 99 Cursor::Dozen(index) => Some(BetTarget::Dozen {
100 index: *index as u8, 100 index: *index as u8,
101 }), 101 }),
@@ -171,7 +171,11 @@ fn cell_number(column: usize, row: usize) -> Option<u8> {
171} 171}
172 172
173/// Bet under a point of the fine grid. 173/// Bet under a point of the fine grid.
174fn felt_target(x: usize, y: usize) -> Option<BetTarget> { 174///
175/// Both frontends use this: the terminal moves a cursor over the lattice, and
176/// the GUI snaps a mouse position to it. Odd coordinates are cell centres,
177/// even coordinates are the lines where a split or corner chip sits.
178pub fn target_at(x: usize, y: usize) -> Option<BetTarget> {
175 if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { 179 if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) {
176 return None; 180 return None;
177 } 181 }
diff --git a/src/roulette.rs b/src/roulette.rs
index 6b73c2a..b697e31 100644
--- a/src/roulette.rs
+++ b/src/roulette.rs
@@ -226,6 +226,16 @@ impl Bets {
226 } 226 }
227 } 227 }
228 228
229 /// Take `amount` back from one target, dropping the chip when it empties.
230 ///
231 /// This is the felt undo: a right or middle click removes one chip.
232 pub fn reduce(&mut self, target: &BetTarget, amount: u64) {
233 if let Some(chip) = self.chips.iter_mut().find(|chip| &chip.target == target) {
234 chip.amount = chip.amount.saturating_sub(amount);
235 }
236 self.chips.retain(|chip| chip.amount > 0);
237 }
238
229 /// Total returned for a winning number, including returned stakes. 239 /// Total returned for a winning number, including returned stakes.
230 pub fn winnings(&self, number: u8) -> u64 { 240 pub fn winnings(&self, number: u8) -> u64 {
231 self.chips.iter().fold(0u64, |total, chip| { 241 self.chips.iter().fold(0u64, |total, chip| {
diff --git a/tests/roulette.rs b/tests/roulette.rs
index 0c5e195..970ab6a 100644
--- a/tests/roulette.rs
+++ b/tests/roulette.rs
@@ -396,3 +396,23 @@ fn rejects_bad_roulette_results() {
396 assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); 396 assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err());
397 assert!(parse_roulette_response("").is_err()); 397 assert!(parse_roulette_response("").is_err());
398} 398}
399
400#[test]
401fn chips_can_be_taken_back_one_at_a_time() {
402 let mut bets = Bets::default();
403 bets.add(BetTarget::Straight { number: 17 }, 50);
404 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
405
406 bets.reduce(&BetTarget::Straight { number: 17 }, 25);
407 assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 25);
408 assert_eq!(bets.total(), 35);
409
410 // Reducing past zero drops the chip instead of going negative.
411 bets.reduce(&BetTarget::Straight { number: 17 }, 100);
412 assert_eq!(bets.amount_on(&BetTarget::Straight { number: 17 }), 0);
413 assert_eq!(bets.total(), 10);
414
415 // A target with no chip is left alone.
416 bets.reduce(&BetTarget::Dozen { index: 0 }, 25);
417 assert_eq!(bets.total(), 10);
418}