summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 21:09:56 +0100
commitbf4cf78ab26896faf457273e37d30fc96381c4a4 (patch)
tree71d67c300b6be191016a988ed5338e15c8b1bdd3
parentv4.1.1: pointer-first betting and visible payouts in the GUI (diff)
downloadzagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz
zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip
v4.2.0: wire the GUI to the core, transport and all four games
Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears. Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting. The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals. Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS.
-rw-r--r--AGENTS.md8
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md71
-rw-r--r--docs/TESTING.md60
-rw-r--r--docs/screenshots/gui-blackjack-1080p.pngbin0 -> 60075 bytes
-rw-r--r--docs/screenshots/gui-crash-1080p.pngbin0 -> 64377 bytes
-rw-r--r--docs/screenshots/gui-history-1080p.pngbin0 -> 166509 bytes
-rw-r--r--docs/screenshots/gui-lobby-1080p.pngbin0 -> 64944 bytes
-rw-r--r--docs/screenshots/gui-roulette-1080p.pngbin128158 -> 118143 bytes
-rw-r--r--docs/screenshots/gui-roulette-result-1080p.pngbin127283 -> 113253 bytes
-rw-r--r--docs/screenshots/gui-slots-1080p.pngbin0 -> 78173 bytes
-rw-r--r--gui/Cargo.toml4
-rw-r--r--gui/src/app.rs1963
-rw-r--r--gui/src/blackjack.rs428
-rw-r--r--gui/src/crash.rs416
-rw-r--r--gui/src/lib.rs5
-rw-r--r--gui/src/main.rs38
-rw-r--r--gui/src/net.rs220
-rw-r--r--gui/src/roulette.rs431
-rw-r--r--gui/src/slots.rs447
-rw-r--r--gui/src/widgets.rs255
-rw-r--r--gui/tests/layout.rs35
-rw-r--r--src/client/core.rs39
-rw-r--r--src/client/games/manager.rs6
-rw-r--r--src/client/view.rs2
-rw-r--r--tests/client_core.rs67
26 files changed, 3935 insertions, 562 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6207d7a..368a348 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -77,6 +77,14 @@
77 (cell centre, edge, crossing), left click adds the chip and right or middle 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 78 click takes one back. The terminal keeps its arrow-key cursor; the GUI does
79 not paint it. 79 not paint it.
80- The GUI network worker (`gui/src/net.rs`) owns the TLS stream and the request
81 deadline; `ClientCore` stays I/O-free. One thread with channels, no async
82 runtime.
83- Game sidebars pin their action block with `widgets::sidebar_layout`. An
84 action that moves as the bet slip grows is a bug.
85- The embedded fonts have no suit or arrow glyphs. Draw card suits as shapes
86 and use plain words for navigation; a glyph that is not in Open Sans or Hack
87 renders as tofu.
80 88
81## Client TUI 89## Client TUI
82- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, 90- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`,
diff --git a/Cargo.toml b/Cargo.toml
index 53acc77..23188da 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.1" 6version = "4.2.0"
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 bb63530..4279626 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -67,9 +67,7 @@ crash board from its local clock.
67 67
68**Gate:** all four games playable in the TUI through `Command`/`View`. 68**Gate:** all four games playable in the TUI through `Command`/`View`.
69 69
70## Phase 3 — `gui/` skeleton and the style spike 70## Phase 3 — `gui/` skeleton and the style spike (done)
71
72**Code complete in v4.1.0; awaiting the screenshot sign-off.**
73 71
74- `gui/` is a workspace member (`zagan-bet-gui`). It depends on the core with 72- `gui/` is a workspace member (`zagan-bet-gui`). It depends on the core with
75 `default-features = false`, so neither egui nor native-tls enters the TUI or 73 `default-features = false`, so neither egui nor native-tls enters the TUI or
@@ -104,24 +102,65 @@ Measured on Linux at 1080p with llvmpipe software GL:
104| Idle RSS | 137 MB | 102| Idle RSS | 137 MB |
105| Idle CPU | 0% | 103| Idle CPU | 0% |
106 104
107The approved reference lives at `docs/screenshots/gui-roulette-1080p.png`, with 105The Phase 4 screenshots in `docs/screenshots/` supersede the spike captures;
108the result state at `docs/screenshots/gui-roulette-result-1080p.png`. 106they show the same layout with the real session and the pinned action bars.
109 107
110**Gate:** the screenshot needs your approval. Frame rate on integrated graphics 108**Gate:** met. The style was approved and the spike grew into Phase 4.
111still needs a run on real hardware. 109
110## Phase 4 — GUI wiring (code complete, v4.2.0)
111
112- `gui/src/net.rs` is the adapter's network worker. One blocking thread owns the
113 TLS stream; `Command` goes in, `Event` comes out (connected, failed, trust,
114 line, closed), and every event calls `Context::request_repaint`. There is no
115 async runtime.
116- First contact is a modal that answers the transport's trust callback through
117 a channel. A changed key is a hard stop with an explicit forget-and-retry;
118 a version mismatch links to the download page; a saved credential auto-logs
119 in before the login screen is ever shown.
120- Screens: connect (remembered hosts, forget trust), login/register with a
121 "remember this login" choice, lobby with the update notice, history table,
122 and the four games. "Log out" and "forget saved login" are separate. Leaving
123 a live hand or round asks first and forfeits the reservation.
124- Games are painted from `View`: slots with a 0.9 s reel animation and payline
125 highlights, roulette with the wheel spin, the hover lattice, chip placement
126 and payouts, blackjack with vector-drawn suits and legal actions, crash with
127 a sampled curve, cashout and a server-drawn crash point. A refused spin shows
128 the error and never runs the animation: the reels and the wheel wait for the
129 server's answer, not for the click.
130- Sidebars pin their action block below the content, so SPIN, CLEAR, DEAL and
131 START never move as the bet slip or a hand grows. The embedded fonts have no
132 suit or arrow glyphs, so suits are shapes and navigation uses plain words.
133- The header balance is held at the staked amount while the reels or wheel
134 turn, then jumps to the settled value: the GUI never reveals a payout before
135 the animation does. Refused spins cancel the animation and show the error.
136 The crash multiplier keeps full precision in the game state so the chart is
137 a smooth line; the terminal still formats it to two decimals.
138- `gui/tests/layout.rs` covers money, wheel angles, lattice snapping, table
139 aspect and bet names.
140
141Verified live against a v4.2.0 server: register, login, auto-login, trust,
142all four games settling, history, logout, disconnect, the leave-forfeit
143confirmation, and the version-mismatch screen (new client against a 4.1.1
144server).
145
146Screenshots: `docs/screenshots/gui-{lobby,slots,roulette,blackjack,crash,
147history}-1080p.png`, plus the roulette result state.
112 148
113## Phase 4 — GUI wiring 149Measured on Linux at 1080p with llvmpipe software GL:
114## Phase 4 — GUI wiring
115 150
116- Network thread with channels, request deadlines in the shared adapter. 151| Check | Result |
117- Trust modal, version-mismatch screen, login/register, lobby, history panel. 152| --- | --- |
118- All four games, per-frame painting and tier 1–2 effects. 153| Linux binary | 8.3 MB |
119- Update notice from the handshake in the lobby. 154| Windows x86_64 binary | 6.8 MB |
155| Cold start to window | 61 ms |
156| Idle RSS | 131 MB |
157| Idle CPU | 0% |
120 158
121**Gate:** manual checklist per game at 60 fps, `egui_kittest` wiring tests, 159**Gate:** the screenshot set is ready for review. Remaining for the gate:
122modem-throttled run. 160frame rate on integrated graphics and the Windows interactive pass.
123 161
124## Phase 5 — packaging 162## Phase 5 — packaging
163## Phase 5 — packaging
125 164
126- `publish.sh` emits `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`. 165- `publish.sh` emits `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`.
127- Bash and PowerShell installers that verify the SHA-256, install to the user 166- Bash and PowerShell installers that verify the SHA-256, install to the user
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 32fc628..817cb3d 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -46,7 +46,7 @@ or a vendored OpenSSL.
46| `tests/slots.rs` | Server slots payout rules and overflow handling. | 46| `tests/slots.rs` | Server slots payout rules and overflow handling. |
47| `tests/storage.rs` | Client/server SQLite storage helpers. | 47| `tests/storage.rs` | Client/server SQLite storage helpers. |
48| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | 48| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. |
49| `gui/tests/layout.rs` | GUI presentation maths: money grouping, wheel angle, table aspect, bet names. | 49| `gui/tests/layout.rs` | GUI presentation maths: money grouping, wheel angle, table aspect, lattice snapping (straight/split/corner/outside), bet names. |
50 50
51`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them 51`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them
52on a machine where the `openssl` CLI is available. 52on a machine where the `openssl` CLI is available.
@@ -118,34 +118,36 @@ Use an 80-column terminal because several output lines are width-sensitive.
118 118
119## Manual GUI checklist 119## Manual GUI checklist
120 120
121The GUI is a style spike until Phase 4: the table and wheel are painted from a 121The desktop frontend shares `ClientCore` and the data directory with the
122demo `RouletteView`, and no network connection is made. Run it on Linux and on 122terminal client; a saved login from one signs in the other.
123Windows 10 before approving the look. 123
124 1241. `cargo run -p zagan-bet-gui` (or `-d <dir>` / `--unsafe`). The connect
1251. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header 125 screen lists remembered hosts with short fingerprints.
126 keeps its right margin at both sizes and shows ONLINE, host, ping, account 1262. First contact: the fingerprint modal appears before any credentials. Cancel
127 and balance; the table is centred and the sidebar is full height. 127 leaves the connect screen; Trust continues to the login screen.
1282. The felt shows 0, the 3x12 numbers, dozens, columns (each a quarter of the 1283. Register, then log in with "Remember this login". The lobby shows the host,
129 row) and the six outside cells. Chips sit on their targets: a straight in 129 ping, account, balance and the four games; quit and reconnect: the stored
130 the cell, a corner on the lattice crossing, an outside bet in its box. 130 login signs in without asking.
1313. Hover a number: a translucent chip of the selected denomination appears. 1314. Each game: place a bet or deal, play a round, and confirm the balance and the
132 In the inner half of a cell it is a straight (one cell tints); near an edge 132 header update. Slots shows reels and win lines; roulette spins the wheel and
133 it snaps to a split (two cells tint) and near a corner to a corner (four 133 reports the payout; blackjack draws suits and settles; crash draws a smooth
134 cells tint). Hovering the dozens, columns and outside boxes moves the 134 curve and cashes out. While the reels or wheel turn, the balance shows the
135 preview there. 135 stake already deducted; the payout appears only when they stop. A spin or
1364. Left click adds the chip where the preview is; right or middle click takes 136 deal the server refuses (for example an insufficient balance) shows the
137 one chip of that value back, dropping the row when it reaches zero. The 137 error and must not animate the reels, the wheel or the curve.
138 slip and total follow every change. C or CLEAR empties the slip. 1385. Sidebar actions never move while the slip grows. CLEAR empties the slip;
1395. Space or SPIN animates the wheel for about 2.4 s and stops with the winning 139 right or middle click on the felt takes one chip back; C clears and space
140 pocket under the pointer. The winning cell gets a gold outline and LAST 140 spins in roulette.
141 gains a dot. RESULT names the number and colour, WON shows the amount paid 1416. Leaving a live blackjack hand or crash round asks for confirmation, then
142 and the net for the round, and a losing spin says NO WIN. SPIN, CLEAR and 142 forfeits. Leaving an idle table returns to the lobby.
143 the chips are disabled during the spin. 1437. HISTORY shows the last plays; SHOW changes the limit. LOG OUT returns to
1446. Placing a chip after a result starts the next round: the payout row goes 144 the login screen but stays connected; Disconnect drops the session (the
145 back to PLACE YOUR BETS while the wheel stays where it stopped. Resize to 145 saved login survives).
146 1100x720: the sidebar scrolls, nothing overlaps the header, and the table 1468. Run the client against a server of another MAJOR.MINOR version: the
147 keeps its aspect ratio. 147 mismatch screen names both versions and the download page. A changed host
1487. Compare the 1080p window against the approved reference screenshots. 148 key stops the connection and offers forget-and-reconnect.
1499. Resize to 1100x720: the sidebar scrolls, the actions stay reachable, and
150 the board keeps its aspect ratio.
149 151
150## Manual roulette checklist 152## Manual roulette checklist
151 153
diff --git a/docs/screenshots/gui-blackjack-1080p.png b/docs/screenshots/gui-blackjack-1080p.png
new file mode 100644
index 0000000..b0fcc19
--- /dev/null
+++ b/docs/screenshots/gui-blackjack-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-crash-1080p.png b/docs/screenshots/gui-crash-1080p.png
new file mode 100644
index 0000000..245a3a1
--- /dev/null
+++ b/docs/screenshots/gui-crash-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-history-1080p.png b/docs/screenshots/gui-history-1080p.png
new file mode 100644
index 0000000..a979dcd
--- /dev/null
+++ b/docs/screenshots/gui-history-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-lobby-1080p.png b/docs/screenshots/gui-lobby-1080p.png
new file mode 100644
index 0000000..a258967
--- /dev/null
+++ b/docs/screenshots/gui-lobby-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png
index 2f30aa7..1d7af3d 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 a7d425c..fe4c9cd 100644
--- a/docs/screenshots/gui-roulette-result-1080p.png
+++ b/docs/screenshots/gui-roulette-result-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-slots-1080p.png b/docs/screenshots/gui-slots-1080p.png
new file mode 100644
index 0000000..9d79321
--- /dev/null
+++ b/docs/screenshots/gui-slots-1080p.png
Binary files differ
diff --git a/gui/Cargo.toml b/gui/Cargo.toml
index a0dfb86..d3d9c36 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.1" 3version = "4.2.0"
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"
@@ -19,3 +19,5 @@ eframe = { version = "0.37", default-features = false, features = [
19 "wayland", 19 "wayland",
20 "x11", 20 "x11",
21] } 21] }
22rusqlite = { version = "0.29", features = ["bundled"] }
23sha2 = "0.10"
diff --git a/gui/src/app.rs b/gui/src/app.rs
index b5a50b3..f0efd3e 100644
--- a/gui/src/app.rs
+++ b/gui/src/app.rs
@@ -1,75 +1,916 @@
1//! The desktop shell: header, roulette table, and betting sidebar. 1//! The desktop shell: screens, session state, and the bridge to the core.
2//!
3//! Phase 3 is a style spike. The table is painted from a demo `RouletteView`
4//! built with the core's own types, but no command is sent yet: Phase 4 wires
5//! the transport and replaces the demo state with `ClientCore`.
6 2
7use std::f32::consts::TAU; 3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::sync::mpsc::Sender;
6use std::time::{Duration, Instant};
8 7
9use eframe::egui::{ 8use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, 9 self, Align, Align2, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2, 10 RichText, Sense, Stroke, TextEdit, Ui, Vec2, vec2,
12}; 11};
13use zagan_bet::client::games::roulette::Cursor; 12use rusqlite::Connection;
14use zagan_bet::client::view::{RoulettePhase, RouletteView}; 13use sha2::{Digest, Sha256};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; 14use zagan_bet::client::core::{ClientCore, Effect};
16use zagan_bet::version::VERSION; 15use zagan_bet::client::database;
16use zagan_bet::client::paths;
17use zagan_bet::client::view::{Command, LobbyView, View};
18use zagan_bet::history::PlayRecord;
19use zagan_bet::version::{VERSION, is_newer};
17 20
18use crate::roulette::FeltAction; 21use crate::net::{Command as NetCommand, Connected, Event, Failure, Network};
19use crate::{roulette, theme}; 22use crate::{blackjack, crash, roulette, slots, theme, widgets};
20 23
21const HEADER_HEIGHT: f32 = 64.0; 24/// A request that has no reply after this long kills the connection, matching
22const SIDEBAR_WIDTH: f32 = 336.0; 25/// the terminal adapter's socket timeout.
23const SPIN_SECONDS: f64 = 2.4; 26const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
24/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. 27const NOTICE_SECONDS: u64 = 6;
25const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; 28const DEFAULT_BET: u64 = 10;
26const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; 29
30/// Where the app keeps its files and whether TLS is bypassed.
31pub struct Startup {
32 pub data_dir: PathBuf,
33 pub unsafe_mode: bool,
34}
27 35
28pub struct ZaganApp { 36pub struct ZaganApp {
29 demo: Demo, 37 core: ClientCore,
38 net: Network,
39 db: Option<Connection>,
40 db_error: Option<String>,
41 unsafe_mode: bool,
42
43 session: Option<Session>,
44 connect: ConnectState,
45 login: LoginState,
46 trust: Option<TrustState>,
47 key_changed: Option<KeyChanged>,
48 leave_confirm: bool,
49 notices: VecDeque<Notice>,
50 awaiting: Option<Instant>,
51 need_info: bool,
52 pending_login: Option<(String, String)>,
53 /// Displayed balance while a result animation runs: stake deducted, no
54 /// winnings yet. The view already knows the outcome; the player must not.
55 balance_hold: Option<i64>,
56 balance: Option<i64>,
57 account: Option<String>,
58 fx: Fx,
59}
60
61struct Session {
62 host: String,
63 fingerprint: Option<String>,
64 ping_ms: f32,
65 server_version: String,
66 latest: String,
67 url: String,
68 user: Option<String>,
69}
70
71#[derive(Default)]
72struct ConnectState {
73 addr: String,
74 error: Option<String>,
75 version_issue: bool,
76 pending: bool,
77}
78
79#[derive(Default)]
80struct LoginState {
81 user: String,
82 password: String,
83 error: Option<String>,
84 remember: bool,
85}
86
87impl LoginState {
88 fn new() -> Self {
89 Self {
90 remember: true,
91 ..Self::default()
92 }
93 }
94}
95
96struct TrustState {
97 addr: String,
98 fingerprint: String,
99 reply: Sender<bool>,
100}
101
102struct KeyChanged {
103 addr: String,
104 expected: String,
105 observed: String,
106}
107
108struct Notice {
109 text: String,
110 at: Instant,
111 tone: Tone,
112}
113
114#[derive(Clone, Copy, PartialEq, Eq)]
115enum Tone {
116 Info,
117 Good,
118 Bad,
119}
120
121#[derive(Default)]
122struct Fx {
123 game: Option<String>,
124 roulette: roulette::Fx,
125 slots: slots::Fx,
126 crash: crash::Fx,
127}
128
129impl Fx {
130 /// Reset the animation state when the player enters or leaves a game.
131 fn sync(&mut self, game: Option<&str>) -> bool {
132 if self.game.as_deref() != game {
133 let game = game.map(str::to_string);
134 *self = Fx {
135 game,
136 ..Fx::default()
137 };
138 return true;
139 }
140 false
141 }
30} 142}
31 143
32impl ZaganApp { 144impl ZaganApp {
33 pub fn new(cc: &eframe::CreationContext<'_>) -> Self { 145 pub fn new(cc: &eframe::CreationContext<'_>, startup: Startup) -> Self {
34 theme::install(&cc.egui_ctx); 146 theme::install(&cc.egui_ctx);
35 Self { demo: Demo::new() } 147
148 let db_path = paths::database_path(&startup.data_dir);
149 let (db, db_error) = match Connection::open(&db_path) {
150 Ok(conn) => {
151 let _ = conn.busy_timeout(Duration::from_secs(2));
152 database::init_db(&conn);
153 (Some(conn), None)
154 }
155 Err(error) => (
156 None,
157 Some(format!("Cannot open {}: {error}", db_path.display())),
158 ),
159 };
160 let addr = db
161 .as_ref()
162 .and_then(database::get_last_host)
163 .unwrap_or_else(|| "127.0.0.1:11525".to_string());
164
165 let net = Network::spawn(
166 cc.egui_ctx.clone(),
167 startup.data_dir.clone(),
168 startup.unsafe_mode,
169 );
170
171 Self {
172 core: ClientCore::new(),
173 net,
174 db,
175 db_error,
176 unsafe_mode: startup.unsafe_mode,
177 session: None,
178 connect: ConnectState {
179 addr,
180 ..ConnectState::default()
181 },
182 login: LoginState::new(),
183 trust: None,
184 key_changed: None,
185 leave_confirm: false,
186 notices: VecDeque::new(),
187 awaiting: None,
188 need_info: false,
189 pending_login: None,
190 balance_hold: None,
191 balance: None,
192 account: None,
193 fx: Fx::default(),
194 }
195 }
196
197 fn screen(&mut self, ui: &mut Ui) {
198 if let Some(error) = self.db_error.clone() {
199 fatal(ui, &error);
200 return;
201 }
202
203 let view = self.core.view();
204 if self.session.is_none() {
205 connect_screen(ui, self);
206 return;
207 }
208
209 match view {
210 Some(View::Lobby(lobby)) if lobby.user.is_none() => login_screen(ui, self, &lobby),
211 Some(View::Lobby(lobby)) => lobby_screen(ui, self, &lobby),
212 Some(View::History(history)) => history_screen(ui, self, &history.records),
213 Some(View::Slots(view)) => {
214 let command = slots::show(ui, &view, &mut self.fx.slots);
215 if command == Some(Command::Spin) {
216 self.balance_hold = self
217 .balance
218 .map(|balance| (balance - view.bet as i64).max(0));
219 }
220 if let Some(command) = command {
221 self.command(ui.ctx(), command);
222 }
223 }
224 Some(View::Roulette(view)) => {
225 let command = roulette::show(ui, &view, &mut self.fx.roulette);
226 if command == Some(Command::Spin) {
227 self.balance_hold = self
228 .balance
229 .map(|balance| (balance - view.bets.total() as i64).max(0));
230 }
231 if let Some(command) = command {
232 self.command(ui.ctx(), command);
233 }
234 }
235 Some(View::Blackjack(view)) => {
236 if let Some(command) = blackjack::show(ui, &view) {
237 self.command(ui.ctx(), command);
238 }
239 }
240 Some(View::Crash(view)) => {
241 let command = crash::show(ui, &view, &mut self.fx.crash);
242 if let Some(command) = command {
243 self.command(ui.ctx(), command);
244 }
245 }
246 None => {}
247 }
248 }
249
250 /// Run one core command, holding back a leave that would forfeit a live
251 /// round until the player confirms.
252 fn command(&mut self, ctx: &Context, command: Command) {
253 if command == Command::Leave && self.core.is_live_game() {
254 self.leave_confirm = true;
255 return;
256 }
257 if command == Command::Logout {
258 self.need_info = false;
259 self.account = None;
260 self.balance = None;
261 self.pending_login = None;
262 if let Some(session) = &mut self.session {
263 session.user = None;
264 }
265 }
266 let effects = self.core.command(command);
267 self.apply(ctx, effects);
268 }
269
270 fn apply(&mut self, ctx: &Context, effects: Vec<Effect>) {
271 for effect in effects {
272 match effect {
273 Effect::Print(lines) => {
274 // Multi-line prints are terminal tables (history, help,
275 // info); the GUI draws those from `View` instead.
276 if lines.len() == 1 {
277 if let Some(line) = lines.first() {
278 self.notice(line);
279 }
280 }
281 }
282 Effect::SendLine(line) => {
283 self.net.send(NetCommand::Send(line));
284 self.awaiting = Some(Instant::now());
285 }
286 Effect::Disconnect => {
287 self.net.send(NetCommand::Disconnect);
288 self.core.reset_after_disconnect();
289 self.session = None;
290 self.awaiting = None;
291 self.need_info = false;
292 self.account = None;
293 self.balance = None;
294 self.balance_hold = None;
295 }
296 Effect::ClearRegion | Effect::Render(_) => {}
297 Effect::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
298 }
299 }
300 }
301
302 fn notice(&mut self, text: &str) {
303 // `info` is fetched to fill the header; its terminal lines are not
304 // news to the GUI.
305 if text.starts_with("Connected to:")
306 || text.starts_with("Fingerprint:")
307 || text.starts_with("User:")
308 || text.starts_with("Account:")
309 || text.starts_with("Balance:")
310 {
311 return;
312 }
313 let tone = if text.starts_with("ERROR")
314 || text.contains("failed")
315 || text.contains("Failed")
316 || text.contains("lost")
317 {
318 Tone::Bad
319 } else if text.starts_with("Logged in")
320 || text.starts_with("Account created")
321 || text.contains("saved")
322 {
323 Tone::Good
324 } else {
325 Tone::Info
326 };
327 self.notices.push_back(Notice {
328 text: text.to_string(),
329 at: Instant::now(),
330 tone,
331 });
332 while self.notices.len() > 3 {
333 self.notices.pop_front();
334 }
335 }
336
337 fn pump(&mut self, ctx: &Context) {
338 while let Some(event) = self.net.try_recv() {
339 match event {
340 Event::Trust {
341 addr,
342 fingerprint,
343 reply,
344 } => {
345 self.trust = Some(TrustState {
346 addr,
347 fingerprint,
348 reply,
349 });
350 }
351 Event::Connected(connected) => self.connected(ctx, connected),
352 Event::Failed {
353 addr,
354 failure,
355 message,
356 } => self.failed(addr, failure, message),
357 Event::Line(line) => {
358 if self.core.is_async(&line) {
359 if let Some(effects) = self.core.handle_async_line(&line) {
360 self.apply(ctx, effects);
361 }
362 } else {
363 self.awaiting = None;
364 let effects = self.core.handle_reply(&line);
365 self.apply(ctx, effects);
366 }
367 }
368 Event::Closed => {
369 self.session = None;
370 self.awaiting = None;
371 self.core.reset_after_disconnect();
372 self.notice("Connection lost");
373 ctx.request_repaint();
374 }
375 }
376 }
377 }
378
379 fn connected(&mut self, ctx: &Context, connected: Connected) {
380 self.connect.pending = false;
381 self.connect.error = None;
382 self.core.set_connected(true);
383 self.core
384 .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone());
385 self.core.set_logged_in(connected.user.clone());
386 self.login.error = connected.auth_error.clone();
387 self.login.user = connected.user.clone().unwrap_or_default();
388
389 if let Some(db) = &self.db {
390 database::save_host(db, &connected.addr);
391 }
392 self.session = Some(Session {
393 host: connected.addr,
394 fingerprint: connected.fingerprint,
395 ping_ms: connected.elapsed_ms,
396 server_version: connected.server_version,
397 latest: connected.latest,
398 url: connected.url,
399 user: connected.user,
400 });
401
402 self.need_info = self.session.as_ref().is_some_and(|s| s.user.is_some());
403 self.account = None;
404 if self.need_info {
405 let effects = self.core.command(Command::Info);
406 self.apply(ctx, effects);
407 self.need_info = false;
408 }
409 }
410
411 fn failed(&mut self, addr: String, failure: Failure, message: String) {
412 self.connect.pending = false;
413 self.session = None;
414 self.core.set_connected(false);
415 self.awaiting = None;
416 match failure {
417 Failure::KeyChanged { expected, observed } => {
418 self.key_changed = Some(KeyChanged {
419 addr,
420 expected,
421 observed,
422 });
423 }
424 Failure::VersionMismatch => {
425 self.connect.version_issue = true;
426 self.connect.error = Some(message);
427 }
428 Failure::TrustDeclined => {
429 self.trust = None;
430 self.connect.error = Some("Certificate was not trusted.".to_string());
431 }
432 Failure::Timeout => {
433 self.trust = None;
434 self.connect.error = Some("Connection timed out.".to_string());
435 }
436 Failure::Other => {
437 self.trust = None;
438 self.connect.error = Some(message);
439 }
440 }
441 }
442
443 fn begin_connect(&mut self, addr: &str) {
444 let addr = normalize_addr(addr);
445 self.connect.addr = addr.clone();
446 self.connect.error = None;
447 self.connect.version_issue = false;
448 self.connect.pending = true;
449 self.login.error = None;
450 self.net.send(NetCommand::Connect { addr });
451 }
452
453 fn disconnect(&mut self) {
454 self.net.send(NetCommand::Disconnect);
455 self.core.reset_after_disconnect();
456 self.session = None;
457 self.awaiting = None;
458 self.trust = None;
459 self.key_changed = None;
460 self.need_info = false;
461 self.account = None;
462 self.balance = None;
463 self.balance_hold = None;
464 }
465
466 fn trust_decision(&mut self, accept: bool) {
467 if let Some(trust) = self.trust.take() {
468 let _ = trust.reply.send(accept);
469 if !accept {
470 self.connect.error = Some("Certificate was not trusted.".to_string());
471 }
472 }
473 }
474
475 fn forget_trusted_host(&mut self, addr: &str) {
476 let Some(db) = &self.db else { return };
477 match database::forget_host(db, addr) {
478 Ok(_) => {
479 self.key_changed = None;
480 self.begin_connect(addr);
481 }
482 Err(error) => self.connect.error = Some(format!("Failed to forget host: {error}")),
483 }
484 }
485
486 fn forget_login(&mut self) {
487 let Some(fingerprint) = self
488 .session
489 .as_ref()
490 .and_then(|session| session.fingerprint.clone())
491 else {
492 return;
493 };
494 let Some(db) = &self.db else { return };
495 match database::forget_credentials(db, &fingerprint) {
496 Ok(true) => self.notice("Saved login forgotten. Trust is unchanged."),
497 Ok(false) => self.notice("No saved login for this server."),
498 Err(error) => self.notice(&format!("Failed to forget the login: {error}")),
499 }
500 }
501
502 fn login(&mut self, ctx: &Context, registering: bool) {
503 let user = self.login.user.trim().to_string();
504 if user.is_empty() || self.login.password.is_empty() {
505 self.login.error = Some("Enter a username and a password.".to_string());
506 return;
507 }
508 let credential = format!("{:x}", Sha256::digest(self.login.password.as_bytes()));
509 self.login.password.clear();
510 self.login.error = None;
511 let command = if registering {
512 Command::Register { user, credential }
513 } else {
514 self.need_info = true;
515 if self.login.remember {
516 self.pending_login = Some((user.clone(), credential.clone()));
517 }
518 Command::Login { user, credential }
519 };
520 let effects = self.core.command(command);
521 self.apply(ctx, effects);
522 }
523
524 fn tick(&mut self, ctx: &Context) {
525 // Request deadline, mirroring the terminal's socket timeout.
526 if self.need_info
527 && self
528 .session
529 .as_ref()
530 .is_some_and(|session| session.user.is_some())
531 && self.account.is_none()
532 {
533 let effects = self.core.command(Command::Info);
534 self.apply(ctx, effects);
535 self.need_info = false;
536 }
537
538 if let Some(sent) = self.awaiting {
539 if sent.elapsed() > REQUEST_TIMEOUT {
540 self.awaiting = None;
541 self.net.send(NetCommand::Disconnect);
542 self.session = None;
543 self.core.reset_after_disconnect();
544 let effects = self.core.handle_reply("ERROR Request timed out");
545 self.apply(ctx, effects);
546 self.notice("Request timed out. Disconnected.");
547 }
548 }
549
550 let game = self.core.current_game_type().map(str::to_string);
551 if self.fx.sync(game.as_deref()) {
552 self.balance_hold = None;
553 }
554
555 match game.as_deref() {
556 Some("crash") => {
557 let effects = self.core.tick();
558 self.apply(ctx, effects);
559 if let Some(View::Crash(view)) = self.core.view() {
560 self.fx.crash.tick(ctx, &view);
561 if view.status == "running" || view.status == "starting" {
562 ctx.request_repaint_after(Duration::from_millis(16));
563 }
564 }
565 }
566 Some("slots") => {
567 if let Some(View::Slots(view)) = self.core.view() {
568 self.fx.slots.tick(ctx, &view);
569 }
570 }
571 Some("roulette") => {
572 if let Some(View::Roulette(view)) = self.core.view() {
573 self.fx.roulette.tick(ctx, &view);
574 }
575 }
576 _ => {}
577 }
578
579 // Hold the balance at the staked amount until the reels or wheel stop,
580 // so the payout is not visible before the animation reveals it.
581 if self.balance_hold.is_some() && !self.result_animating(ctx, game.as_deref()) {
582 self.balance_hold = None;
583 }
584
585 self.keys(ctx, game.as_deref());
586
587 // A successful manual login can be remembered for the next connect.
588 if let Some((user, credential)) = self.pending_login.clone() {
589 if self
590 .core
591 .view()
592 .as_ref()
593 .is_some_and(|view| matches!(view, View::Lobby(lobby) if lobby.user.is_some()))
594 {
595 if let Some(fingerprint) = self
596 .session
597 .as_ref()
598 .and_then(|session| session.fingerprint.as_deref())
599 {
600 if let Some(db) = &self.db {
601 let _ = database::save_credentials(db, fingerprint, &user, &credential);
602 self.notice("Login saved for this server.");
603 }
604 }
605 self.pending_login = None;
606 }
607 }
608
609 // Cache the header data, which game views do not all carry.
610 if let Some(view) = self.core.view() {
611 match view {
612 View::Lobby(lobby) => {
613 if let Some(session) = &mut self.session {
614 session.user = lobby.user.clone();
615 }
616 if lobby.user.is_none() {
617 self.balance = None;
618 self.account = None;
619 } else {
620 if let Some(account) = lobby.account {
621 self.account = Some(account);
622 }
623 if let Some(balance) = lobby.balance {
624 if self.balance_hold.is_none() {
625 self.balance = Some(balance);
626 }
627 }
628 }
629 }
630 View::History(_) => {}
631 other => {
632 if self.balance_hold.is_none() {
633 if let Some(balance) = balance_of(&other) {
634 self.balance = Some(balance);
635 }
636 }
637 }
638 }
639 }
640
641 self.notices
642 .retain(|notice| notice.at.elapsed() < Duration::from_secs(NOTICE_SECONDS));
643 if !self.notices.is_empty() {
644 ctx.request_repaint_after(Duration::from_millis(500));
645 }
646 }
647
648 /// True while a spin's result is still being animated.
649 fn result_animating(&self, ctx: &Context, game: Option<&str>) -> bool {
650 match game {
651 Some("slots") => match self.core.view() {
652 Some(View::Slots(view)) if view.error.is_none() => {
653 !self.fx.slots.revealed(ctx, &view)
654 }
655 _ => false,
656 },
657 Some("roulette") => match self.core.view() {
658 Some(View::Roulette(view)) if view.error.is_none() => {
659 !self.fx.roulette.revealed(&view)
660 }
661 _ => false,
662 },
663 _ => false,
664 }
665 }
666
667 /// The few keys that stay useful when the pointer is elsewhere. Buttons
668 /// keep focus, so this only fires when nothing is focused.
669 fn keys(&mut self, ctx: &Context, game: Option<&str>) {
670 if self.session.is_none() || !ctx.memory(|memory| memory.focused().is_none()) {
671 return;
672 }
673 let (space, clear, cash, hit, stand, double) = ctx.input(|input| {
674 (
675 input.key_pressed(Key::Space),
676 input.key_pressed(Key::C),
677 input.key_pressed(Key::C),
678 input.key_pressed(Key::H),
679 input.key_pressed(Key::S),
680 input.key_pressed(Key::D),
681 )
682 });
683 let command = match game {
684 Some("roulette") => {
685 if space {
686 self.fx.roulette.spin_requested();
687 Some(Command::Spin)
688 } else if clear {
689 Some(Command::ClearBets)
690 } else {
691 None
692 }
693 }
694 Some("slots") if space => Some(Command::Spin),
695 Some("crash") => {
696 if cash {
697 Some(Command::CashOut)
698 } else if space {
699 Some(Command::Start)
700 } else {
701 None
702 }
703 }
704 Some("blackjack") => {
705 if hit {
706 Some(Command::Hit)
707 } else if stand {
708 Some(Command::Stand)
709 } else if double {
710 Some(Command::Double)
711 } else if space {
712 Some(Command::Deal)
713 } else {
714 None
715 }
716 }
717 _ => None,
718 };
719 if let Some(command) = command {
720 self.command(ctx, command);
721 }
722 }
723
724 fn modals(&mut self, ctx: &Context) {
725 if let Some(trust) = &self.trust {
726 let addr = trust.addr.clone();
727 let fingerprint = trust.fingerprint.clone();
728 let mut decision = None;
729 egui::Modal::new(egui::Id::unique("trust")).show(ctx, |ui| {
730 ui.set_width(460.0);
731 ui.label(
732 RichText::new("New server certificate")
733 .font(theme::sans(20.0))
734 .color(theme::TEXT),
735 );
736 ui.add_space(theme::SPACE_2);
737 widgets::note(
738 ui,
739 "This is the first time this client has seen this server. \
740 Check the fingerprint before trusting it.",
741 );
742 ui.add_space(theme::SPACE_3);
743 widgets::row(ui, "Address", &addr, theme::TEXT);
744 widgets::mono_text(ui, &fingerprint, theme::ACCENT);
745 ui.add_space(theme::SPACE_3);
746 widgets::note(
747 ui,
748 "Future connections must present the same key. A changed key is refused.",
749 );
750 ui.add_space(theme::SPACE_4);
751 ui.horizontal(|ui| {
752 if widgets::primary(ui, "TRUST", true, 130.0, 40.0) {
753 decision = Some(true);
754 }
755 ui.add_space(theme::SPACE_2);
756 if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) {
757 decision = Some(false);
758 }
759 });
760 });
761 if let Some(accept) = decision {
762 self.trust_decision(accept);
763 }
764 }
765
766 if let Some(changed) = &self.key_changed {
767 let addr = changed.addr.clone();
768 let expected = changed.expected.clone();
769 let observed = changed.observed.clone();
770 let mut action = None;
771 egui::Modal::new(egui::Id::unique("key-changed")).show(ctx, |ui| {
772 ui.set_width(520.0);
773 ui.label(
774 RichText::new("SERVER KEY CHANGED")
775 .font(theme::mono(16.0))
776 .color(theme::LOSS),
777 );
778 ui.add_space(theme::SPACE_2);
779 widgets::note(
780 ui,
781 "A server that used to present a different certificate is answering. \
782 This can mean the server was reinstalled, or that someone is \
783 intercepting the connection.",
784 );
785 ui.add_space(theme::SPACE_3);
786 widgets::row(ui, "Address", &addr, theme::TEXT);
787 widgets::mono_text(ui, &format!("expected {expected}"), theme::TEXT_DIM);
788 widgets::mono_text(ui, &format!("observed {observed}"), theme::LOSS);
789 ui.add_space(theme::SPACE_4);
790 ui.horizontal(|ui| {
791 if widgets::primary(ui, "FORGET AND RECONNECT", true, 220.0, 40.0) {
792 action = Some(true);
793 }
794 ui.add_space(theme::SPACE_2);
795 if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) {
796 action = Some(false);
797 }
798 });
799 });
800 match action {
801 Some(true) => self.forget_trusted_host(&addr),
802 Some(false) => {
803 self.key_changed = None;
804 self.connect.error = Some("Server key changed. Trust not reset.".to_string());
805 }
806 None => {}
807 }
808 }
809
810 if self.leave_confirm {
811 let mut action = None;
812 egui::Modal::new(egui::Id::unique("leave-confirm")).show(ctx, |ui| {
813 ui.set_width(400.0);
814 ui.label(
815 RichText::new("Leave the table?")
816 .font(theme::sans(19.0))
817 .color(theme::TEXT),
818 );
819 ui.add_space(theme::SPACE_2);
820 widgets::note(
821 ui,
822 "A live hand or round is forfeited. The reserved bet is lost.",
823 );
824 ui.add_space(theme::SPACE_4);
825 ui.horizontal(|ui| {
826 if widgets::primary(ui, "FORFEIT AND LEAVE", true, 190.0, 40.0) {
827 action = Some(true);
828 }
829 ui.add_space(theme::SPACE_2);
830 if widgets::secondary(ui, "STAY", true, 100.0, 40.0) {
831 action = Some(false);
832 }
833 });
834 });
835 if let Some(leave) = action {
836 self.leave_confirm = false;
837 if leave {
838 let effects = self.core.command(Command::Leave);
839 self.apply(ctx, effects);
840 }
841 }
842 }
843 }
844
845 fn toasts(&self, ctx: &Context) {
846 if self.notices.is_empty() {
847 return;
848 }
849 egui::Area::new(egui::Id::unique("toasts"))
850 .anchor(Align2::LEFT_BOTTOM, vec2(24.0, -24.0))
851 .order(egui::Order::Foreground)
852 .interactable(false)
853 .show(ctx, |ui| {
854 ui.set_min_width(220.0);
855 ui.set_max_width(460.0);
856 ui.spacing_mut().item_spacing.y = theme::SPACE_2;
857 for notice in &self.notices {
858 let color = match notice.tone {
859 Tone::Bad => theme::LOSS,
860 Tone::Good => theme::WIN,
861 Tone::Info => theme::TEXT_DIM,
862 };
863 Frame::new()
864 .fill(theme::SURFACE_RAISED)
865 .stroke(Stroke::new(1.0, theme::LINE))
866 .corner_radius(theme::RADIUS_MD)
867 .inner_margin(Margin::symmetric(14, 8))
868 .show(ui, |ui| {
869 ui.set_min_width(226.0);
870 ui.set_max_width(440.0);
871 ui.label(
872 RichText::new(&notice.text)
873 .font(theme::sans(12.0))
874 .color(color),
875 );
876 });
877 }
878 });
36 } 879 }
37} 880}
38 881
39impl eframe::App for ZaganApp { 882impl eframe::App for ZaganApp {
40 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { 883 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
41 self.demo.tick(ui.ctx()); 884 let ctx = ui.ctx().clone();
42 self.demo.keys(ui.ctx()); 885 self.pump(&ctx);
43 886 self.tick(&ctx);
44 Panel::top("header") 887 header(ui, self);
45 .exact_size(HEADER_HEIGHT) 888 self.screen(ui);
46 .frame( 889 self.modals(&ctx);
47 Frame::new() 890 self.toasts(&ctx);
48 .fill(theme::SURFACE) 891 }
49 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), 892}
50 )
51 .show(ui, |ui| header(ui, &self.demo));
52
53 Panel::right("sidebar")
54 .exact_size(SIDEBAR_WIDTH)
55 .frame(
56 Frame::new()
57 .fill(theme::SURFACE)
58 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)),
59 )
60 .show(ui, |ui| {
61 egui::ScrollArea::vertical()
62 .auto_shrink([false, false])
63 .show(ui, |ui| sidebar(ui, &mut self.demo));
64 });
65 893
66 CentralPanel::default() 894fn balance_of(view: &View) -> Option<i64> {
67 .frame( 895 match view {
68 Frame::new() 896 View::Lobby(lobby) => lobby.balance,
69 .fill(theme::BG) 897 View::Slots(view) => view.balance,
70 .inner_margin(Margin::same(theme::SPACE_5 as i8)), 898 View::Roulette(view) => view.balance,
71 ) 899 View::Blackjack(view) => view.balance,
72 .show(ui, |ui| table(ui, &mut self.demo)); 900 View::Crash(view) => view.balance,
901 View::History(_) => None,
902 }
903}
904
905fn normalize_addr(addr: &str) -> String {
906 let addr = addr.trim();
907 if addr.is_empty() {
908 return "127.0.0.1:11525".to_string();
909 }
910 if addr.contains(':') {
911 addr.to_string()
912 } else {
913 format!("{addr}:11525")
73 } 914 }
74} 915}
75 916
@@ -77,50 +918,76 @@ impl eframe::App for ZaganApp {
77// Header 918// Header
78// --------------------------------------------------------------------------- 919// ---------------------------------------------------------------------------
79 920
80fn header(ui: &mut Ui, demo: &Demo) { 921fn header(ui: &mut Ui, app: &ZaganApp) {
81 ui.with_layout(Layout::left_to_right(Align::Center), |ui| { 922 Panel::top("header")
82 let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); 923 .exact_size(64.0)
83 ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); 924 .frame(
84 ui.add_space(theme::SPACE_2); 925 Frame::new()
85 ui.label( 926 .fill(theme::SURFACE)
86 RichText::new("ZAGAN") 927 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)),
87 .font(theme::mono(18.0)) 928 )
88 .color(theme::TEXT), 929 .show(ui, |ui| {
89 ); 930 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
90 ui.label( 931 let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
91 RichText::new("BET") 932 ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN);
92 .font(theme::mono(18.0)) 933 ui.add_space(theme::SPACE_2);
93 .color(theme::WIN), 934 ui.label(
94 ); 935 RichText::new("ZAGAN")
95 ui.add_space(theme::SPACE_3); 936 .font(theme::mono(18.0))
96 pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); 937 .color(theme::TEXT),
97 ui.add_space(theme::SPACE_1); 938 );
98 pill(ui, "PREVIEW", theme::GOLD); 939 ui.label(
99 ui.add_space(theme::SPACE_5); 940 RichText::new("BET")
100 connection_pill(ui, demo); 941 .font(theme::mono(18.0))
942 .color(theme::WIN),
943 );
944 ui.add_space(theme::SPACE_3);
945 pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT);
946 if app.unsafe_mode {
947 ui.add_space(theme::SPACE_1);
948 pill(ui, "UNSAFE", theme::LOSS);
949 }
950 ui.add_space(theme::SPACE_5);
951 match &app.session {
952 Some(session) => connection_pill(ui, session),
953 None => pill(ui, "OFFLINE", theme::TEXT_FAINT),
954 }
101 955
102 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 956 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
103 stat_block( 957 let signed_in = app
104 ui, 958 .session
105 150.0, 959 .as_ref()
106 "Balance", 960 .is_some_and(|session| session.user.is_some());
107 &theme::money(demo.balance), 961 if signed_in {
108 theme::mono(20.0), 962 if let Some(balance) = app.balance_hold.or(app.balance) {
109 theme::TEXT, 963 stat_block(
110 None, 964 ui,
111 ); 965 150.0,
112 ui.add_space(theme::SPACE_5); 966 "Balance",
113 stat_block( 967 &theme::money(balance),
114 ui, 968 theme::mono(20.0),
115 190.0, 969 theme::TEXT,
116 "Account", 970 None,
117 demo.user, 971 );
118 theme::sans(theme::FONT_BODY), 972 ui.add_space(theme::SPACE_5);
119 theme::TEXT, 973 }
120 Some(demo.account), 974 if let Some(user) = app.session.as_ref().and_then(|s| s.user.as_deref()) {
121 ); 975 let account =
976 app.account.as_deref().map(|account| format!("#{account}"));
977 stat_block(
978 ui,
979 190.0,
980 "Account",
981 user,
982 theme::sans(theme::FONT_BODY),
983 theme::TEXT,
984 account.as_deref(),
985 );
986 }
987 }
988 });
989 });
122 }); 990 });
123 });
124} 991}
125 992
126fn pill(ui: &mut Ui, text: &str, color: Color32) { 993fn pill(ui: &mut Ui, text: &str, color: Color32) {
@@ -133,7 +1000,7 @@ fn pill(ui: &mut Ui, text: &str, color: Color32) {
133 }); 1000 });
134} 1001}
135 1002
136fn connection_pill(ui: &mut Ui, demo: &Demo) { 1003fn connection_pill(ui: &mut Ui, session: &Session) {
137 Frame::new() 1004 Frame::new()
138 .fill(theme::SURFACE_RAISED) 1005 .fill(theme::SURFACE_RAISED)
139 .corner_radius(theme::RADIUS_SM) 1006 .corner_radius(theme::RADIUS_SM)
@@ -148,12 +1015,12 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) {
148 .color(theme::WIN), 1015 .color(theme::WIN),
149 ); 1016 );
150 ui.label( 1017 ui.label(
151 RichText::new(demo.host) 1018 RichText::new(&session.host)
152 .font(theme::mono(12.0)) 1019 .font(theme::mono(12.0))
153 .color(theme::TEXT_DIM), 1020 .color(theme::TEXT_DIM),
154 ); 1021 );
155 ui.label( 1022 ui.label(
156 RichText::new(format!("{} ms", demo.ping_ms)) 1023 RichText::new(format!("{:.1} ms", session.ping_ms))
157 .font(theme::mono(12.0)) 1024 .font(theme::mono(12.0))
158 .color(theme::TEXT_FAINT), 1025 .color(theme::TEXT_FAINT),
159 ); 1026 );
@@ -161,8 +1028,6 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) {
161 }); 1028 });
162} 1029}
163 1030
164/// A label over a value, right-aligned in a fixed slot so the header's right
165/// edge stays put as the numbers change.
166fn stat_block( 1031fn stat_block(
167 ui: &mut Ui, 1032 ui: &mut Ui,
168 width: f32, 1033 width: f32,
@@ -187,454 +1052,554 @@ fn stat_block(
187} 1052}
188 1053
189// --------------------------------------------------------------------------- 1054// ---------------------------------------------------------------------------
190// Sidebar 1055// Connect
191// --------------------------------------------------------------------------- 1056// ---------------------------------------------------------------------------
192 1057
193fn sidebar(ui: &mut Ui, demo: &mut Demo) { 1058fn connect_screen(ui: &mut Ui, app: &mut ZaganApp) {
194 ui.label( 1059 CentralPanel::default()
195 RichText::new("Roulette") 1060 .frame(
196 .font(theme::sans(22.0)) 1061 Frame::new()
197 .color(theme::TEXT), 1062 .fill(theme::BG)
198 ); 1063 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
199 ui.label( 1064 )
200 RichText::new("European · single zero") 1065 .show(ui, |ui| {
201 .font(theme::sans(12.0)) 1066 ui.vertical_centered(|ui| {
202 .color(theme::TEXT_DIM), 1067 ui.add_space(60.0);
203 ); 1068 let width = 560.0;
1069 ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(Align::Min), |ui| {
1070 Frame::new()
1071 .fill(theme::SURFACE)
1072 .corner_radius(theme::RADIUS_LG)
1073 .inner_margin(Margin::same(theme::SPACE_5 as i8))
1074 .show(ui, |ui| {
1075 ui.label(
1076 RichText::new("Connect to a server")
1077 .font(theme::sans(24.0))
1078 .color(theme::TEXT),
1079 );
1080 ui.add_space(theme::SPACE_1);
1081 widgets::note(
1082 ui,
1083 "TLS with certificate pinning. First contact asks before \
1084 any credential is sent.",
1085 );
1086 ui.add_space(theme::SPACE_4);
204 1087
205 ui.add_space(theme::SPACE_4); 1088 let response = ui.add(
206 let view = demo.view(); 1089 TextEdit::singleline(&mut app.connect.addr)
207 let wheel_size = ui.available_width().min(210.0); 1090 .hint_text("host:port")
208 ui.horizontal(|ui| { 1091 .desired_width(f32::INFINITY)
209 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); 1092 .font(theme::mono(14.0)),
210 ui.add_space(pad); 1093 );
211 roulette::wheel(ui, wheel_size, &view, demo.rotation); 1094 let submitted = response.lost_focus()
212 }); 1095 && ui.input(|input| input.key_pressed(Key::Enter));
213 ui.add_space(theme::SPACE_1);
214 result_line(ui, demo);
215 1096
216 ui.add_space(theme::SPACE_3); 1097 ui.add_space(theme::SPACE_3);
217 ui.separator(); 1098 let connect = widgets::primary(
218 ui.add_space(theme::SPACE_3); 1099 ui,
1100 if app.connect.pending {
1101 "CONNECTING…"
1102 } else {
1103 "CONNECT"
1104 },
1105 !app.connect.pending,
1106 ui.available_width(),
1107 44.0,
1108 );
1109 if (connect || submitted) && !app.connect.pending {
1110 let addr = app.connect.addr.clone();
1111 app.begin_connect(&addr);
1112 }
219 1113
220 ui.label(theme::label("Chip")); 1114 if let Some(error) = &app.connect.error {
221 ui.add_space(theme::SPACE_2); 1115 ui.add_space(theme::SPACE_3);
222 let spinning = demo.phase == RoulettePhase::Spinning; 1116 widgets::error(ui, error);
223 ui.horizontal(|ui| { 1117 }
224 ui.spacing_mut().item_spacing.x = theme::SPACE_2; 1118 if app.connect.version_issue {
225 for amount in CHIP_SIZES { 1119 ui.add_space(theme::SPACE_2);
226 if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { 1120 widgets::note(
227 demo.chip = amount; 1121 ui,
228 } 1122 &format!(
229 } 1123 "Download the matching client from {}",
230 }); 1124 zagan_bet::version::DEFAULT_DOWNLOAD_URL
1125 ),
1126 );
1127 }
1128 });
231 1129
232 ui.add_space(theme::SPACE_3); 1130 ui.add_space(theme::SPACE_4);
233 ui.label(theme::label("Bets")); 1131 Frame::new()
234 ui.add_space(theme::SPACE_1); 1132 .fill(theme::SURFACE)
235 if demo.bets.chips.is_empty() { 1133 .corner_radius(theme::RADIUS_LG)
1134 .inner_margin(Margin::same(theme::SPACE_4 as i8))
1135 .show(ui, |ui| {
1136 ui.label(theme::label("Remembered hosts"));
1137 ui.add_space(theme::SPACE_2);
1138 let hosts = app
1139 .db
1140 .as_ref()
1141 .map(database::get_all_hosts)
1142 .unwrap_or_default();
1143 if hosts.is_empty() {
1144 widgets::note(ui, "No remembered hosts yet.");
1145 }
1146 for (host, fingerprint) in hosts {
1147 remembered_host(ui, app, &host, fingerprint.as_deref());
1148 }
1149 });
1150 });
1151 });
1152 });
1153}
1154
1155fn remembered_host(ui: &mut Ui, app: &mut ZaganApp, host: &str, fingerprint: Option<&str>) {
1156 ui.horizontal(|ui| {
236 ui.label( 1157 ui.label(
237 RichText::new("No chips on the felt") 1158 RichText::new(host)
238 .font(theme::sans(12.0)) 1159 .font(theme::mono(13.0))
239 .color(theme::TEXT_FAINT), 1160 .color(theme::TEXT),
240 ); 1161 );
241 } else { 1162 if let Some(fingerprint) = fingerprint {
242 for chip in &demo.bets.chips { 1163 ui.label(
243 bet_row(ui, &roulette::target_name(&chip.target), chip.amount); 1164 RichText::new(short_fingerprint(fingerprint))
1165 .font(theme::mono(10.0))
1166 .color(theme::TEXT_FAINT),
1167 );
244 } 1168 }
245 } 1169 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
1170 if widgets::ghost(ui, "Forget") {
1171 if let Some(db) = &app.db {
1172 if let Err(error) = database::forget_host(db, host) {
1173 app.connect.error = Some(format!("Failed to forget host: {error}"));
1174 }
1175 }
1176 }
1177 if widgets::ghost(ui, "Connect") {
1178 app.begin_connect(host);
1179 }
1180 });
1181 });
1182}
246 1183
247 ui.add_space(theme::SPACE_3); 1184fn short_fingerprint(fingerprint: &str) -> String {
248 ui.separator(); 1185 let head: String = fingerprint.chars().take(16).collect();
249 ui.add_space(theme::SPACE_2); 1186 format!("{head}…")
250 bet_row(ui, "Total", demo.bets.total()); 1187}
251 1188
252 ui.add_space(theme::SPACE_4); 1189// ---------------------------------------------------------------------------
253 let actions = ui.available_width(); 1190// Login
254 let clear_width = 108.0; 1191// ---------------------------------------------------------------------------
255 ui.horizontal(|ui| {
256 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
257 1192
258 let spin = Button::new( 1193fn login_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) {
259 RichText::new("SPIN") 1194 CentralPanel::default()
260 .font(theme::mono(theme::FONT_MONO)) 1195 .frame(
261 .color(theme::BG), 1196 Frame::new()
1197 .fill(theme::BG)
1198 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
262 ) 1199 )
263 .fill(if spinning { 1200 .show(ui, |ui| {
264 theme::WIN.gamma_multiply(0.45) 1201 ui.vertical_centered(|ui| {
265 } else { 1202 ui.add_space(100.0);
266 theme::WIN 1203 ui.allocate_ui_with_layout(vec2(420.0, 0.0), Layout::top_down(Align::Min), |ui| {
267 }) 1204 Frame::new()
268 .corner_radius(theme::RADIUS_MD) 1205 .fill(theme::SURFACE)
269 .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); 1206 .corner_radius(theme::RADIUS_LG)
270 let can_spin = !spinning && !demo.bets.is_empty(); 1207 .inner_margin(Margin::same(theme::SPACE_5 as i8))
271 if ui.add_enabled(can_spin, spin).clicked() { 1208 .show(ui, |ui| {
272 demo.start_spin(ui.ctx()); 1209 ui.label(
273 } 1210 RichText::new("Sign in")
1211 .font(theme::sans(24.0))
1212 .color(theme::TEXT),
1213 );
1214 if let Some(session) = &app.session {
1215 widgets::mono_text(
1216 ui,
1217 &format!(
1218 "{} · {}",
1219 session.host,
1220 short_fingerprint(
1221 session.fingerprint.as_deref().unwrap_or("unknown")
1222 )
1223 ),
1224 theme::TEXT_DIM,
1225 );
1226 }
1227 ui.add_space(theme::SPACE_4);
274 1228
275 let clear = Button::new( 1229 ui.label(theme::label("Username"));
276 RichText::new("CLEAR") 1230 ui.add(
277 .font(theme::mono(12.0)) 1231 TextEdit::singleline(&mut app.login.user)
278 .color(theme::TEXT_DIM), 1232 .desired_width(f32::INFINITY),
279 ) 1233 );
280 .fill(theme::SURFACE_RAISED) 1234 ui.add_space(theme::SPACE_3);
281 .stroke(Stroke::new(1.0, theme::LINE)) 1235 ui.label(theme::label("Password"));
282 .corner_radius(theme::RADIUS_MD) 1236 ui.add(
283 .min_size(vec2(clear_width, 44.0)); 1237 TextEdit::singleline(&mut app.login.password)
284 if ui 1238 .password(true)
285 .add_enabled(!demo.bets.is_empty() && !spinning, clear) 1239 .desired_width(f32::INFINITY),
286 .clicked() 1240 );
287 {
288 demo.begin_placing();
289 demo.bets = Bets::default();
290 }
291 });
292}
293 1241
294fn result_line(ui: &mut Ui, demo: &Demo) { 1242 ui.add_space(theme::SPACE_2);
295 ui.horizontal(|ui| match (demo.phase, demo.number) { 1243 ui.checkbox(
296 (RoulettePhase::Result, Some(number)) => { 1244 &mut app.login.remember,
297 ui.label(theme::label("Result")); 1245 RichText::new("Remember this login")
298 ui.label( 1246 .font(theme::sans(12.0))
299 RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) 1247 .color(theme::TEXT_DIM),
300 .font(theme::mono(14.0)) 1248 );
301 .color(color_of(number)), 1249
302 ); 1250 let enter = ui.input(|input| input.key_pressed(Key::Enter));
303 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 1251 ui.add_space(theme::SPACE_4);
304 payout(ui, demo); 1252 if widgets::primary(ui, "LOG IN", true, ui.available_width(), 44.0)
1253 || enter
1254 {
1255 app.login(ui.ctx(), false);
1256 }
1257 ui.add_space(theme::SPACE_2);
1258 if widgets::secondary(
1259 ui,
1260 "CREATE ACCOUNT",
1261 true,
1262 ui.available_width(),
1263 38.0,
1264 ) {
1265 app.login(ui.ctx(), true);
1266 }
1267
1268 if let Some(error) =
1269 lobby.login_error.as_ref().or(app.login.error.as_ref())
1270 {
1271 ui.add_space(theme::SPACE_3);
1272 widgets::error(ui, error);
1273 }
1274 });
1275
1276 ui.add_space(theme::SPACE_3);
1277 ui.allocate_ui_with_layout(
1278 vec2(420.0, 24.0),
1279 Layout::top_down(Align::Center),
1280 |ui| {
1281 if widgets::ghost(ui, "Disconnect") {
1282 app.disconnect();
1283 }
1284 },
1285 );
1286 });
305 }); 1287 });
306 } 1288 });
307 (RoulettePhase::Spinning, _) => { 1289}
308 ui.label( 1290
309 RichText::new("SPINNING…") 1291// ---------------------------------------------------------------------------
310 .font(theme::mono(12.0)) 1292// Lobby
311 .color(theme::GOLD), 1293// ---------------------------------------------------------------------------
312 ); 1294
313 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 1295fn lobby_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) {
1296 CentralPanel::default()
1297 .frame(
1298 Frame::new()
1299 .fill(theme::BG)
1300 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
1301 )
1302 .show(ui, |ui| {
1303 ui.horizontal(|ui| {
314 ui.label( 1304 ui.label(
315 RichText::new(format!("{} chips", demo.bets.chips.len())) 1305 RichText::new("Lobby")
316 .font(theme::mono(10.0)) 1306 .font(theme::sans(24.0))
317 .color(theme::TEXT_FAINT), 1307 .color(theme::TEXT),
318 ); 1308 );
319 });
320 }
321 _ => {
322 ui.label(
323 RichText::new("Place your bets")
324 .font(theme::sans(12.0))
325 .color(theme::TEXT_DIM),
326 );
327 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
328 ui.label( 1309 ui.label(
329 RichText::new(format!("{} chips", demo.bets.chips.len())) 1310 RichText::new(format!("{} games", lobby.games.len()))
330 .font(theme::mono(10.0)) 1311 .font(theme::sans(12.0))
331 .color(theme::TEXT_FAINT), 1312 .color(theme::TEXT_DIM),
332 ); 1313 );
1314 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
1315 if widgets::secondary(ui, "LOG OUT", true, 100.0, 34.0) {
1316 app.command(ui.ctx(), Command::Logout);
1317 }
1318 if widgets::secondary(ui, "HISTORY", true, 104.0, 34.0) {
1319 let effects = app.core.command(Command::History { limit: 20 });
1320 app.apply(ui.ctx(), effects);
1321 }
1322 });
333 }); 1323 });
334 } 1324
335 }); 1325 if let Some(session) = &app.session {
1326 if is_newer(&session.latest, VERSION) {
1327 ui.add_space(theme::SPACE_3);
1328 Frame::new()
1329 .fill(theme::GOLD.gamma_multiply(0.10))
1330 .stroke(Stroke::new(1.0, theme::GOLD.gamma_multiply(0.5)))
1331 .corner_radius(theme::RADIUS_MD)
1332 .inner_margin(Margin::symmetric(14, 10))
1333 .show(ui, |ui| {
1334 ui.horizontal(|ui| {
1335 ui.label(
1336 RichText::new(format!("Update available: v{}", session.latest))
1337 .font(theme::mono(12.0))
1338 .color(theme::GOLD),
1339 );
1340 widgets::mono_text(ui, &session.url, theme::TEXT_DIM);
1341 });
1342 });
1343 }
1344 }
1345
1346 ui.add_space(theme::SPACE_4);
1347 games_grid(ui, app, lobby);
1348
1349 ui.add_space(theme::SPACE_4);
1350 ui.separator();
1351 ui.add_space(theme::SPACE_2);
1352 ui.horizontal(|ui| {
1353 if let Some(session) = &app.session {
1354 widgets::note(
1355 ui,
1356 &format!(
1357 "Signed in to {} · server v{}",
1358 session.host, session.server_version
1359 ),
1360 );
1361 }
1362 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
1363 if widgets::ghost(ui, "Forget saved login") {
1364 app.forget_login();
1365 }
1366 });
1367 });
1368 });
336} 1369}
337 1370
338/// What the round paid, and the net for the spin. 1371fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) {
339fn payout(ui: &mut Ui, demo: &Demo) { 1372 const ORDER: [&str; 4] = ["slots", "roulette", "blackjack", "crash"];
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 1373
350 let profit = winnings as i64 - demo.spent as i64; 1374 for row in ORDER.chunks(2) {
351 ui.label( 1375 ui.columns(2, |columns| {
352 RichText::new(format!("({})", theme::signed_money(profit))) 1376 for (index, game) in row.iter().enumerate() {
353 .font(theme::sans(11.0)) 1377 let Some((key, name)) = lobby.games.iter().find(|(key, _)| key == game) else {
354 .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), 1378 continue;
355 ); 1379 };
356 ui.label( 1380 game_card(&mut columns[index], app, key, name);
357 RichText::new(theme::money(winnings as i64)) 1381 }
358 .font(theme::mono(15.0)) 1382 });
359 .color(theme::WIN), 1383 ui.add_space(theme::SPACE_4);
360 ); 1384 }
361 ui.label(theme::label("Won"));
362} 1385}
363 1386
364fn bet_row(ui: &mut Ui, name: &str, amount: u64) { 1387fn game_card(ui: &mut Ui, app: &mut ZaganApp, key: &str, name: &str) {
365 ui.horizontal(|ui| { 1388 Frame::new()
366 ui.label( 1389 .fill(theme::SURFACE)
367 RichText::new(name) 1390 .corner_radius(theme::RADIUS_LG)
368 .font(theme::sans(13.0)) 1391 .inner_margin(Margin::same(theme::SPACE_4 as i8))
369 .color(theme::TEXT_DIM), 1392 .show(ui, |ui| {
370 ); 1393 ui.set_width(ui.available_width());
371 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
372 ui.label( 1394 ui.label(
373 RichText::new(theme::money(amount as i64)) 1395 RichText::new(name)
374 .font(theme::mono(13.0)) 1396 .font(theme::sans(22.0))
375 .color(theme::GOLD), 1397 .color(theme::TEXT),
376 ); 1398 );
1399 ui.add_space(2.0);
1400 widgets::note(ui, tagline(key));
1401 ui.add_space(theme::SPACE_1);
1402 ui.label(
1403 RichText::new(meta(key))
1404 .font(theme::mono(10.0))
1405 .color(theme::TEXT_FAINT),
1406 );
1407 ui.add_space(theme::SPACE_3);
1408 if widgets::primary(ui, "PLAY", true, 110.0, 36.0) {
1409 let effects = app.core.command(Command::Play {
1410 game: key.to_string(),
1411 bet: Some(DEFAULT_BET),
1412 });
1413 app.apply(ui.ctx(), effects);
1414 }
377 }); 1415 });
378 });
379} 1416}
380 1417
381fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { 1418fn tagline(game: &str) -> &'static str {
382 let sense = if enabled { 1419 match game {
383 Sense::click() 1420 "slots" => "Five reels, five paylines, one lever.",
384 } else { 1421 "roulette" => "The European wheel, thirty-seven pockets.",
385 Sense::hover() 1422 "blackjack" => "Six decks, dealer stands on seventeen. Pays 3:2.",
386 }; 1423 "crash" => "The multiplier rises. Cash out before it busts.",
387 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); 1424 _ => "",
388 let center = rect.center();
389 let radius = 20.0;
390 let painter = ui.painter();
391 if selected || response.hovered() {
392 painter.circle_stroke(
393 center,
394 radius + 3.0,
395 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
396 );
397 } 1425 }
398 if enabled {
399 roulette::paint_chip(painter, center, radius, amount);
400 } else {
401 roulette::paint_chip_preview(painter, center, radius, amount);
402 }
403 response
404} 1426}
405 1427
406fn color_of(number: u8) -> Color32 { 1428fn meta(game: &str) -> &'static str {
407 match wheel_color(number) { 1429 match game {
408 "red" => theme::ROULETTE_RED, 1430 "slots" => "MIN 10 KR · RTP 96%",
409 "green" => theme::WIN, 1431 "roulette" => "MIN 10 KR · SINGLE ZERO",
410 _ => theme::TEXT, 1432 "blackjack" => "MIN 10 KR · S17 · 3:2",
1433 "crash" => "MIN 10 KR · 1% EDGE",
1434 _ => "",
411 } 1435 }
412} 1436}
413 1437
414// --------------------------------------------------------------------------- 1438// ---------------------------------------------------------------------------
415// Table 1439// History
416// --------------------------------------------------------------------------- 1440// ---------------------------------------------------------------------------
417 1441
418fn table(ui: &mut Ui, demo: &mut Demo) { 1442fn history_screen(ui: &mut Ui, app: &mut ZaganApp, records: &[PlayRecord]) {
419 ui.horizontal(|ui| { 1443 CentralPanel::default()
420 ui.label( 1444 .frame(
421 RichText::new("Table 01") 1445 Frame::new()
422 .font(theme::sans(15.0)) 1446 .fill(theme::BG)
423 .color(theme::TEXT), 1447 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
424 ); 1448 )
425 ui.label( 1449 .show(ui, |ui| {
426 RichText::new("European · single zero") 1450 ui.horizontal(|ui| {
427 .font(theme::sans(12.0)) 1451 if widgets::ghost(ui, "Back to lobby") {
428 .color(theme::TEXT_DIM), 1452 app.command(ui.ctx(), Command::ShowLobby);
429 ); 1453 }
430 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 1454 ui.label(
431 ui.label( 1455 RichText::new("Play history")
432 RichText::new("Limits 10 – 500 Kr") 1456 .font(theme::sans(24.0))
433 .font(theme::mono(12.0)) 1457 .color(theme::TEXT),
434 .color(theme::TEXT_FAINT), 1458 );
435 ); 1459 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
436 }); 1460 for limit in [50, 20, 10] {
437 }); 1461 if widgets::ghost(ui, &limit.to_string()) {
438 ui.add_space(theme::SPACE_4); 1462 let effects = app.core.command(Command::History { limit });
439 1463 app.apply(ui.ctx(), effects);
440 let view = demo.view(); 1464 }
441 let strip_height = 104.0; 1465 }
442 let available = ui.available_size(); 1466 ui.label(theme::label("Show"));
443 let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); 1467 });
444 let side_pad = ((available.x - size.x) * 0.5).max(0.0); 1468 });
445 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); 1469 ui.add_space(theme::SPACE_4);
446 let chip = demo.chip; 1470
447 let placing = demo.phase != RoulettePhase::Spinning; 1471 if records.is_empty() {
448 ui.add_space(top_pad); 1472 widgets::note(ui, "No plays recorded yet.");
449 ui.horizontal(|ui| { 1473 return;
450 ui.add_space(side_pad);
451 match roulette::felt(ui, size, &view) {
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 } 1474 }
460 _ => {}
461 }
462 });
463 1475
464 ui.add_space(theme::SPACE_4); 1476 Frame::new()
465 ui.horizontal(|ui| { 1477 .fill(theme::SURFACE)
466 ui.label(theme::label("Last")); 1478 .corner_radius(theme::RADIUS_LG)
467 if demo.last.is_empty() { 1479 .inner_margin(Margin::same(theme::SPACE_3 as i8))
468 ui.label( 1480 .show(ui, |ui| {
469 RichText::new("No spins yet") 1481 ui.set_width(ui.available_width());
470 .font(theme::sans(12.0)) 1482 ui.set_min_height(ui.available_height());
471 .color(theme::TEXT_FAINT),
472 );
473 }
474 for number in &demo.last {
475 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
476 roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
477 ui.add_space(theme::SPACE_1);
478 }
479 });
480 ui.add_space(theme::SPACE_2);
481 ui.label(
482 RichText::new(
483 "Click to place · right or middle click takes back · C clears · space spins",
484 )
485 .font(theme::sans(12.0))
486 .color(theme::TEXT_FAINT),
487 );
488}
489 1483
490// --------------------------------------------------------------------------- 1484 let widths = history_columns(ui.available_width());
491// Demo state 1485 history_header(ui, &widths);
492// --------------------------------------------------------------------------- 1486 ui.add_space(theme::SPACE_1);
1487 ui.separator();
493 1488
494struct Spin { 1489 egui::ScrollArea::vertical()
495 started: f64, 1490 .auto_shrink([false, false])
496 from: f32, 1491 .show(ui, |ui| {
497 delta: f32, 1492 for (index, record) in records.iter().enumerate() {
498 target: u8, 1493 history_row(ui, &widths, record, index % 2 == 1);
499} 1494 }
500 1495 });
501struct Demo { 1496 });
502 bets: Bets, 1497 });
503 chip: u64, 1498}
504 phase: RoulettePhase,
505 number: Option<u8>,
506 rotation: f32,
507 spin: Option<Spin>,
508 spins: usize,
509 last: Vec<u8>,
510 winnings: Option<u64>,
511 spent: u64,
512 balance: i64,
513 host: &'static str,
514 ping_ms: u32,
515 user: &'static str,
516 account: &'static str,
517}
518
519impl Demo {
520 fn new() -> Self {
521 let mut bets = Bets::default();
522 bets.add(BetTarget::Straight { number: 17 }, 50);
523 bets.add(
524 BetTarget::Corner {
525 numbers: [17, 18, 20, 21],
526 },
527 25,
528 );
529 bets.add(BetTarget::Outside { kind: Outside::Red }, 25);
530 1499
531 Self { 1500/// Column widths for the play log, filling the available width.
532 bets, 1501fn history_columns(width: f32) -> [f32; 6] {
533 chip: 25, 1502 let usable = (width - theme::SPACE_3 * 5.0).max(320.0);
534 phase: RoulettePhase::Placing, 1503 [
535 number: None, 1504 usable * 0.18,
536 rotation: 0.0, 1505 usable * 0.12,
537 spin: None, 1506 usable * 0.10,
538 spins: 0, 1507 usable * 0.10,
539 last: Vec::new(), 1508 usable * 0.12,
540 winnings: None, 1509 usable * 0.38,
541 spent: 0, 1510 ]
542 balance: 1000, 1511}
543 host: "127.0.0.1:11525",
544 ping_ms: 41,
545 user: "alice",
546 account: "#4161673306",
547 }
548 }
549 1512
550 fn view(&self) -> RouletteView { 1513fn history_header(ui: &mut Ui, widths: &[f32; 6]) {
551 RouletteView { 1514 history_line(
552 bet_amount: 10, 1515 ui,
553 balance: Some(self.balance), 1516 widths,
554 chip: self.chip, 1517 [
555 bets: self.bets.clone(), 1518 theme::label("When"),
556 cursor: Cursor::default(), 1519 theme::label("Game"),
557 phase: self.phase, 1520 theme::label("Bet"),
558 number: self.number, 1521 theme::label("Won"),
559 color: self.number.map(|number| wheel_color(number).to_string()), 1522 theme::label("Balance"),
560 winnings: self.winnings, 1523 theme::label("Result"),
561 error: None, 1524 ],
562 } 1525 false,
563 } 1526 );
1527}
564 1528
565 /// A bet change after a result starts the next round: the wheel stays 1529fn history_row(ui: &mut Ui, widths: &[f32; 6], record: &PlayRecord, stripe: bool) {
566 /// where it stopped, but the payout and the winning highlight do not. 1530 history_line(
567 fn begin_placing(&mut self) { 1531 ui,
568 if self.phase == RoulettePhase::Result { 1532 widths,
569 self.phase = RoulettePhase::Placing; 1533 [
570 self.winnings = None; 1534 RichText::new(record.short_time())
571 } 1535 .font(theme::mono(11.0))
572 } 1536 .color(theme::TEXT_DIM),
1537 RichText::new(record.game.to_uppercase())
1538 .font(theme::mono(11.0))
1539 .color(theme::TEXT),
1540 RichText::new(theme::money(record.bet))
1541 .font(theme::mono(12.0))
1542 .color(theme::TEXT_DIM),
1543 RichText::new(theme::money(record.winnings))
1544 .font(theme::mono(12.0))
1545 .color(if record.winnings > 0 {
1546 theme::WIN
1547 } else {
1548 theme::TEXT_FAINT
1549 }),
1550 RichText::new(theme::money(record.balance_after))
1551 .font(theme::mono(12.0))
1552 .color(theme::TEXT),
1553 RichText::new(record.summary())
1554 .font(theme::sans(12.0))
1555 .color(theme::TEXT_DIM),
1556 ],
1557 stripe,
1558 );
1559}
573 1560
574 fn tick(&mut self, ctx: &Context) { 1561fn history_line(ui: &mut Ui, widths: &[f32; 6], cells: [RichText; 6], stripe: bool) {
575 let now = ctx.input(|input| input.time); 1562 let spacing = theme::SPACE_3;
576 let mut finished = None; 1563 let width = widths.iter().sum::<f32>() + spacing * 5.0;
577 if let Some(spin) = &self.spin { 1564 let (rect, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
578 let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); 1565 if stripe {
579 let eased = 1.0 - (1.0 - t).powi(3); 1566 ui.painter().rect_filled(
580 self.rotation = spin.from + spin.delta * eased; 1567 rect,
581 if t >= 1.0 { 1568 theme::RADIUS_SM,
582 finished = Some(spin.target); 1569 theme::SURFACE_RAISED.gamma_multiply(0.6),
583 } 1570 );
584 ctx.request_repaint();
585 }
586 if let Some(number) = finished {
587 self.spin = None;
588 self.phase = RoulettePhase::Result;
589 self.number = Some(number);
590 self.winnings = Some(self.bets.winnings(number));
591 self.last.insert(0, number);
592 self.last.truncate(12);
593 }
594 } 1571 }
595 1572 let mut child = ui.new_child(
596 /// Pointer-first: the felt reads the mouse. The keyboard keeps the two 1573 eframe::egui::UiBuilder::new()
597 /// verbs that have no pointer equivalent in profile (spin and clear). 1574 .max_rect(rect.shrink2(vec2(6.0, 0.0)))
598 fn keys(&mut self, ctx: &Context) { 1575 .layout(Layout::left_to_right(Align::Center)),
599 if self.phase == RoulettePhase::Spinning { 1576 );
600 return; 1577 child.spacing_mut().item_spacing.x = spacing;
601 } 1578 for (width, cell) in widths.iter().zip(cells) {
602 if ctx.memory(|memory| memory.focused().is_none()) { 1579 child.add_sized(
603 if ctx.input(|input| input.key_pressed(Key::Space)) { 1580 vec2(*width, 18.0),
604 self.start_spin(ctx); 1581 egui::Label::new(cell).selectable(false).truncate(),
605 } 1582 );
606 if ctx.input(|input| input.key_pressed(Key::C)) {
607 self.begin_placing();
608 self.bets = Bets::default();
609 }
610 }
611 } 1583 }
1584}
612 1585
613 fn start_spin(&mut self, ctx: &Context) { 1586fn fatal(ui: &mut Ui, message: &str) {
614 if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { 1587 CentralPanel::default()
615 return; 1588 .frame(
616 } 1589 Frame::new()
617 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; 1590 .fill(theme::BG)
618 self.spins += 1; 1591 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
619 self.spent = self.bets.total(); 1592 )
620 self.winnings = None; 1593 .show(ui, |ui| {
621 1594 ui.vertical_centered(|ui| {
622 let segment = TAU / WHEEL.len() as f32; 1595 ui.add_space(120.0);
623 let index = WHEEL 1596 ui.label(
624 .iter() 1597 RichText::new("Cannot start")
625 .position(|number| *number == target) 1598 .font(theme::sans(24.0))
626 .unwrap_or(0) as f32; 1599 .color(theme::LOSS),
627 let desired = -index * segment; 1600 );
628 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; 1601 ui.add_space(theme::SPACE_2);
629 1602 widgets::error(ui, message);
630 self.spin = Some(Spin { 1603 });
631 started: ctx.input(|input| input.time),
632 from: self.rotation,
633 delta,
634 target,
635 }); 1604 });
636 self.phase = RoulettePhase::Spinning;
637 self.number = None;
638 ctx.request_repaint();
639 }
640} 1605}
diff --git a/gui/src/blackjack.rs b/gui/src/blackjack.rs
new file mode 100644
index 0000000..d42f000
--- /dev/null
+++ b/gui/src/blackjack.rs
@@ -0,0 +1,428 @@
1//! Blackjack screen: cards painted from `BlackjackView`.
2
3use eframe::egui::{
4 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
5 pos2, vec2,
6};
7use zagan_bet::blackjack::is_red_suit;
8use zagan_bet::client::view::{BlackjackView, Command};
9
10use crate::{theme, widgets};
11
12const CARD: Vec2 = vec2(92.0, 128.0);
13
14pub fn show(ui: &mut Ui, view: &BlackjackView) -> Option<Command> {
15 let mut command = None;
16 eframe::egui::CentralPanel::default()
17 .frame(eframe::egui::Frame::new().fill(theme::BG))
18 .show(ui, |ui| {
19 let full = ui.max_rect();
20 let sidebar_rect = eframe::egui::Rect::from_min_max(
21 eframe::egui::pos2(full.right() - 336.0, full.top()),
22 full.right_bottom(),
23 );
24 let board_rect = eframe::egui::Rect::from_min_max(
25 full.min,
26 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
27 );
28 ui.painter().rect_filled(
29 sidebar_rect,
30 eframe::egui::CornerRadius::ZERO,
31 theme::SURFACE,
32 );
33 ui.scope_builder(
34 eframe::egui::UiBuilder::new()
35 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
36 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
37 |ui| board(ui, view),
38 );
39 ui.scope_builder(
40 eframe::egui::UiBuilder::new()
41 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
42 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
43 |ui| {
44 command = sidebar(ui, view);
45 },
46 );
47 });
48 command
49}
50
51fn live(view: &BlackjackView) -> bool {
52 view.status == "player"
53}
54
55fn sidebar(ui: &mut Ui, view: &BlackjackView) -> Option<Command> {
56 let mut command = None;
57 let betting = !live(view) && view.status != "dealing" && view.status != "forfeiting";
58 let (content, actions) = widgets::sidebar_layout(ui, 200.0);
59
60 ui.scope_builder(
61 eframe::egui::UiBuilder::new()
62 .max_rect(content)
63 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
64 |ui| {
65 eframe::egui::ScrollArea::vertical()
66 .auto_shrink([false, false])
67 .show(ui, |ui| {
68 ui.label(
69 eframe::egui::RichText::new("Blackjack")
70 .font(theme::sans(22.0))
71 .color(theme::TEXT),
72 );
73 ui.label(
74 eframe::egui::RichText::new("6 decks · dealer stands on 17 · 3:2")
75 .font(theme::sans(12.0))
76 .color(theme::TEXT_DIM),
77 );
78
79 ui.add_space(theme::SPACE_5);
80 ui.label(theme::label("Bet"));
81 ui.add_space(theme::SPACE_2);
82 if let Some(delta) = widgets::bet_control(ui, view.bet, betting) {
83 command = Some(Command::AdjustBet(delta));
84 }
85
86 ui.add_space(theme::SPACE_4);
87 match view.status.as_str() {
88 "player" => widgets::note(ui, "Hit, stand or double."),
89 "done" => {
90 if let Some(hand) = &view.hand {
91 let (text, color) = result_text(&hand.result, hand.winnings);
92 ui.label(
93 eframe::egui::RichText::new(text)
94 .font(theme::mono(15.0))
95 .color(color),
96 );
97 }
98 }
99 "dealing" => widgets::note(ui, "Dealing…"),
100 _ => widgets::note(ui, "Press DEAL to start a hand."),
101 }
102
103 if let Some(error) = &view.error {
104 ui.add_space(theme::SPACE_3);
105 widgets::error(ui, error);
106 }
107
108 ui.add_space(theme::SPACE_4);
109 ui.separator();
110 ui.add_space(theme::SPACE_2);
111 widgets::note(ui, "Blackjack pays 3:2. The dealer stands on soft 17.");
112 widgets::note(ui, "Double only on the first two cards.");
113 });
114 },
115 );
116
117 ui.scope_builder(
118 eframe::egui::UiBuilder::new()
119 .max_rect(actions)
120 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
121 |ui| {
122 if live(view) {
123 if widgets::ghost(ui, "Forfeit hand") {
124 command = Some(Command::Leave);
125 }
126 } else if widgets::ghost(ui, "Leave table") {
127 command = Some(Command::Leave);
128 }
129
130 match view.status.as_str() {
131 "player" => {
132 if view
133 .hand
134 .as_ref()
135 .is_some_and(|hand| hand.actions.iter().any(|action| action == "double"))
136 {
137 ui.add_space(theme::SPACE_2);
138 if widgets::secondary(ui, "DOUBLE", true, ui.available_width(), 38.0) {
139 command = Some(Command::Double);
140 }
141 }
142 ui.add_space(theme::SPACE_2);
143 if widgets::secondary(ui, "STAND", true, ui.available_width(), 38.0) {
144 command = Some(Command::Stand);
145 }
146 ui.add_space(theme::SPACE_2);
147 if widgets::primary(ui, "HIT", true, ui.available_width(), 44.0) {
148 command = Some(Command::Hit);
149 }
150 }
151 "dealing" | "forfeiting" => {
152 ui.label(
153 eframe::egui::RichText::new(if view.status == "dealing" {
154 "DEALING…"
155 } else {
156 "FORFEITING…"
157 })
158 .font(theme::mono(12.0))
159 .color(theme::GOLD),
160 );
161 }
162 _ => {
163 if widgets::primary(ui, "DEAL", true, ui.available_width(), 46.0) {
164 command = Some(Command::Deal);
165 }
166 }
167 }
168 },
169 );
170
171 command
172}
173
174fn result_text(result: &str, winnings: u64) -> (String, Color32) {
175 match result {
176 "win" => (
177 format!("YOU WIN {}", theme::money(winnings as i64)),
178 theme::WIN,
179 ),
180 "blackjack" => (
181 format!("BLACKJACK {}", theme::money(winnings as i64)),
182 theme::GOLD,
183 ),
184 "push" => ("PUSH".to_string(), theme::TEXT),
185 "bust" => ("BUST".to_string(), theme::LOSS),
186 "forfeit" => ("FORFEITED".to_string(), theme::TEXT_DIM),
187 _ => ("DEALER WINS".to_string(), theme::LOSS),
188 }
189}
190
191fn board(ui: &mut Ui, view: &BlackjackView) {
192 ui.horizontal(|ui| {
193 ui.label(
194 eframe::egui::RichText::new("Table 01")
195 .font(theme::sans(15.0))
196 .color(theme::TEXT),
197 );
198 ui.label(
199 eframe::egui::RichText::new("Blackjack · six decks")
200 .font(theme::sans(12.0))
201 .color(theme::TEXT_DIM),
202 );
203 ui.with_layout(
204 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
205 |ui| {
206 ui.label(
207 eframe::egui::RichText::new("3:2 · S17")
208 .font(theme::mono(12.0))
209 .color(theme::TEXT_FAINT),
210 );
211 },
212 );
213 });
214 ui.add_space(theme::SPACE_5);
215
216 let Some(hand) = &view.hand else {
217 centered_note(ui, "Place a bet and deal a hand.");
218 return;
219 };
220
221 let width = ui.available_width();
222 let hidden = hand.dealer.iter().any(|card| card == "?");
223 hand_header(ui, "Dealer", hand.dealer_total, hidden);
224 paint_hand(ui, &hand.dealer, width);
225 ui.add_space(theme::SPACE_5);
226
227 let (line, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
228 ui.painter()
229 .rect_filled(line, CornerRadius::ZERO, theme::LINE);
230 ui.add_space(theme::SPACE_5);
231
232 hand_header(ui, "You", hand.player_total, false);
233 paint_hand(ui, &hand.player, width);
234
235 ui.add_space(theme::SPACE_4);
236 ui.label(
237 eframe::egui::RichText::new(match view.status.as_str() {
238 "player" => "Your move.",
239 "dealing" => "Dealing…",
240 "done" => "Hand complete.",
241 _ => "Place your bet.",
242 })
243 .font(theme::sans(13.0))
244 .color(theme::TEXT_DIM),
245 );
246}
247
248fn hand_header(ui: &mut Ui, label: &str, total: u8, hidden: bool) {
249 ui.horizontal(|ui| {
250 ui.label(theme::label(label));
251 ui.label(
252 eframe::egui::RichText::new(if hidden {
253 format!("{total} + ?")
254 } else {
255 total.to_string()
256 })
257 .font(theme::mono(15.0))
258 .color(if label == "You" {
259 theme::TEXT
260 } else {
261 theme::TEXT_DIM
262 }),
263 );
264 });
265 ui.add_space(theme::SPACE_2);
266}
267
268fn paint_hand(ui: &mut Ui, cards: &[String], width: f32) {
269 if cards.is_empty() {
270 ui.add_space(CARD.y);
271 return;
272 }
273 let step = CARD.x * 0.74;
274 let hand_width = CARD.x + step * (cards.len() as f32 - 1.0);
275 let (rect, _) = ui.allocate_exact_size(vec2(width.max(hand_width), CARD.y), Sense::hover());
276 let left = rect.left() + ((rect.width() - hand_width) * 0.5).max(0.0);
277 let painter = ui.painter();
278 for (index, card) in cards.iter().enumerate() {
279 let rect = Rect::from_min_size(pos2(left + step * index as f32, rect.top()), CARD);
280 if card == "?" {
281 paint_back(painter, rect);
282 } else {
283 paint_card(painter, rect, card);
284 }
285 }
286}
287
288fn paint_card(painter: &Painter, rect: Rect, card: &str) {
289 painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0xe8, 0xe4, 0xd8));
290 painter.rect_stroke(
291 rect,
292 theme::RADIUS_MD,
293 Stroke::new(1.0, Color32::from_rgb(0x9a, 0x94, 0x86)),
294 StrokeKind::Inside,
295 );
296 let color = if is_red_suit(card) {
297 Color32::from_rgb(0xb0, 0x31, 0x3a)
298 } else {
299 Color32::from_rgb(0x15, 0x1b, 0x21)
300 };
301 let suit = card.chars().last().unwrap_or('?');
302 let rank = &card[..card.len().saturating_sub(suit.len_utf8())];
303 painter.text(
304 rect.left_top() + vec2(9.0, 6.0),
305 Align2::LEFT_TOP,
306 rank,
307 theme::mono(20.0),
308 color,
309 );
310 painter.text(
311 rect.right_bottom() - vec2(9.0, 6.0),
312 Align2::RIGHT_BOTTOM,
313 rank,
314 theme::mono(20.0),
315 color,
316 );
317 paint_suit(painter, rect.center(), rect.width() * 0.55, suit, color);
318}
319
320/// Card suits are drawn, not typed: neither embedded font carries the glyphs.
321///
322/// The circles must overlap the stem, or the shape reads as three dots above a
323/// detached bar.
324fn paint_suit(painter: &Painter, center: Pos2, size: f32, suit: char, color: Color32) {
325 let s = size;
326 match suit {
327 'H' => {
328 painter.circle_filled(center + vec2(-s * 0.22, -s * 0.12), s * 0.31, color);
329 painter.circle_filled(center + vec2(s * 0.22, -s * 0.12), s * 0.31, color);
330 painter.add(Shape::convex_polygon(
331 vec![
332 center + vec2(-s * 0.50, 0.0),
333 center + vec2(s * 0.50, 0.0),
334 center + vec2(0.0, s * 0.56),
335 ],
336 color,
337 Stroke::NONE,
338 ));
339 }
340 'D' => {
341 painter.add(Shape::convex_polygon(
342 vec![
343 center + vec2(0.0, -s * 0.56),
344 center + vec2(s * 0.42, 0.0),
345 center + vec2(0.0, s * 0.56),
346 center + vec2(-s * 0.42, 0.0),
347 ],
348 color,
349 Stroke::NONE,
350 ));
351 }
352 'S' => {
353 painter.circle_filled(center + vec2(-s * 0.24, s * 0.10), s * 0.32, color);
354 painter.circle_filled(center + vec2(s * 0.24, s * 0.10), s * 0.32, color);
355 painter.add(Shape::convex_polygon(
356 vec![
357 center + vec2(-s * 0.50, s * 0.02),
358 center + vec2(s * 0.50, s * 0.02),
359 center + vec2(0.0, -s * 0.56),
360 ],
361 color,
362 Stroke::NONE,
363 ));
364 painter.add(Shape::convex_polygon(
365 vec![
366 center + vec2(-s * 0.11, s * 0.08),
367 center + vec2(s * 0.11, s * 0.08),
368 center + vec2(s * 0.05, s * 0.52),
369 center + vec2(-s * 0.05, s * 0.52),
370 ],
371 color,
372 Stroke::NONE,
373 ));
374 }
375 'C' => {
376 painter.circle_filled(center + vec2(0.0, -s * 0.26), s * 0.33, color);
377 painter.circle_filled(center + vec2(-s * 0.30, s * 0.10), s * 0.33, color);
378 painter.circle_filled(center + vec2(s * 0.30, s * 0.10), s * 0.33, color);
379 painter.add(Shape::convex_polygon(
380 vec![
381 center + vec2(-s * 0.13, s * 0.08),
382 center + vec2(s * 0.13, s * 0.08),
383 center + vec2(s * 0.05, s * 0.52),
384 center + vec2(-s * 0.05, s * 0.52),
385 ],
386 color,
387 Stroke::NONE,
388 ));
389 }
390 _ => {}
391 }
392}
393
394fn paint_back(painter: &Painter, rect: Rect) {
395 painter.rect_filled(rect, theme::RADIUS_MD, Color32::from_rgb(0x1b, 0x3a, 0x2c));
396 painter.rect_stroke(
397 rect,
398 theme::RADIUS_MD,
399 Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)),
400 StrokeKind::Inside,
401 );
402 let inner = rect.shrink(8.0);
403 painter.rect_stroke(
404 inner,
405 theme::RADIUS_SM,
406 Stroke::new(1.0, Color32::from_rgb(0x2b, 0x5a, 0x43)),
407 StrokeKind::Inside,
408 );
409 painter.text(
410 rect.center(),
411 Align2::CENTER_CENTER,
412 "Z",
413 theme::mono(30.0),
414 Color32::from_rgb(0x4e, 0x8a, 0x68),
415 );
416}
417
418fn centered_note(ui: &mut Ui, text: &str) {
419 let available = ui.available_size();
420 ui.add_space(available.y * 0.35);
421 ui.vertical_centered(|ui| {
422 ui.label(
423 eframe::egui::RichText::new(text)
424 .font(theme::sans(15.0))
425 .color(theme::TEXT_DIM),
426 );
427 });
428}
diff --git a/gui/src/crash.rs b/gui/src/crash.rs
new file mode 100644
index 0000000..14197ae
--- /dev/null
+++ b/gui/src/crash.rs
@@ -0,0 +1,416 @@
1//! Crash screen: a live multiplier painted from `CrashView`.
2
3use eframe::egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
4use zagan_bet::client::view::{Command, CrashView};
5
6use crate::{theme, widgets};
7
8/// One sample of the running curve.
9struct Sample {
10 seconds: f32,
11 multiplier: f64,
12}
13
14#[derive(Default)]
15pub struct Fx {
16 points: Vec<Sample>,
17 started: Option<f64>,
18 status: String,
19 peak: f64,
20 last_crash: Option<f64>,
21}
22
23impl Fx {
24 pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &CrashView) {
25 let now = ctx.input(|input| input.time);
26
27 if view.status == "starting" && self.status != "starting" {
28 self.points.clear();
29 self.started = None;
30 self.peak = 1.0;
31 }
32
33 if view.status == "running" {
34 let started = *self.started.get_or_insert(now);
35 let seconds = (now - started) as f32;
36 let last = self.points.last().map(|sample| sample.seconds);
37 if last.is_none_or(|last| seconds - last >= 0.016) {
38 self.points.push(Sample {
39 seconds,
40 multiplier: view.multiplier,
41 });
42 self.peak = self.peak.max(view.multiplier);
43 }
44 ctx.request_repaint_after(std::time::Duration::from_millis(16));
45 } else if view.status == "starting" {
46 ctx.request_repaint_after(std::time::Duration::from_millis(50));
47 }
48
49 if view.status == "crashed" {
50 if let Some(point) = view.crash_point {
51 self.last_crash = Some(point);
52 }
53 }
54 if view.status != self.status {
55 self.status = view.status.clone();
56 }
57 }
58
59 fn reset(&mut self) {
60 self.points.clear();
61 self.started = None;
62 self.peak = 1.0;
63 }
64}
65
66pub fn show(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> {
67 let mut command = None;
68 eframe::egui::CentralPanel::default()
69 .frame(eframe::egui::Frame::new().fill(theme::BG))
70 .show(ui, |ui| {
71 let full = ui.max_rect();
72 let sidebar_rect = eframe::egui::Rect::from_min_max(
73 eframe::egui::pos2(full.right() - 336.0, full.top()),
74 full.right_bottom(),
75 );
76 let board_rect = eframe::egui::Rect::from_min_max(
77 full.min,
78 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
79 );
80 ui.painter().rect_filled(
81 sidebar_rect,
82 eframe::egui::CornerRadius::ZERO,
83 theme::SURFACE,
84 );
85 ui.scope_builder(
86 eframe::egui::UiBuilder::new()
87 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
88 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
89 |ui| chart(ui, view, fx),
90 );
91 ui.scope_builder(
92 eframe::egui::UiBuilder::new()
93 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
94 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
95 |ui| {
96 command = sidebar(ui, view, fx);
97 },
98 );
99 });
100 command
101}
102
103fn sidebar(ui: &mut Ui, view: &CrashView, fx: &mut Fx) -> Option<Command> {
104 let mut command = None;
105 let running = view.status == "running";
106 let starting = view.status == "starting";
107 let (content, actions) = widgets::sidebar_layout(ui, 130.0);
108
109 ui.scope_builder(
110 eframe::egui::UiBuilder::new()
111 .max_rect(content)
112 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
113 |ui| {
114 eframe::egui::ScrollArea::vertical()
115 .auto_shrink([false, false])
116 .show(ui, |ui| {
117 ui.label(
118 eframe::egui::RichText::new("Crash")
119 .font(theme::sans(22.0))
120 .color(theme::TEXT),
121 );
122 ui.label(
123 eframe::egui::RichText::new("Rise · cash out · don't bust")
124 .font(theme::sans(12.0))
125 .color(theme::TEXT_DIM),
126 );
127
128 ui.add_space(theme::SPACE_5);
129 ui.label(theme::label("Bet"));
130 ui.add_space(theme::SPACE_2);
131 if let Some(delta) = widgets::bet_control(ui, view.bet, !running && !starting) {
132 command = Some(Command::AdjustBet(delta));
133 }
134
135 ui.add_space(theme::SPACE_4);
136 widgets::row(
137 ui,
138 "Potential",
139 &theme::money(view.potential as i64),
140 theme::GOLD,
141 );
142
143 ui.add_space(theme::SPACE_3);
144 status(ui, view);
145
146 if let Some(point) = fx.last_crash {
147 ui.add_space(theme::SPACE_2);
148 widgets::row(ui, "Last crash", &format!("{point:.2}x"), theme::TEXT_DIM);
149 }
150
151 if let Some(error) = &view.error {
152 ui.add_space(theme::SPACE_3);
153 widgets::error(ui, error);
154 }
155
156 ui.add_space(theme::SPACE_4);
157 ui.separator();
158 ui.add_space(theme::SPACE_2);
159 widgets::note(
160 ui,
161 "The crash point is server-timed. The curve here is display only.",
162 );
163 });
164 },
165 );
166
167 ui.scope_builder(
168 eframe::egui::UiBuilder::new()
169 .max_rect(actions)
170 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
171 |ui| {
172 if widgets::ghost(
173 ui,
174 if running {
175 "Forfeit round"
176 } else {
177 "Leave table"
178 },
179 ) {
180 command = Some(Command::Leave);
181 }
182 ui.add_space(theme::SPACE_2);
183 if running {
184 if widgets::primary(ui, "CASH OUT", true, ui.available_width(), 46.0) {
185 command = Some(Command::CashOut);
186 }
187 } else if !starting && widgets::primary(ui, "START", true, ui.available_width(), 46.0) {
188 fx.reset();
189 command = Some(Command::Start);
190 }
191 },
192 );
193
194 command
195}
196
197fn status(ui: &mut Ui, view: &CrashView) {
198 match view.status.as_str() {
199 "starting" => widgets::note(ui, "Round starting…"),
200 "running" => widgets::note(ui, "Rising. Cash out before it crashes."),
201 "cashed" => {
202 ui.label(
203 eframe::egui::RichText::new(format!(
204 "CASHED OUT {:.2}x +{}",
205 view.multiplier,
206 theme::money(view.payout as i64)
207 ))
208 .font(theme::mono(14.0))
209 .color(theme::WIN),
210 );
211 }
212 "crashed" => {
213 ui.label(
214 eframe::egui::RichText::new(format!(
215 "CRASHED at {:.2}x",
216 view.crash_point.unwrap_or(view.multiplier)
217 ))
218 .font(theme::mono(14.0))
219 .color(theme::LOSS),
220 );
221 }
222 "forfeit" => {
223 ui.label(
224 eframe::egui::RichText::new("FORFEITED")
225 .font(theme::mono(14.0))
226 .color(theme::TEXT_DIM),
227 );
228 }
229 _ => widgets::note(ui, "Press START to play."),
230 }
231}
232
233fn chart(ui: &mut Ui, view: &CrashView, fx: &Fx) {
234 ui.horizontal(|ui| {
235 ui.label(
236 eframe::egui::RichText::new("Table 01")
237 .font(theme::sans(15.0))
238 .color(theme::TEXT),
239 );
240 ui.label(
241 eframe::egui::RichText::new("Crash · server-timed")
242 .font(theme::sans(12.0))
243 .color(theme::TEXT_DIM),
244 );
245 ui.with_layout(
246 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
247 |ui| {
248 ui.label(
249 eframe::egui::RichText::new("Cash out any time before the crash")
250 .font(theme::mono(11.0))
251 .color(theme::TEXT_FAINT),
252 );
253 },
254 );
255 });
256 ui.add_space(theme::SPACE_4);
257
258 let available = ui.available_size();
259 let size = vec2(available.x, (available.y - 70.0).max(260.0));
260 let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
261 let painter = ui.painter();
262
263 painter.rect_filled(rect, theme::RADIUS_LG, theme::SURFACE);
264 painter.rect_stroke(
265 rect,
266 theme::RADIUS_LG,
267 Stroke::new(1.0, theme::LINE),
268 StrokeKind::Inside,
269 );
270
271 let plot = rect.shrink2(vec2(56.0, 48.0));
272 let max_seconds = fx
273 .points
274 .last()
275 .map(|sample| sample.seconds)
276 .unwrap_or(0.0)
277 .max(6.0)
278 * 1.08;
279 let max_multiplier = fx.peak.max(2.0) * 1.15;
280
281 painter.text(
282 plot.left_top() + vec2(0.0, 2.0),
283 Align2::LEFT_TOP,
284 format!("{:.2}x", view.multiplier),
285 theme::mono(46.0),
286 curve_color(view),
287 );
288 painter.text(
289 plot.left_top() + vec2(2.0, 58.0),
290 Align2::LEFT_TOP,
291 status_label(view),
292 theme::mono(10.0),
293 theme::TEXT_FAINT,
294 );
295
296 // Grid: whole multipliers and two-second columns.
297 let grid = Stroke::new(1.0, theme::LINE.gamma_multiply(0.55));
298 let mut step = 1.0;
299 while max_multiplier / step > 6.0 {
300 step += 1.0;
301 }
302 let mut value = step;
303 while value <= max_multiplier {
304 let y = map_y(plot, value, max_multiplier);
305 painter.hline(plot.x_range(), y, grid);
306 painter.text(
307 pos2(plot.left() - 10.0, y),
308 Align2::RIGHT_CENTER,
309 format!("{value:.0}x"),
310 theme::mono(10.0),
311 theme::TEXT_FAINT,
312 );
313 value += step;
314 }
315 let mut second = 2.0;
316 while f64::from(second) <= f64::from(max_seconds) {
317 let x = plot.left() + plot.width() * (second / max_seconds);
318 painter.vline(x, plot.y_range(), grid);
319 painter.text(
320 pos2(x, plot.bottom() + 8.0),
321 Align2::CENTER_TOP,
322 format!("{second:.0}s"),
323 theme::mono(10.0),
324 theme::TEXT_FAINT,
325 );
326 second += 2.0;
327 }
328
329 if fx.points.is_empty() {
330 painter.text(
331 plot.center(),
332 Align2::CENTER_CENTER,
333 if view.status == "running" {
334 "…"
335 } else {
336 "Press START to play"
337 },
338 theme::sans(14.0),
339 theme::TEXT_FAINT,
340 );
341 } else {
342 let points: Vec<Pos2> = fx
343 .points
344 .iter()
345 .map(|sample| {
346 pos2(
347 plot.left() + plot.width() * (sample.seconds / max_seconds),
348 map_y(plot, sample.multiplier, max_multiplier),
349 )
350 })
351 .collect();
352
353 // A soft fill under the curve.
354 if points.len() >= 2 {
355 let mut fill = points.clone();
356 fill.push(pos2(points[points.len() - 1].x, plot.bottom()));
357 fill.push(pos2(points[0].x, plot.bottom()));
358 painter.add(Shape::convex_polygon(
359 fill,
360 curve_color(view).gamma_multiply(0.10),
361 Stroke::NONE,
362 ));
363 }
364 painter.add(Shape::line(points, Stroke::new(2.5, curve_color(view))));
365
366 if view.status == "crashed" {
367 if let Some(point) = view.crash_point {
368 let y = map_y(plot, point, max_multiplier);
369 let x = plot.left()
370 + plot.width() * (fx.points.last().map_or(0.0, |s| s.seconds) / max_seconds);
371 painter.circle_filled(pos2(x, y), 5.0, theme::LOSS);
372 painter.text(
373 pos2(x + 10.0, y - 8.0),
374 Align2::LEFT_BOTTOM,
375 format!("{point:.2}x"),
376 theme::mono(12.0),
377 theme::LOSS,
378 );
379 }
380 }
381 }
382
383 ui.add_space(theme::SPACE_4);
384 ui.label(
385 eframe::egui::RichText::new(
386 "The crash point is drawn from the server; the multiplier here is display only.",
387 )
388 .font(theme::sans(11.0))
389 .color(theme::TEXT_FAINT),
390 );
391}
392
393fn map_y(plot: Rect, multiplier: f64, max_multiplier: f64) -> f32 {
394 let range = (max_multiplier - 1.0).max(0.1);
395 plot.bottom() - ((multiplier - 1.0) / range) as f32 * plot.height()
396}
397
398fn status_label(view: &CrashView) -> &'static str {
399 match view.status.as_str() {
400 "starting" => "STARTING",
401 "running" => "LIVE",
402 "cashed" => "CASHED OUT",
403 "crashed" => "CRASHED",
404 "forfeit" => "FORFEITED",
405 _ => "READY",
406 }
407}
408
409fn curve_color(view: &CrashView) -> Color32 {
410 match view.status.as_str() {
411 "running" => theme::WIN,
412 "cashed" => theme::GOLD,
413 "crashed" => theme::LOSS,
414 _ => theme::TEXT_DIM,
415 }
416}
diff --git a/gui/src/lib.rs b/gui/src/lib.rs
index e0f5bc9..e5895fc 100644
--- a/gui/src/lib.rs
+++ b/gui/src/lib.rs
@@ -5,5 +5,10 @@
5//! layout. It never enters the TUI or server build graph. 5//! layout. It never enters the TUI or server build graph.
6 6
7pub mod app; 7pub mod app;
8pub mod blackjack;
9pub mod crash;
10pub mod net;
8pub mod roulette; 11pub mod roulette;
12pub mod slots;
9pub mod theme; 13pub mod theme;
14pub mod widgets;
diff --git a/gui/src/main.rs b/gui/src/main.rs
index cf1c67d..82babb6 100644
--- a/gui/src/main.rs
+++ b/gui/src/main.rs
@@ -1,12 +1,36 @@
1//! Desktop frontend for zagan-bet. 1//! Desktop frontend for zagan-bet.
2//! 2//!
3//! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the 3//! Phase 4: the egui shell over the shared `ClientCore`, with the transport on
4//! roulette reference screen; the shared core and transport are wired in 4//! a worker thread. The same data directory and flags as the terminal client.
5//! Phase 4.
6 5
7use zagan_bet_gui::app::ZaganApp; 6use zagan_bet::client::paths::{default_data_dir, parse_args};
7use zagan_bet_gui::app::{Startup, ZaganApp};
8 8
9fn main() -> eframe::Result { 9fn main() -> eframe::Result {
10 let args = match parse_args(std::env::args().skip(1)) {
11 Ok(args) => args,
12 Err(message) => {
13 eprintln!("{message}");
14 std::process::exit(2);
15 }
16 };
17 if args.help {
18 println!(
19 "usage: zagan-bet-gui [-d <dir>] [--unsafe]
20
21 -d, --data-dir <dir> where to keep client.db (default: XDG data dir)
22 --unsafe skip certificate verification
23 -h, --help show this message"
24 );
25 return Ok(());
26 }
27
28 let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
29 if let Err(error) = std::fs::create_dir_all(&data_dir) {
30 eprintln!("Cannot create {}: {error}", data_dir.display());
31 std::process::exit(1);
32 }
33
10 let options = eframe::NativeOptions { 34 let options = eframe::NativeOptions {
11 viewport: eframe::egui::ViewportBuilder::default() 35 viewport: eframe::egui::ViewportBuilder::default()
12 .with_title("Zagan-Bet") 36 .with_title("Zagan-Bet")
@@ -17,9 +41,13 @@ fn main() -> eframe::Result {
17 ..Default::default() 41 ..Default::default()
18 }; 42 };
19 43
44 let startup = Startup {
45 data_dir,
46 unsafe_mode: args.unsafe_mode,
47 };
20 eframe::run_native( 48 eframe::run_native(
21 "Zagan-Bet", 49 "Zagan-Bet",
22 options, 50 options,
23 Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))), 51 Box::new(move |cc| Ok(Box::new(ZaganApp::new(cc, startup)))),
24 ) 52 )
25} 53}
diff --git a/gui/src/net.rs b/gui/src/net.rs
new file mode 100644
index 0000000..d2bc7e4
--- /dev/null
+++ b/gui/src/net.rs
@@ -0,0 +1,220 @@
1//! Background network worker for the desktop frontend.
2//!
3//! The core is I/O-free, so the adapter owns the TLS stream. One blocking
4//! thread keeps the connection, forwards every server line, and wakes the UI
5//! through `egui::Context::request_repaint`. There is no async runtime: the
6//! protocol has one request in flight plus server pushes.
7
8use std::path::PathBuf;
9use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
10use std::thread;
11use std::time::Duration;
12
13use eframe::egui;
14use rusqlite::Connection;
15use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt};
16use zagan_bet::client::{database, paths};
17
18/// How often the worker checks the socket for pushes when idle.
19const POLL: Duration = Duration::from_millis(8);
20/// How long a first-contact prompt may stay unanswered.
21const TRUST_TIMEOUT: Duration = Duration::from_secs(120);
22
23/// What the UI asks the worker to do.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command {
26 Connect { addr: String },
27 Send(String),
28 Disconnect,
29 Stop,
30}
31
32/// Why a connection attempt failed. The UI picks a screen from this.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Failure {
35 TrustDeclined,
36 KeyChanged { expected: String, observed: String },
37 VersionMismatch,
38 Timeout,
39 Other,
40}
41
42/// A connection that survived trust, version and auto-login.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Connected {
45 pub addr: String,
46 pub user: Option<String>,
47 pub auth_error: Option<String>,
48 pub fingerprint: Option<String>,
49 pub server_version: String,
50 pub latest: String,
51 pub url: String,
52 pub elapsed_ms: f32,
53}
54
55/// What the worker tells the UI.
56pub enum Event {
57 /// First contact: the UI must answer through the sender.
58 Trust {
59 addr: String,
60 fingerprint: String,
61 reply: Sender<bool>,
62 },
63 Connected(Connected),
64 Failed {
65 addr: String,
66 failure: Failure,
67 message: String,
68 },
69 Line(String),
70 Closed,
71}
72
73fn classify(error: &TransportError) -> Failure {
74 match error {
75 TransportError::TrustDeclined => Failure::TrustDeclined,
76 TransportError::KeyChanged { expected, observed } => Failure::KeyChanged {
77 expected: expected.clone(),
78 observed: observed.clone(),
79 },
80 TransportError::VersionMismatch(_) => Failure::VersionMismatch,
81 TransportError::Timeout => Failure::Timeout,
82 _ => Failure::Other,
83 }
84}
85
86/// Handle held by the app. Dropping it stops the worker.
87pub struct Network {
88 commands: Sender<Command>,
89 events: Receiver<Event>,
90}
91
92impl Network {
93 pub fn spawn(ctx: egui::Context, data_dir: PathBuf, unsafe_mode: bool) -> Self {
94 let (commands, command_rx) = mpsc::channel();
95 let (event_tx, events) = mpsc::channel();
96 thread::spawn(move || worker(ctx, data_dir, unsafe_mode, command_rx, event_tx));
97 Self { commands, events }
98 }
99
100 pub fn send(&self, command: Command) {
101 let _ = self.commands.send(command);
102 }
103
104 pub fn try_recv(&self) -> Option<Event> {
105 self.events.try_recv().ok()
106 }
107}
108
109impl Drop for Network {
110 fn drop(&mut self) {
111 let _ = self.commands.send(Command::Stop);
112 }
113}
114
115fn worker(
116 ctx: egui::Context,
117 data_dir: PathBuf,
118 unsafe_mode: bool,
119 commands: Receiver<Command>,
120 events: Sender<Event>,
121) {
122 let db_path = paths::database_path(&data_dir);
123 let conn = match Connection::open(&db_path) {
124 Ok(conn) => {
125 let _ = conn.busy_timeout(Duration::from_secs(2));
126 database::init_db(&conn);
127 Some(conn)
128 }
129 Err(_) => None,
130 };
131
132 let mut stream: Option<TlsStream> = None;
133
134 loop {
135 match commands.recv_timeout(POLL) {
136 Ok(Command::Connect { addr }) => {
137 stream = None;
138 let Some(conn) = &conn else {
139 let _ = events.send(Event::Failed {
140 addr,
141 failure: Failure::Other,
142 message: format!("cannot open {}", db_path.display()),
143 });
144 ctx.request_repaint();
145 continue;
146 };
147 let mut trust = |prompt: &TrustPrompt| {
148 let (reply, decision) = mpsc::channel();
149 let _ = events.send(Event::Trust {
150 addr: prompt.addr.clone(),
151 fingerprint: prompt.fingerprint.clone(),
152 reply,
153 });
154 ctx.request_repaint();
155 decision.recv_timeout(TRUST_TIMEOUT).unwrap_or(false)
156 };
157 match transport::connect(&addr, conn, unsafe_mode, &mut trust) {
158 Ok(connected) => {
159 let fingerprint = database::get_fingerprint(conn, &addr);
160 let _ = events.send(Event::Connected(Connected {
161 addr: addr.clone(),
162 user: connected.user,
163 auth_error: connected.auth_error,
164 fingerprint,
165 server_version: connected.server_version,
166 latest: connected.latest,
167 url: connected.url,
168 elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0,
169 }));
170 stream = Some(connected.stream);
171 }
172 Err(error) => {
173 let _ = events.send(Event::Failed {
174 addr,
175 failure: classify(&error),
176 message: error.to_string(),
177 });
178 }
179 }
180 ctx.request_repaint();
181 }
182 Ok(Command::Send(line)) => {
183 if let Some(open) = &mut stream {
184 if open.write_line(&line).is_err() {
185 stream = None;
186 let _ = events.send(Event::Closed);
187 }
188 }
189 ctx.request_repaint();
190 }
191 Ok(Command::Disconnect) => {
192 stream = None;
193 ctx.request_repaint();
194 }
195 Ok(Command::Stop) => break,
196 Err(RecvTimeoutError::Timeout) => {}
197 Err(RecvTimeoutError::Disconnected) => break,
198 }
199
200 // Drain pushes and replies without blocking the command channel.
201 if let Some(open) = &mut stream {
202 loop {
203 match open.read_available_line() {
204 Ok(Some(line)) => {
205 if events.send(Event::Line(line)).is_err() {
206 return;
207 }
208 }
209 Ok(None) => break,
210 Err(_) => {
211 stream = None;
212 let _ = events.send(Event::Closed);
213 break;
214 }
215 }
216 }
217 ctx.request_repaint();
218 }
219 }
220}
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs
index f674251..c791693 100644
--- a/gui/src/roulette.rs
+++ b/gui/src/roulette.rs
@@ -553,3 +553,434 @@ pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8
553 theme::FELT_TEXT, 553 theme::FELT_TEXT,
554 ); 554 );
555} 555}
556
557// ---------------------------------------------------------------------------
558// The screen
559// ---------------------------------------------------------------------------
560
561/// Wheel animation and result history. Kept across frames; reset when the
562/// player leaves the table.
563pub struct Fx {
564 rotation: f32,
565 spin: Option<WheelSpin>,
566 awaiting_result: bool,
567 settled: Option<u8>,
568 last: Vec<u8>,
569}
570
571struct WheelSpin {
572 started: f64,
573 from: f32,
574 delta: f32,
575 target: u8,
576}
577
578impl Default for Fx {
579 fn default() -> Self {
580 Self {
581 rotation: 0.0,
582 spin: None,
583 awaiting_result: false,
584 settled: None,
585 last: Vec::new(),
586 }
587 }
588}
589
590impl Fx {
591 /// Called when the player asks for a spin, before the reply can arrive.
592 pub fn spin_requested(&mut self) {
593 self.awaiting_result = true;
594 }
595
596 pub fn ticking(&self) -> bool {
597 self.spin.is_some()
598 }
599
600 /// Advance the wheel; starts a spin when a new result lands.
601 pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &RouletteView) {
602 let now = ctx.input(|input| input.time);
603 if view.error.is_some() {
604 // The request was refused, so no result is coming.
605 self.awaiting_result = false;
606 }
607 if self.awaiting_result && view.phase == RoulettePhase::Result {
608 if let Some(target) = view.number {
609 self.awaiting_result = false;
610 self.start(now, target);
611 }
612 }
613
614 let Some(spin) = &self.spin else {
615 return;
616 };
617 let t = (((now - spin.started) / 1.7) as f32).clamp(0.0, 1.0);
618 let eased = 1.0 - (1.0 - t).powi(3);
619 self.rotation = spin.from + spin.delta * eased;
620 if t >= 1.0 {
621 let target = spin.target;
622 self.spin = None;
623 self.settled = Some(target);
624 self.last.insert(0, target);
625 self.last.truncate(12);
626 }
627 ctx.request_repaint();
628 }
629
630 fn start(&mut self, now: f64, target: u8) {
631 let segment = TAU / WHEEL.len() as f32;
632 let index = WHEEL
633 .iter()
634 .position(|number| *number == target)
635 .unwrap_or(0) as f32;
636 let desired = -index * segment;
637 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU;
638 self.spin = Some(WheelSpin {
639 started: now,
640 from: self.rotation,
641 delta,
642 target,
643 });
644 }
645
646 fn spinning(&self) -> bool {
647 self.spin.is_some()
648 }
649
650 /// The payout is held back until the wheel has stopped.
651 pub fn revealed(&self, view: &RouletteView) -> bool {
652 view.phase == RoulettePhase::Result && !self.spinning()
653 }
654}
655
656pub fn show(
657 ui: &mut Ui,
658 view: &RouletteView,
659 fx: &mut Fx,
660) -> Option<zagan_bet::client::view::Command> {
661 let mut command = None;
662 // While the wheel turns, keep the result hidden: no winning cell and no
663 // payout until it stops under the pointer.
664 let mut shown = view.clone();
665 if fx.spinning() && view.phase == RoulettePhase::Result {
666 shown.phase = RoulettePhase::Spinning;
667 }
668 eframe::egui::CentralPanel::default()
669 .frame(eframe::egui::Frame::new().fill(theme::BG))
670 .show(ui, |ui| {
671 let full = ui.max_rect();
672 let sidebar_rect = eframe::egui::Rect::from_min_max(
673 eframe::egui::pos2(full.right() - 336.0, full.top()),
674 full.right_bottom(),
675 );
676 let board_rect = eframe::egui::Rect::from_min_max(
677 full.min,
678 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
679 );
680 ui.painter().rect_filled(
681 sidebar_rect,
682 eframe::egui::CornerRadius::ZERO,
683 theme::SURFACE,
684 );
685
686 ui.scope_builder(
687 eframe::egui::UiBuilder::new()
688 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
689 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
690 |ui| {
691 if let Some(clicked) = board(ui, &shown, fx) {
692 command = Some(clicked);
693 }
694 },
695 );
696 ui.scope_builder(
697 eframe::egui::UiBuilder::new()
698 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
699 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
700 |ui| {
701 if let Some(clicked) = sidebar(ui, &shown, fx) {
702 command = Some(clicked);
703 }
704 },
705 );
706 });
707 command
708}
709
710fn sidebar(
711 ui: &mut Ui,
712 view: &RouletteView,
713 fx: &mut Fx,
714) -> Option<zagan_bet::client::view::Command> {
715 use zagan_bet::client::view::Command;
716
717 let mut command = None;
718 let (content, actions) = super::widgets::sidebar_layout(ui, 150.0);
719
720 ui.scope_builder(
721 eframe::egui::UiBuilder::new()
722 .max_rect(content)
723 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
724 |ui| {
725 eframe::egui::ScrollArea::vertical()
726 .auto_shrink([false, false])
727 .show(ui, |ui| {
728 ui.label(
729 eframe::egui::RichText::new("Roulette")
730 .font(theme::sans(22.0))
731 .color(theme::TEXT),
732 );
733 ui.label(
734 eframe::egui::RichText::new("European · single zero")
735 .font(theme::sans(12.0))
736 .color(theme::TEXT_DIM),
737 );
738
739 ui.add_space(theme::SPACE_4);
740 let wheel_size = ui.available_width().min(210.0);
741 ui.horizontal(|ui| {
742 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0);
743 ui.add_space(pad);
744 wheel(ui, wheel_size, view, fx.rotation);
745 });
746 ui.add_space(theme::SPACE_1);
747
748 let spinning = view.phase == RoulettePhase::Spinning || fx.spinning();
749 if spinning {
750 ui.label(
751 eframe::egui::RichText::new("SPINNING…")
752 .font(theme::mono(12.0))
753 .color(theme::GOLD),
754 );
755 } else if fx.revealed(view) {
756 ui.horizontal(|ui| {
757 let number = view.number.unwrap_or(0);
758 ui.label(theme::label("Result"));
759 ui.label(
760 eframe::egui::RichText::new(format!(
761 "{number} {}",
762 wheel_color(number).to_uppercase()
763 ))
764 .font(theme::mono(14.0))
765 .color(number_color(number)),
766 );
767 ui.with_layout(
768 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
769 |ui| {
770 let winnings = view.winnings.unwrap_or(0);
771 if winnings == 0 {
772 ui.label(
773 eframe::egui::RichText::new("NO WIN")
774 .font(theme::mono(13.0))
775 .color(theme::TEXT_FAINT),
776 );
777 } else {
778 let profit = winnings as i64 - view.bets.total() as i64;
779 ui.label(
780 eframe::egui::RichText::new(format!(
781 "({})",
782 theme::signed_money(profit)
783 ))
784 .font(theme::sans(11.0))
785 .color(
786 if profit >= 0 { theme::WIN } else { theme::LOSS },
787 ),
788 );
789 ui.label(
790 eframe::egui::RichText::new(theme::money(
791 winnings as i64,
792 ))
793 .font(theme::mono(15.0))
794 .color(theme::WIN),
795 );
796 ui.label(theme::label("Won"));
797 }
798 },
799 );
800 });
801 } else {
802 ui.horizontal(|ui| {
803 ui.label(
804 eframe::egui::RichText::new("Place your bets")
805 .font(theme::sans(12.0))
806 .color(theme::TEXT_DIM),
807 );
808 ui.with_layout(
809 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
810 |ui| {
811 ui.label(
812 eframe::egui::RichText::new(match view.bets.chips.len() {
813 1 => "1 chip".to_string(),
814 count => format!("{count} chips"),
815 })
816 .font(theme::mono(10.0))
817 .color(theme::TEXT_FAINT),
818 );
819 },
820 );
821 });
822 }
823
824 ui.add_space(theme::SPACE_3);
825 ui.separator();
826 ui.add_space(theme::SPACE_3);
827
828 ui.label(theme::label("Chip"));
829 ui.add_space(theme::SPACE_2);
830 if let Some(chip) =
831 super::widgets::chip_row(ui, [10, 25, 50, 100], view.chip, !spinning)
832 {
833 command = Some(Command::SetChip(chip));
834 }
835
836 ui.add_space(theme::SPACE_3);
837 ui.label(theme::label("Bets"));
838 ui.add_space(theme::SPACE_1);
839 if view.bets.chips.is_empty() {
840 ui.label(
841 eframe::egui::RichText::new("No chips on the felt")
842 .font(theme::sans(12.0))
843 .color(theme::TEXT_FAINT),
844 );
845 } else {
846 for chip in &view.bets.chips {
847 super::widgets::row(
848 ui,
849 &target_name(&chip.target),
850 &theme::money(chip.amount as i64),
851 theme::GOLD,
852 );
853 }
854 }
855
856 ui.add_space(theme::SPACE_3);
857 ui.separator();
858 ui.add_space(theme::SPACE_2);
859 super::widgets::row(
860 ui,
861 "Total",
862 &theme::money(view.bets.total() as i64),
863 theme::GOLD,
864 );
865 });
866 },
867 );
868
869 let spinning = view.phase == RoulettePhase::Spinning || fx.spinning();
870 ui.scope_builder(
871 eframe::egui::UiBuilder::new()
872 .max_rect(actions)
873 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
874 |ui| {
875 if super::widgets::ghost(ui, "Leave table") {
876 command = Some(Command::Leave);
877 }
878 ui.add_space(theme::SPACE_2);
879 if super::widgets::secondary(
880 ui,
881 "CLEAR",
882 !spinning && !view.bets.is_empty(),
883 ui.available_width(),
884 36.0,
885 ) {
886 command = Some(Command::ClearBets);
887 }
888 ui.add_space(theme::SPACE_2);
889 let clicked = super::widgets::primary(
890 ui,
891 "SPIN",
892 !spinning && !view.bets.is_empty(),
893 ui.available_width(),
894 44.0,
895 );
896 if clicked {
897 fx.spin_requested();
898 command = Some(Command::Spin);
899 }
900 },
901 );
902
903 command
904}
905
906fn board(ui: &mut Ui, view: &RouletteView, fx: &Fx) -> Option<zagan_bet::client::view::Command> {
907 use zagan_bet::client::view::Command;
908
909 ui.horizontal(|ui| {
910 ui.label(
911 eframe::egui::RichText::new("Table 01")
912 .font(theme::sans(15.0))
913 .color(theme::TEXT),
914 );
915 ui.label(
916 eframe::egui::RichText::new("European · single zero")
917 .font(theme::sans(12.0))
918 .color(theme::TEXT_DIM),
919 );
920 ui.with_layout(
921 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
922 |ui| {
923 ui.label(
924 eframe::egui::RichText::new("Limits 10 – 500 Kr")
925 .font(theme::mono(12.0))
926 .color(theme::TEXT_FAINT),
927 );
928 },
929 );
930 });
931 ui.add_space(theme::SPACE_4);
932
933 let strip_height = 104.0;
934 let available = ui.available_size();
935 let size = size_for(vec2(available.x, (available.y - strip_height).max(220.0)));
936 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
937 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
938 ui.add_space(top_pad);
939
940 let enabled = view.phase != RoulettePhase::Spinning && !fx.spinning();
941 let action = ui
942 .horizontal(|ui| {
943 ui.add_space(side_pad);
944 felt(ui, size, view)
945 })
946 .inner;
947 let command = match action {
948 Some(FeltAction::Place(target)) if enabled => Some(Command::PlaceBetAt(target)),
949 Some(FeltAction::Reduce(target)) if enabled => Some(Command::RemoveBetAt(target)),
950 _ => None,
951 };
952
953 ui.add_space(theme::SPACE_4);
954 ui.horizontal(|ui| {
955 ui.label(theme::label("Last"));
956 if fx.last.is_empty() {
957 ui.label(
958 eframe::egui::RichText::new("No spins yet")
959 .font(theme::sans(12.0))
960 .color(theme::TEXT_FAINT),
961 );
962 }
963 for number in &fx.last {
964 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
965 paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
966 ui.add_space(theme::SPACE_1);
967 }
968 });
969 ui.add_space(theme::SPACE_2);
970 ui.label(
971 eframe::egui::RichText::new(
972 "Click to place · right or middle click takes back · C clears · space spins",
973 )
974 .font(theme::sans(12.0))
975 .color(theme::TEXT_FAINT),
976 );
977 command
978}
979
980fn number_color(number: u8) -> Color32 {
981 match wheel_color(number) {
982 "red" => theme::ROULETTE_RED,
983 "green" => theme::WIN,
984 _ => theme::TEXT,
985 }
986}
diff --git a/gui/src/slots.rs b/gui/src/slots.rs
new file mode 100644
index 0000000..6574626
--- /dev/null
+++ b/gui/src/slots.rs
@@ -0,0 +1,447 @@
1//! Slots screen: a 5x3 board painted from `SlotsView`.
2
3use eframe::egui::{
4 Align2, Color32, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, vec2,
5};
6use zagan_bet::client::view::{Command, SlotsPhase, SlotsView};
7use zagan_bet::slots::{LINE_NAMES, PAYLINES, REELS, ROWS, SYMBOLS};
8
9use crate::{theme, widgets};
10
11/// Reel animation. The server answers at once, so the UI spins the reels for
12/// this long before revealing the result it already has.
13const SPIN_SECONDS: f64 = 0.9;
14
15#[derive(Default)]
16pub struct Fx {
17 started: Option<f64>,
18}
19
20impl Fx {
21 pub fn spin_requested(&mut self, ctx: &eframe::egui::Context) {
22 self.started = Some(ctx.input(|input| input.time));
23 }
24
25 fn spinning(&self, view: &SlotsView, now: f64) -> bool {
26 // A rejected spin (no balance, server error) never reaches the reels.
27 if view.error.is_some() {
28 return false;
29 }
30 match self.started {
31 Some(started) => now - started < SPIN_SECONDS || view.phase == SlotsPhase::Spinning,
32 None => view.phase == SlotsPhase::Spinning,
33 }
34 }
35
36 /// True once the reels have stopped on the server's answer.
37 pub fn revealed(&self, ctx: &eframe::egui::Context, view: &SlotsView) -> bool {
38 let now = ctx.input(|input| input.time);
39 !self.spinning(view, now)
40 }
41
42 pub fn tick(&mut self, ctx: &eframe::egui::Context, view: &SlotsView) {
43 if view.error.is_some() {
44 self.started = None;
45 return;
46 }
47 let now = ctx.input(|input| input.time);
48 if self.spinning(view, now) {
49 ctx.request_repaint();
50 } else {
51 self.started = None;
52 }
53 }
54}
55
56pub fn show(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> {
57 let mut command = None;
58 eframe::egui::CentralPanel::default()
59 .frame(eframe::egui::Frame::new().fill(theme::BG))
60 .show(ui, |ui| {
61 let full = ui.max_rect();
62 let sidebar_rect = eframe::egui::Rect::from_min_max(
63 eframe::egui::pos2(full.right() - 336.0, full.top()),
64 full.right_bottom(),
65 );
66 let board_rect = eframe::egui::Rect::from_min_max(
67 full.min,
68 eframe::egui::pos2(sidebar_rect.left(), full.bottom()),
69 );
70 ui.painter().rect_filled(
71 sidebar_rect,
72 eframe::egui::CornerRadius::ZERO,
73 theme::SURFACE,
74 );
75 ui.scope_builder(
76 eframe::egui::UiBuilder::new()
77 .max_rect(board_rect.shrink2(eframe::egui::vec2(24.0, 24.0)))
78 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
79 |ui| board(ui, view, fx),
80 );
81 ui.scope_builder(
82 eframe::egui::UiBuilder::new()
83 .max_rect(sidebar_rect.shrink2(eframe::egui::vec2(24.0, 20.0)))
84 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
85 |ui| {
86 command = sidebar(ui, view, fx);
87 },
88 );
89 });
90 command
91}
92
93fn sidebar(ui: &mut Ui, view: &SlotsView, fx: &mut Fx) -> Option<Command> {
94 let now = ui.input(|input| input.time);
95 let spinning = fx.spinning(view, now);
96 let mut command = None;
97 let (content, actions) = widgets::sidebar_layout(ui, 150.0);
98
99 ui.scope_builder(
100 eframe::egui::UiBuilder::new()
101 .max_rect(content)
102 .layout(eframe::egui::Layout::top_down(eframe::egui::Align::Min)),
103 |ui| {
104 eframe::egui::ScrollArea::vertical()
105 .auto_shrink([false, false])
106 .show(ui, |ui| {
107 ui.label(
108 eframe::egui::RichText::new("Slots")
109 .font(theme::sans(22.0))
110 .color(theme::TEXT),
111 );
112 ui.label(
113 eframe::egui::RichText::new("5 reels · 5 paylines")
114 .font(theme::sans(12.0))
115 .color(theme::TEXT_DIM),
116 );
117
118 ui.add_space(theme::SPACE_5);
119 ui.label(theme::label("Bet"));
120 ui.add_space(theme::SPACE_2);
121 if let Some(delta) = widgets::bet_control(ui, view.bet, !spinning) {
122 command = Some(Command::AdjustBet(delta));
123 }
124
125 ui.add_space(theme::SPACE_4);
126 status(ui, view, spinning);
127 if let Some(error) = &view.error {
128 ui.add_space(theme::SPACE_3);
129 widgets::error(ui, error);
130 }
131
132 ui.add_space(theme::SPACE_4);
133 ui.separator();
134 ui.add_space(theme::SPACE_3);
135 ui.label(theme::label("Paytable"));
136 ui.add_space(theme::SPACE_2);
137 ui.label(
138 eframe::egui::RichText::new("Pays bet × symbol × shape")
139 .font(theme::sans(11.0))
140 .color(theme::TEXT_FAINT),
141 );
142 ui.horizontal(|ui| {
143 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
144 for (symbol, multiplier) in zagan_bet::slots::SYMBOL_MULTIPLIERS {
145 ui.label(
146 eframe::egui::RichText::new(format!("{symbol}{multiplier}"))
147 .font(theme::mono(12.0))
148 .color(symbol_color(symbol)),
149 );
150 }
151 });
152 ui.label(
153 eframe::egui::RichText::new("3×1 4×3 5×14")
154 .font(theme::mono(12.0))
155 .color(theme::TEXT_DIM),
156 );
157 });
158 },
159 );
160
161 ui.scope_builder(
162 eframe::egui::UiBuilder::new()
163 .max_rect(actions)
164 .layout(eframe::egui::Layout::bottom_up(eframe::egui::Align::Min)),
165 |ui| {
166 if widgets::ghost(ui, "Leave machine") {
167 command = Some(Command::Leave);
168 }
169 ui.add_space(theme::SPACE_2);
170 if widgets::primary(ui, "SPIN", !spinning, ui.available_width(), 46.0) {
171 fx.spin_requested(ui.ctx());
172 command = Some(Command::Spin);
173 }
174 },
175 );
176
177 command
178}
179
180fn status(ui: &mut Ui, view: &SlotsView, spinning: bool) {
181 if spinning {
182 ui.label(
183 eframe::egui::RichText::new("SPINNING…")
184 .font(theme::mono(12.0))
185 .color(theme::GOLD),
186 );
187 return;
188 }
189
190 match view.phase {
191 SlotsPhase::Result => {
192 let winnings = view.winnings.unwrap_or(0);
193 if winnings == 0 {
194 ui.label(
195 eframe::egui::RichText::new("NO WIN")
196 .font(theme::mono(13.0))
197 .color(theme::TEXT_FAINT),
198 );
199 } else {
200 let label = match view.win_type.as_str() {
201 "mega" => "MEGA WIN",
202 "big" => "BIG WIN",
203 _ => "WON",
204 };
205 ui.horizontal(|ui| {
206 ui.label(theme::label(label));
207 ui.label(
208 eframe::egui::RichText::new(theme::money(winnings as i64))
209 .font(theme::mono(18.0))
210 .color(theme::WIN),
211 );
212 });
213 if !view.win_lines.is_empty() {
214 let names: Vec<String> = view
215 .win_lines
216 .iter()
217 .map(|line| {
218 line.checked_sub(1)
219 .and_then(|index| LINE_NAMES.get(index).copied())
220 .map(|name| format!("{line} ({name})"))
221 .unwrap_or_else(|| line.to_string())
222 })
223 .collect();
224 ui.label(
225 eframe::egui::RichText::new(names.join(" · "))
226 .font(theme::sans(11.0))
227 .color(theme::TEXT_DIM),
228 );
229 }
230 }
231 }
232 _ => {
233 ui.label(
234 eframe::egui::RichText::new("Press SPIN to play")
235 .font(theme::sans(12.0))
236 .color(theme::TEXT_DIM),
237 );
238 }
239 }
240}
241
242fn board(ui: &mut Ui, view: &SlotsView, fx: &Fx) {
243 ui.horizontal(|ui| {
244 ui.label(
245 eframe::egui::RichText::new("Machine 01")
246 .font(theme::sans(15.0))
247 .color(theme::TEXT),
248 );
249 ui.label(
250 eframe::egui::RichText::new("5 reels · 5 paylines")
251 .font(theme::sans(12.0))
252 .color(theme::TEXT_DIM),
253 );
254 ui.with_layout(
255 eframe::egui::Layout::right_to_left(eframe::egui::Align::Center),
256 |ui| {
257 ui.label(
258 eframe::egui::RichText::new("Bet 10 – 500 Kr")
259 .font(theme::mono(12.0))
260 .color(theme::TEXT_FAINT),
261 );
262 },
263 );
264 });
265 ui.add_space(theme::SPACE_5);
266
267 let now = ui.input(|input| input.time);
268 let spinning = fx.spinning(view, now);
269 let available = ui.available_size();
270 let gap = theme::SPACE_2;
271 let tile_width = ((available.x - gap * (REELS as f32 - 1.0)) / REELS as f32)
272 .min((available.y - 150.0) / 3.0)
273 .min(190.0);
274 let tile = vec2(tile_width, tile_width * 0.92);
275 let board_width = tile.x * REELS as f32 + gap * (REELS as f32 - 1.0);
276 let board_height = tile.y * 3.0 + gap * 2.0;
277 let left = ((available.x - board_width) * 0.5).max(0.0);
278 let top = ((available.y - board_height) * 0.35).max(0.0);
279
280 ui.add_space(top);
281 let origin = ui
282 .horizontal(|ui| {
283 ui.add_space(left);
284 ui.allocate_exact_size(vec2(board_width, board_height), Sense::hover())
285 .0
286 .min
287 })
288 .inner;
289
290 let painter = ui.painter();
291 let cycling = (now * 18.0) as usize;
292 for col in 0..REELS {
293 for row in 0..ROWS {
294 let rect = Rect::from_min_size(
295 origin + vec2(col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)),
296 tile,
297 );
298 let index = col * ROWS + row;
299 let stocked =
300 view.reels.len() == REELS * ROWS || (view.reels.len() == REELS && row == 0);
301 let symbol = if spinning {
302 SYMBOLS[(cycling + index * 3) % SYMBOLS.len()]
303 } else if stocked {
304 if view.reels.len() == REELS * ROWS {
305 view.reels[index]
306 } else {
307 view.reels[col]
308 }
309 } else {
310 SYMBOLS[(index * 5 + col) % SYMBOLS.len()]
311 };
312 let winning = !spinning && view.win_cells.contains(&index);
313 paint_tile(painter, rect, symbol, spinning, winning, !stocked);
314 }
315 }
316
317 if !spinning && view.phase == SlotsPhase::Result {
318 paint_win_lines(painter, origin, tile, gap, view);
319 }
320
321 ui.add_space(theme::SPACE_5);
322 ui.horizontal(|ui| {
323 ui.label(theme::label("Result"));
324 if spinning {
325 ui.label(
326 eframe::egui::RichText::new("Spinning…")
327 .font(theme::mono(12.0))
328 .color(theme::GOLD),
329 );
330 } else if view.phase == SlotsPhase::Result {
331 let winnings = view.winnings.unwrap_or(0);
332 if winnings == 0 {
333 ui.label(
334 eframe::egui::RichText::new("No win")
335 .font(theme::mono(12.0))
336 .color(theme::TEXT_DIM),
337 );
338 } else {
339 let label = match view.win_type.as_str() {
340 "mega" => "MEGA WIN",
341 "big" => "BIG WIN",
342 _ => "WIN",
343 };
344 ui.label(
345 eframe::egui::RichText::new(format!(
346 "{label} +{}",
347 theme::money(winnings as i64)
348 ))
349 .font(theme::mono(12.0))
350 .color(theme::WIN),
351 );
352 }
353 } else {
354 ui.label(
355 eframe::egui::RichText::new("Ready")
356 .font(theme::mono(12.0))
357 .color(theme::TEXT_DIM),
358 );
359 }
360 });
361}
362
363fn paint_tile(
364 painter: &Painter,
365 rect: Rect,
366 symbol: char,
367 spinning: bool,
368 winning: bool,
369 placeholder: bool,
370) {
371 let fill = if winning {
372 theme::GOLD.gamma_multiply(0.14)
373 } else {
374 theme::SURFACE
375 };
376 painter.rect_filled(rect, theme::RADIUS_MD, fill);
377 let stroke = if winning {
378 Stroke::new(2.0, theme::GOLD)
379 } else {
380 Stroke::new(1.0, theme::LINE)
381 };
382 painter.rect_stroke(rect, theme::RADIUS_MD, stroke, StrokeKind::Inside);
383
384 let color = if spinning || placeholder {
385 symbol_color(symbol).gamma_multiply(0.35)
386 } else {
387 symbol_color(symbol)
388 };
389 painter.text(
390 rect.center(),
391 Align2::CENTER_CENTER,
392 symbol,
393 theme::mono(rect.width() * 0.42),
394 color,
395 );
396}
397
398fn paint_win_lines(painter: &Painter, origin: Pos2, tile: Vec2, gap: f32, view: &SlotsView) {
399 for line in &view.win_lines {
400 let columns = winning_columns(view, *line);
401 if columns.len() < 2 {
402 continue;
403 }
404 let points: Vec<Pos2> = columns
405 .iter()
406 .map(|col| {
407 let row = PAYLINES[*line - 1][*col];
408 Rect::from_min_size(
409 origin + vec2(*col as f32 * (tile.x + gap), row as f32 * (tile.y + gap)),
410 tile,
411 )
412 .center()
413 })
414 .collect();
415 painter.add(eframe::egui::Shape::line(
416 points,
417 Stroke::new(2.0, theme::GOLD.gamma_multiply(0.8)),
418 ));
419 }
420}
421
422/// The reels that actually matched on a payline.
423///
424/// A win is a prefix of matching symbols from reel 1, so the line must stop
425/// at the last winning cell instead of crossing all five reels.
426pub fn winning_columns(view: &SlotsView, line: usize) -> Vec<usize> {
427 let Some(rows) = line.checked_sub(1).and_then(|index| PAYLINES.get(index)) else {
428 return Vec::new();
429 };
430 rows.iter()
431 .enumerate()
432 .filter(|(col, row)| view.win_cells.contains(&(col * ROWS + *row)))
433 .map(|(col, _)| col)
434 .collect()
435}
436
437pub fn symbol_color(symbol: char) -> Color32 {
438 match symbol {
439 'C' => Color32::from_rgb(0xe0, 0x5a, 0x5a),
440 'L' => Color32::from_rgb(0xe0, 0xb6, 0x4a),
441 'O' => Color32::from_rgb(0xe0, 0x8a, 0x3c),
442 'G' => Color32::from_rgb(0xb5, 0x7e, 0xdc),
443 'D' => Color32::from_rgb(0x4f, 0xb8, 0xc9),
444 'S' => Color32::from_rgb(0x4e, 0xc2, 0x7b),
445 _ => theme::TEXT,
446 }
447}
diff --git a/gui/src/widgets.rs b/gui/src/widgets.rs
new file mode 100644
index 0000000..f7d1512
--- /dev/null
+++ b/gui/src/widgets.rs
@@ -0,0 +1,255 @@
1//! Small shared controls. Every button and panel comes from here so the
2//! screens cannot drift apart.
3
4use eframe::egui::{
5 Align, Button, Color32, Frame, Layout, Margin, RichText, Sense, Stroke, Ui, Vec2, vec2,
6};
7
8use crate::theme;
9
10pub fn panel_frame() -> Frame {
11 Frame::new()
12 .fill(theme::SURFACE)
13 .corner_radius(theme::RADIUS_LG)
14 .inner_margin(Margin::same(theme::SPACE_4 as i8))
15}
16
17pub fn inset_frame() -> Frame {
18 Frame::new()
19 .fill(theme::SURFACE_RAISED)
20 .corner_radius(theme::RADIUS_MD)
21 .inner_margin(Margin::same(theme::SPACE_3 as i8))
22}
23
24/// The one green action on a screen.
25pub fn primary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool {
26 let fill = if enabled {
27 theme::WIN
28 } else {
29 theme::WIN.gamma_multiply(0.35)
30 };
31 let response = ui.add_enabled(
32 enabled,
33 Button::new(RichText::new(text).font(theme::mono(13.0)).color(theme::BG))
34 .fill(fill)
35 .corner_radius(theme::RADIUS_MD)
36 .min_size(vec2(width, height)),
37 );
38 if response.hovered() {
39 ui.painter().rect_filled(
40 response.rect,
41 theme::RADIUS_MD,
42 Color32::WHITE.gamma_multiply(0.10),
43 );
44 ui.ctx()
45 .set_cursor_icon(eframe::egui::CursorIcon::PointingHand);
46 }
47 response.clicked()
48}
49
50/// A bordered action that is not the main verb.
51pub fn secondary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool {
52 let stroke = if enabled {
53 theme::LINE
54 } else {
55 theme::LINE.gamma_multiply(0.5)
56 };
57 let response = ui.add_enabled(
58 enabled,
59 Button::new(
60 RichText::new(text)
61 .font(theme::mono(12.0))
62 .color(theme::TEXT_DIM),
63 )
64 .fill(theme::SURFACE_RAISED)
65 .stroke(Stroke::new(1.0, stroke))
66 .corner_radius(theme::RADIUS_MD)
67 .min_size(vec2(width, height)),
68 );
69 if response.hovered() {
70 ui.painter().rect_filled(
71 response.rect,
72 theme::RADIUS_MD,
73 Color32::WHITE.gamma_multiply(0.06),
74 );
75 ui.ctx()
76 .set_cursor_icon(eframe::egui::CursorIcon::PointingHand);
77 }
78 response.clicked()
79}
80
81/// A quiet text action, for "back" and "forget".
82pub fn ghost(ui: &mut Ui, text: &str) -> bool {
83 let response = ui.add(
84 Button::new(
85 RichText::new(text)
86 .font(theme::sans(12.0))
87 .color(theme::TEXT_DIM),
88 )
89 .frame(false),
90 );
91 if response.hovered() {
92 ui.painter().rect_filled(
93 response.rect.expand(2.0),
94 theme::RADIUS_SM,
95 Color32::WHITE.gamma_multiply(0.05),
96 );
97 ui.ctx()
98 .set_cursor_icon(eframe::egui::CursorIcon::PointingHand);
99 }
100 response.clicked()
101}
102
103/// Split a game sidebar into a flexible content area and a pinned action bar,
104/// so the primary action never moves as the bet slip grows.
105pub fn sidebar_layout(ui: &Ui, actions_height: f32) -> (eframe::egui::Rect, eframe::egui::Rect) {
106 let full = ui.max_rect();
107 let actions_top = (full.bottom() - actions_height).max(full.top());
108 let actions = eframe::egui::Rect::from_min_max(
109 eframe::egui::pos2(full.left(), actions_top),
110 full.right_bottom(),
111 );
112 let content = eframe::egui::Rect::from_min_max(
113 full.min,
114 eframe::egui::pos2(full.right(), (actions_top - theme::SPACE_3).max(full.top())),
115 );
116 (content, actions)
117}
118
119pub fn square_button(ui: &mut Ui, text: &str, enabled: bool) -> bool {
120 ui.add_enabled(
121 enabled,
122 Button::new(
123 RichText::new(text)
124 .font(theme::mono(16.0))
125 .color(theme::TEXT),
126 )
127 .fill(theme::SURFACE_RAISED)
128 .stroke(Stroke::new(1.0, theme::LINE))
129 .corner_radius(theme::RADIUS_MD)
130 .min_size(Vec2::splat(34.0)),
131 )
132 .clicked()
133}
134
135/// `- [ 50 Kr ] +`; returns the change in the bet.
136pub fn bet_control(ui: &mut Ui, bet: u64, enabled: bool) -> Option<i64> {
137 let mut delta = None;
138 ui.horizontal(|ui| {
139 if square_button(ui, "−", enabled) {
140 delta = Some(-10);
141 }
142 Frame::new()
143 .fill(theme::BG)
144 .corner_radius(theme::RADIUS_SM)
145 .inner_margin(Margin::symmetric(
146 theme::SPACE_3 as i8,
147 theme::SPACE_2 as i8,
148 ))
149 .show(ui, |ui| {
150 ui.label(
151 RichText::new(theme::money(bet as i64))
152 .font(theme::mono(15.0))
153 .color(theme::TEXT),
154 );
155 });
156 if square_button(ui, "+", enabled) {
157 delta = Some(10);
158 }
159 });
160 delta
161}
162
163/// Label on the left, value on the right, for slips and summaries.
164pub fn row(ui: &mut Ui, label: &str, value: &str, color: Color32) {
165 ui.horizontal(|ui| {
166 ui.label(
167 RichText::new(label)
168 .font(theme::sans(13.0))
169 .color(theme::TEXT_DIM),
170 );
171 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
172 ui.label(RichText::new(value).font(theme::mono(13.0)).color(color));
173 });
174 });
175}
176
177pub fn note(ui: &mut Ui, text: &str) {
178 ui.label(
179 RichText::new(text)
180 .font(theme::sans(12.0))
181 .color(theme::TEXT_DIM),
182 );
183}
184
185pub fn error(ui: &mut Ui, text: &str) {
186 Frame::new()
187 .fill(theme::LOSS.gamma_multiply(0.12))
188 .corner_radius(theme::RADIUS_SM)
189 .inner_margin(Margin::symmetric(
190 theme::SPACE_3 as i8,
191 theme::SPACE_2 as i8,
192 ))
193 .show(ui, |ui| {
194 ui.label(
195 RichText::new(text)
196 .font(theme::sans(12.0))
197 .color(theme::LOSS),
198 );
199 });
200}
201
202/// A monospaced label that can be selected (fingerprints, addresses, URLs).
203pub fn mono_text(ui: &mut Ui, text: &str, color: Color32) {
204 ui.add(
205 eframe::egui::Label::new(
206 RichText::new(text)
207 .font(theme::mono(theme::FONT_SMALL))
208 .color(color),
209 )
210 .selectable(true),
211 );
212}
213
214pub fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> bool {
215 let sense = if enabled {
216 Sense::click()
217 } else {
218 Sense::hover()
219 };
220 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense);
221 let center = rect.center();
222 let radius = 20.0;
223 let painter = ui.painter();
224 if selected || response.hovered() {
225 painter.circle_stroke(
226 center,
227 radius + 3.0,
228 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
229 );
230 }
231 if enabled {
232 super::roulette::paint_chip(painter, center, radius, amount);
233 } else {
234 super::roulette::paint_chip_preview(painter, center, radius, amount);
235 }
236 response.clicked()
237}
238
239pub fn chip_row<const N: usize>(
240 ui: &mut Ui,
241 amounts: [u64; N],
242 selected: u64,
243 enabled: bool,
244) -> Option<u64> {
245 let mut picked = None;
246 ui.horizontal(|ui| {
247 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
248 for amount in amounts {
249 if chip_button(ui, amount, selected == amount, enabled) {
250 picked = Some(amount);
251 }
252 }
253 });
254 picked
255}
diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs
index ab5a62e..5678665 100644
--- a/gui/tests/layout.rs
+++ b/gui/tests/layout.rs
@@ -5,8 +5,10 @@
5use std::f32::consts::TAU; 5use std::f32::consts::TAU;
6 6
7use eframe::egui::{Pos2, Rect, pos2, vec2}; 7use eframe::egui::{Pos2, Rect, pos2, vec2};
8use zagan_bet::client::view::{SlotsPhase, SlotsView};
8use zagan_bet::roulette::{BetTarget, Outside}; 9use zagan_bet::roulette::{BetTarget, Outside};
9use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name}; 10use zagan_bet_gui::roulette::{Felt, number_under, size_for, target_name};
11use zagan_bet_gui::slots::winning_columns;
10use zagan_bet_gui::theme::{group_digits, money}; 12use zagan_bet_gui::theme::{group_digits, money};
11 13
12#[test] 14#[test]
@@ -154,3 +156,36 @@ fn targets_read_as_bet_slips() {
154 "Black" 156 "Black"
155 ); 157 );
156} 158}
159
160fn slots_view(win_cells: Vec<usize>, win_lines: Vec<usize>) -> SlotsView {
161 SlotsView {
162 bet: 10,
163 balance: Some(1000),
164 reels: "OOOOODDDDDOOOOOD".chars().collect(),
165 win_cells,
166 win_lines,
167 win_type: "win".to_string(),
168 winnings: Some(30),
169 phase: SlotsPhase::Result,
170 error: None,
171 }
172}
173
174#[test]
175fn a_payline_stops_at_the_last_matching_reel() {
176 // Middle line (line 2), four Os from reel 0: cells 1, 4, 7, 10.
177 let view = slots_view(vec![1, 4, 7, 10], vec![2]);
178 assert_eq!(winning_columns(&view, 2), vec![0, 1, 2, 3]);
179
180 // Three of a kind stops after reel 2.
181 let view = slots_view(vec![1, 4, 7], vec![2]);
182 assert_eq!(winning_columns(&view, 2), vec![0, 1, 2]);
183
184 // Cells on another line do not extend this one.
185 let view = slots_view(vec![0, 1, 4, 7], vec![2]);
186 assert_eq!(winning_columns(&view, 2), vec![0, 1, 2]);
187
188 // A line number that does not exist draws nothing.
189 let view = slots_view(vec![1, 4, 7], vec![9]);
190 assert!(winning_columns(&view, 9).is_empty());
191}
diff --git a/src/client/core.rs b/src/client/core.rs
index 21c1cb2..6d8294c 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -62,6 +62,8 @@ pub struct ClientCore {
62 logged_in: Option<String>, 62 logged_in: Option<String>,
63 pending: Option<Pending>, 63 pending: Option<Pending>,
64 balance: Option<i64>, 64 balance: Option<i64>,
65 /// Account number from the last `INFO`, shown by both frontends.
66 account: Option<String>,
65 host: Option<String>, 67 host: Option<String>,
66 fingerprint: Option<String>, 68 fingerprint: Option<String>,
67 /// Local anchor for the crash multiplier, set when a round starts. 69 /// Local anchor for the crash multiplier, set when a round starts.
@@ -82,6 +84,7 @@ impl ClientCore {
82 logged_in: None, 84 logged_in: None,
83 pending: None, 85 pending: None,
84 balance: None, 86 balance: None,
87 account: None,
85 host: None, 88 host: None,
86 fingerprint: None, 89 fingerprint: None,
87 crash_started: None, 90 crash_started: None,
@@ -275,15 +278,18 @@ impl ClientCore {
275 } 278 }
276 Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), 279 Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())),
277 Some(Pending::Info) => { 280 Some(Pending::Info) => {
281 // The same reply feeds the balance and the account number.
282 let balance = info_balance(reply);
283 if let Some(account) = info_account(reply) {
284 self.account = Some(account);
285 }
286 if let Some(balance) = balance {
287 self.balance = Some(balance);
288 self.game.set_balance(balance);
289 }
278 if self.game.is_in_game() { 290 if self.game.is_in_game() {
279 // Balance fetch while entering a game. A failure is not 291 // A failure is not fatal: the board keeps showing `?`.
280 // fatal: the board keeps showing `?`. 292 self.board_effects()
281 if let Some(balance) = info_balance(reply) {
282 self.balance = Some(balance);
283 self.game.set_balance(balance);
284 return self.board_effects();
285 }
286 Vec::new()
287 } else { 293 } else {
288 vec![Effect::Print(info_lines(reply))] 294 vec![Effect::Print(info_lines(reply))]
289 } 295 }
@@ -405,6 +411,7 @@ impl ClientCore {
405 None => Vec::new(), 411 None => Vec::new(),
406 }, 412 },
407 Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), 413 Command::PlaceBetAt(target) => self.place_roulette_chip_on(target),
414 Command::RemoveBetAt(target) => self.remove_roulette_chip_on(target),
408 Command::ClearBets => { 415 Command::ClearBets => {
409 self.game.set_roulette_bets(&Bets::default()); 416 self.game.set_roulette_bets(&Bets::default());
410 self.game.set_roulette_status("place"); 417 self.game.set_roulette_status("place");
@@ -489,6 +496,12 @@ impl ClientCore {
489 self.place_roulette_bets(bets) 496 self.place_roulette_bets(bets)
490 } 497 }
491 498
499 fn remove_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> {
500 let mut bets = self.game.roulette_bets();
501 bets.reduce(&target, self.game.roulette_chip());
502 self.place_roulette_bets(bets)
503 }
504
492 fn place_roulette_bets(&mut self, bets: Bets) -> Vec<Effect> { 505 fn place_roulette_bets(&mut self, bets: Bets) -> Vec<Effect> {
493 if let Some(balance) = self.balance { 506 if let Some(balance) = self.balance {
494 if bets.total() > balance.max(0) as u64 { 507 if bets.total() > balance.max(0) as u64 {
@@ -782,6 +795,7 @@ impl ClientCore {
782 Screen::Lobby => View::Lobby(LobbyView { 795 Screen::Lobby => View::Lobby(LobbyView {
783 connected: self.connected, 796 connected: self.connected,
784 user: self.logged_in.clone(), 797 user: self.logged_in.clone(),
798 account: self.account.clone(),
785 balance: self.balance, 799 balance: self.balance,
786 games: self.games(), 800 games: self.games(),
787 login_error: self.login_error.clone(), 801 login_error: self.login_error.clone(),
@@ -824,6 +838,15 @@ fn print_line(message: impl Into<String>) -> Vec<Effect> {
824 vec![Effect::Print(vec![message.into()])] 838 vec![Effect::Print(vec![message.into()])]
825} 839}
826 840
841fn info_account(reply: &str) -> Option<String> {
842 let parts: Vec<&str> = reply.split_whitespace().collect();
843 if parts.first() == Some(&"INFO") && parts.len() == 4 {
844 Some(parts[2].to_string()).filter(|account| account != "-")
845 } else {
846 None
847 }
848}
849
827fn info_balance(reply: &str) -> Option<i64> { 850fn info_balance(reply: &str) -> Option<i64> {
828 let parts: Vec<&str> = reply.split_whitespace().collect(); 851 let parts: Vec<&str> = reply.split_whitespace().collect();
829 if parts.first() == Some(&"INFO") && parts.len() == 4 { 852 if parts.first() == Some(&"INFO") && parts.len() == 4 {
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index bb2a8b5..24ceeaa 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -353,7 +353,7 @@ impl GameManager {
353 .insert("balance".to_string(), state.balance.to_string()); 353 .insert("balance".to_string(), state.balance.to_string());
354 session 354 session
355 .data 355 .data
356 .insert("multiplier".to_string(), format!("{:.2}", state.multiplier)); 356 .insert("multiplier".to_string(), state.multiplier.to_string());
357 session 357 session
358 .data 358 .data
359 .insert("potential".to_string(), state.potential.to_string()); 359 .insert("potential".to_string(), state.potential.to_string());
@@ -373,9 +373,11 @@ impl GameManager {
373 return; 373 return;
374 }; 374 };
375 let bet = session.bet_amount; 375 let bet = session.bet_amount;
376 // Keep the full value: the terminal rounds for display, but the GUI
377 // charts it and must not draw a staircase.
376 session 378 session
377 .data 379 .data
378 .insert("multiplier".to_string(), format!("{multiplier:.2}")); 380 .insert("multiplier".to_string(), multiplier.to_string());
379 session 381 session
380 .data 382 .data
381 .insert("potential".to_string(), payout(bet, multiplier).to_string()); 383 .insert("potential".to_string(), payout(bet, multiplier).to_string());
diff --git a/src/client/view.rs b/src/client/view.rs
index d2ccadb..4615720 100644
--- a/src/client/view.rs
+++ b/src/client/view.rs
@@ -246,6 +246,7 @@ impl CrashView {
246pub struct LobbyView { 246pub struct LobbyView {
247 pub connected: bool, 247 pub connected: bool,
248 pub user: Option<String>, 248 pub user: Option<String>,
249 pub account: Option<String>,
249 pub balance: Option<i64>, 250 pub balance: Option<i64>,
250 pub games: Vec<(String, String)>, 251 pub games: Vec<(String, String)>,
251 /// Why the last login or registration attempt failed. 252 /// Why the last login or registration attempt failed.
@@ -297,6 +298,7 @@ pub enum Command {
297 MoveCursor(Direction), 298 MoveCursor(Direction),
298 PlaceBet, 299 PlaceBet,
299 PlaceBetAt(BetTarget), 300 PlaceBetAt(BetTarget),
301 RemoveBetAt(BetTarget),
300 ClearBets, 302 ClearBets,
301 SetChip(u64), 303 SetChip(u64),
302 AdjustChip(i64), 304 AdjustChip(i64),
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 49abca2..2f43d47 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -17,7 +17,7 @@ use zagan_bet::client::games::roulette::{Cursor, Direction};
17use zagan_bet::client::ui::command_names; 17use zagan_bet::client::ui::command_names;
18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; 18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View};
19use zagan_bet::roulette::BetTarget; 19use zagan_bet::roulette::BetTarget;
20use zagan_bet::crash::CrashState; 20use zagan_bet::crash::{CrashState, multiplier_at};
21 21
22const WIN3: &str = "CCCCLLCLODGSDGS"; 22const WIN3: &str = "CCCCLLCLODGSDGS";
23const LOSS: &str = "CLOOGDSLCDCOGLS"; 23const LOSS: &str = "CLOOGDSLCDCOGLS";
@@ -538,6 +538,45 @@ fn info_shows_the_host_and_the_server_fields() {
538 assert!(h.screen.lines.iter().any(|line| line == "User: alice")); 538 assert!(h.screen.lines.iter().any(|line| line == "User: alice"));
539 assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); 539 assert!(h.screen.lines.iter().any(|line| line == "Account: 42"));
540 assert!(h.screen.lines.iter().any(|line| line == "Balance: 1234 Kr")); 540 assert!(h.screen.lines.iter().any(|line| line == "Balance: 1234 Kr"));
541
542 let Some(View::Lobby(lobby)) = h.core.view() else {
543 panic!("the lobby view is always available");
544 };
545 assert_eq!(lobby.account.as_deref(), Some("42"));
546 assert_eq!(lobby.balance, Some(1234));
547}
548
549#[test]
550fn a_roulette_chip_can_be_taken_back_through_a_command() {
551 let mut h = Harness::new();
552 h.enter_roulette();
553 h.core.command(Command::SetChip(25));
554
555 h.core
556 .command(Command::PlaceBetAt(BetTarget::Straight { number: 17 }));
557 h.core
558 .command(Command::PlaceBetAt(BetTarget::Straight { number: 17 }));
559 let Some(View::Roulette(view)) = h.core.view() else {
560 panic!("roulette is open");
561 };
562 assert_eq!(view.bets.total(), 50);
563
564 h.core
565 .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 }));
566 let Some(View::Roulette(view)) = h.core.view() else {
567 panic!("roulette is open");
568 };
569 assert_eq!(view.bets.total(), 25);
570 assert_eq!(view.phase, RoulettePhase::Placing);
571
572 h.core
573 .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 }));
574 h.core
575 .command(Command::RemoveBetAt(BetTarget::Straight { number: 17 }));
576 let Some(View::Roulette(view)) = h.core.view() else {
577 panic!("roulette is open");
578 };
579 assert_eq!(view.bets.total(), 0);
541} 580}
542 581
543#[test] 582#[test]
@@ -1581,3 +1620,29 @@ fn login_requires_a_connection() {
1581 1620
1582 assert!(!h.sent.iter().any(|line| line.starts_with("AUTH"))); 1621 assert!(!h.sent.iter().any(|line| line.starts_with("AUTH")));
1583} 1622}
1623
1624#[test]
1625fn the_crash_multiplier_is_not_rounded_for_the_chart() {
1626 let mut h = Harness::new();
1627 h.enter_crash();
1628 h.key(Key::Char(' '));
1629 h.reply(&crash_line(
1630 "CRASH_START",
1631 &crash_state("running", 1.0, None, 0, 990),
1632 ));
1633
1634 h.tick(Duration::from_millis(1234));
1635 let expected = multiplier_at(Duration::from_millis(1234));
1636 let Some(View::Crash(view)) = h.core.view() else {
1637 panic!("expected a crash view");
1638 };
1639 assert!(
1640 (view.multiplier - expected).abs() < 1e-9,
1641 "the GUI chart needs the full precision value"
1642 );
1643 let rounded = (expected * 100.0).round() / 100.0;
1644 assert!(
1645 (view.multiplier - rounded).abs() > 1e-6,
1646 "a two-decimal store would flatten the curve into steps"
1647 );
1648}