summaryrefslogtreecommitdiffstats
path: root/gui/src/app.rs
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 /gui/src/app.rs
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.
Diffstat (limited to '')
-rw-r--r--gui/src/app.rs1963
1 files changed, 1464 insertions, 499 deletions
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}