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