From bf4cf78ab26896faf457273e37d30fc96381c4a4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 21:09:56 +0100 Subject: 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. --- gui/src/app.rs | 1963 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 1464 insertions(+), 499 deletions(-) (limited to 'gui/src/app.rs') 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 @@ -//! The desktop shell: header, roulette table, and betting sidebar. -//! -//! Phase 3 is a style spike. The table is painted from a demo `RouletteView` -//! built with the core's own types, but no command is sent yet: Phase 4 wires -//! the transport and replaces the demo state with `ClientCore`. +//! The desktop shell: screens, session state, and the bridge to the core. -use std::f32::consts::TAU; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::time::{Duration, Instant}; use eframe::egui::{ - self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, - RichText, Sense, Stroke, Ui, Vec2, vec2, + self, Align, Align2, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, + RichText, Sense, Stroke, TextEdit, Ui, Vec2, vec2, }; -use zagan_bet::client::games::roulette::Cursor; -use zagan_bet::client::view::{RoulettePhase, RouletteView}; -use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; -use zagan_bet::version::VERSION; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use zagan_bet::client::core::{ClientCore, Effect}; +use zagan_bet::client::database; +use zagan_bet::client::paths; +use zagan_bet::client::view::{Command, LobbyView, View}; +use zagan_bet::history::PlayRecord; +use zagan_bet::version::{VERSION, is_newer}; -use crate::roulette::FeltAction; -use crate::{roulette, theme}; +use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; +use crate::{blackjack, crash, roulette, slots, theme, widgets}; -const HEADER_HEIGHT: f32 = 64.0; -const SIDEBAR_WIDTH: f32 = 336.0; -const SPIN_SECONDS: f64 = 2.4; -/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. -const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; -const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; +/// A request that has no reply after this long kills the connection, matching +/// the terminal adapter's socket timeout. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const NOTICE_SECONDS: u64 = 6; +const DEFAULT_BET: u64 = 10; + +/// Where the app keeps its files and whether TLS is bypassed. +pub struct Startup { + pub data_dir: PathBuf, + pub unsafe_mode: bool, +} pub struct ZaganApp { - demo: Demo, + core: ClientCore, + net: Network, + db: Option, + db_error: Option, + unsafe_mode: bool, + + session: Option, + connect: ConnectState, + login: LoginState, + trust: Option, + key_changed: Option, + leave_confirm: bool, + notices: VecDeque, + awaiting: Option, + need_info: bool, + pending_login: Option<(String, String)>, + /// Displayed balance while a result animation runs: stake deducted, no + /// winnings yet. The view already knows the outcome; the player must not. + balance_hold: Option, + balance: Option, + account: Option, + fx: Fx, +} + +struct Session { + host: String, + fingerprint: Option, + ping_ms: f32, + server_version: String, + latest: String, + url: String, + user: Option, +} + +#[derive(Default)] +struct ConnectState { + addr: String, + error: Option, + version_issue: bool, + pending: bool, +} + +#[derive(Default)] +struct LoginState { + user: String, + password: String, + error: Option, + remember: bool, +} + +impl LoginState { + fn new() -> Self { + Self { + remember: true, + ..Self::default() + } + } +} + +struct TrustState { + addr: String, + fingerprint: String, + reply: Sender, +} + +struct KeyChanged { + addr: String, + expected: String, + observed: String, +} + +struct Notice { + text: String, + at: Instant, + tone: Tone, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tone { + Info, + Good, + Bad, +} + +#[derive(Default)] +struct Fx { + game: Option, + roulette: roulette::Fx, + slots: slots::Fx, + crash: crash::Fx, +} + +impl Fx { + /// Reset the animation state when the player enters or leaves a game. + fn sync(&mut self, game: Option<&str>) -> bool { + if self.game.as_deref() != game { + let game = game.map(str::to_string); + *self = Fx { + game, + ..Fx::default() + }; + return true; + } + false + } } impl ZaganApp { - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>, startup: Startup) -> Self { theme::install(&cc.egui_ctx); - Self { demo: Demo::new() } + + let db_path = paths::database_path(&startup.data_dir); + let (db, db_error) = match Connection::open(&db_path) { + Ok(conn) => { + let _ = conn.busy_timeout(Duration::from_secs(2)); + database::init_db(&conn); + (Some(conn), None) + } + Err(error) => ( + None, + Some(format!("Cannot open {}: {error}", db_path.display())), + ), + }; + let addr = db + .as_ref() + .and_then(database::get_last_host) + .unwrap_or_else(|| "127.0.0.1:11525".to_string()); + + let net = Network::spawn( + cc.egui_ctx.clone(), + startup.data_dir.clone(), + startup.unsafe_mode, + ); + + Self { + core: ClientCore::new(), + net, + db, + db_error, + unsafe_mode: startup.unsafe_mode, + session: None, + connect: ConnectState { + addr, + ..ConnectState::default() + }, + login: LoginState::new(), + trust: None, + key_changed: None, + leave_confirm: false, + notices: VecDeque::new(), + awaiting: None, + need_info: false, + pending_login: None, + balance_hold: None, + balance: None, + account: None, + fx: Fx::default(), + } + } + + fn screen(&mut self, ui: &mut Ui) { + if let Some(error) = self.db_error.clone() { + fatal(ui, &error); + return; + } + + let view = self.core.view(); + if self.session.is_none() { + connect_screen(ui, self); + return; + } + + match view { + Some(View::Lobby(lobby)) if lobby.user.is_none() => login_screen(ui, self, &lobby), + Some(View::Lobby(lobby)) => lobby_screen(ui, self, &lobby), + Some(View::History(history)) => history_screen(ui, self, &history.records), + Some(View::Slots(view)) => { + let command = slots::show(ui, &view, &mut self.fx.slots); + if command == Some(Command::Spin) { + self.balance_hold = self + .balance + .map(|balance| (balance - view.bet as i64).max(0)); + } + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + Some(View::Roulette(view)) => { + let command = roulette::show(ui, &view, &mut self.fx.roulette); + if command == Some(Command::Spin) { + self.balance_hold = self + .balance + .map(|balance| (balance - view.bets.total() as i64).max(0)); + } + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + Some(View::Blackjack(view)) => { + if let Some(command) = blackjack::show(ui, &view) { + self.command(ui.ctx(), command); + } + } + Some(View::Crash(view)) => { + let command = crash::show(ui, &view, &mut self.fx.crash); + if let Some(command) = command { + self.command(ui.ctx(), command); + } + } + None => {} + } + } + + /// Run one core command, holding back a leave that would forfeit a live + /// round until the player confirms. + fn command(&mut self, ctx: &Context, command: Command) { + if command == Command::Leave && self.core.is_live_game() { + self.leave_confirm = true; + return; + } + if command == Command::Logout { + self.need_info = false; + self.account = None; + self.balance = None; + self.pending_login = None; + if let Some(session) = &mut self.session { + session.user = None; + } + } + let effects = self.core.command(command); + self.apply(ctx, effects); + } + + fn apply(&mut self, ctx: &Context, effects: Vec) { + for effect in effects { + match effect { + Effect::Print(lines) => { + // Multi-line prints are terminal tables (history, help, + // info); the GUI draws those from `View` instead. + if lines.len() == 1 { + if let Some(line) = lines.first() { + self.notice(line); + } + } + } + Effect::SendLine(line) => { + self.net.send(NetCommand::Send(line)); + self.awaiting = Some(Instant::now()); + } + Effect::Disconnect => { + self.net.send(NetCommand::Disconnect); + self.core.reset_after_disconnect(); + self.session = None; + self.awaiting = None; + self.need_info = false; + self.account = None; + self.balance = None; + self.balance_hold = None; + } + Effect::ClearRegion | Effect::Render(_) => {} + Effect::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } + } + + fn notice(&mut self, text: &str) { + // `info` is fetched to fill the header; its terminal lines are not + // news to the GUI. + if text.starts_with("Connected to:") + || text.starts_with("Fingerprint:") + || text.starts_with("User:") + || text.starts_with("Account:") + || text.starts_with("Balance:") + { + return; + } + let tone = if text.starts_with("ERROR") + || text.contains("failed") + || text.contains("Failed") + || text.contains("lost") + { + Tone::Bad + } else if text.starts_with("Logged in") + || text.starts_with("Account created") + || text.contains("saved") + { + Tone::Good + } else { + Tone::Info + }; + self.notices.push_back(Notice { + text: text.to_string(), + at: Instant::now(), + tone, + }); + while self.notices.len() > 3 { + self.notices.pop_front(); + } + } + + fn pump(&mut self, ctx: &Context) { + while let Some(event) = self.net.try_recv() { + match event { + Event::Trust { + addr, + fingerprint, + reply, + } => { + self.trust = Some(TrustState { + addr, + fingerprint, + reply, + }); + } + Event::Connected(connected) => self.connected(ctx, connected), + Event::Failed { + addr, + failure, + message, + } => self.failed(addr, failure, message), + Event::Line(line) => { + if self.core.is_async(&line) { + if let Some(effects) = self.core.handle_async_line(&line) { + self.apply(ctx, effects); + } + } else { + self.awaiting = None; + let effects = self.core.handle_reply(&line); + self.apply(ctx, effects); + } + } + Event::Closed => { + self.session = None; + self.awaiting = None; + self.core.reset_after_disconnect(); + self.notice("Connection lost"); + ctx.request_repaint(); + } + } + } + } + + fn connected(&mut self, ctx: &Context, connected: Connected) { + self.connect.pending = false; + self.connect.error = None; + self.core.set_connected(true); + self.core + .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); + self.core.set_logged_in(connected.user.clone()); + self.login.error = connected.auth_error.clone(); + self.login.user = connected.user.clone().unwrap_or_default(); + + if let Some(db) = &self.db { + database::save_host(db, &connected.addr); + } + self.session = Some(Session { + host: connected.addr, + fingerprint: connected.fingerprint, + ping_ms: connected.elapsed_ms, + server_version: connected.server_version, + latest: connected.latest, + url: connected.url, + user: connected.user, + }); + + self.need_info = self.session.as_ref().is_some_and(|s| s.user.is_some()); + self.account = None; + if self.need_info { + let effects = self.core.command(Command::Info); + self.apply(ctx, effects); + self.need_info = false; + } + } + + fn failed(&mut self, addr: String, failure: Failure, message: String) { + self.connect.pending = false; + self.session = None; + self.core.set_connected(false); + self.awaiting = None; + match failure { + Failure::KeyChanged { expected, observed } => { + self.key_changed = Some(KeyChanged { + addr, + expected, + observed, + }); + } + Failure::VersionMismatch => { + self.connect.version_issue = true; + self.connect.error = Some(message); + } + Failure::TrustDeclined => { + self.trust = None; + self.connect.error = Some("Certificate was not trusted.".to_string()); + } + Failure::Timeout => { + self.trust = None; + self.connect.error = Some("Connection timed out.".to_string()); + } + Failure::Other => { + self.trust = None; + self.connect.error = Some(message); + } + } + } + + fn begin_connect(&mut self, addr: &str) { + let addr = normalize_addr(addr); + self.connect.addr = addr.clone(); + self.connect.error = None; + self.connect.version_issue = false; + self.connect.pending = true; + self.login.error = None; + self.net.send(NetCommand::Connect { addr }); + } + + fn disconnect(&mut self) { + self.net.send(NetCommand::Disconnect); + self.core.reset_after_disconnect(); + self.session = None; + self.awaiting = None; + self.trust = None; + self.key_changed = None; + self.need_info = false; + self.account = None; + self.balance = None; + self.balance_hold = None; + } + + fn trust_decision(&mut self, accept: bool) { + if let Some(trust) = self.trust.take() { + let _ = trust.reply.send(accept); + if !accept { + self.connect.error = Some("Certificate was not trusted.".to_string()); + } + } + } + + fn forget_trusted_host(&mut self, addr: &str) { + let Some(db) = &self.db else { return }; + match database::forget_host(db, addr) { + Ok(_) => { + self.key_changed = None; + self.begin_connect(addr); + } + Err(error) => self.connect.error = Some(format!("Failed to forget host: {error}")), + } + } + + fn forget_login(&mut self) { + let Some(fingerprint) = self + .session + .as_ref() + .and_then(|session| session.fingerprint.clone()) + else { + return; + }; + let Some(db) = &self.db else { return }; + match database::forget_credentials(db, &fingerprint) { + Ok(true) => self.notice("Saved login forgotten. Trust is unchanged."), + Ok(false) => self.notice("No saved login for this server."), + Err(error) => self.notice(&format!("Failed to forget the login: {error}")), + } + } + + fn login(&mut self, ctx: &Context, registering: bool) { + let user = self.login.user.trim().to_string(); + if user.is_empty() || self.login.password.is_empty() { + self.login.error = Some("Enter a username and a password.".to_string()); + return; + } + let credential = format!("{:x}", Sha256::digest(self.login.password.as_bytes())); + self.login.password.clear(); + self.login.error = None; + let command = if registering { + Command::Register { user, credential } + } else { + self.need_info = true; + if self.login.remember { + self.pending_login = Some((user.clone(), credential.clone())); + } + Command::Login { user, credential } + }; + let effects = self.core.command(command); + self.apply(ctx, effects); + } + + fn tick(&mut self, ctx: &Context) { + // Request deadline, mirroring the terminal's socket timeout. + if self.need_info + && self + .session + .as_ref() + .is_some_and(|session| session.user.is_some()) + && self.account.is_none() + { + let effects = self.core.command(Command::Info); + self.apply(ctx, effects); + self.need_info = false; + } + + if let Some(sent) = self.awaiting { + if sent.elapsed() > REQUEST_TIMEOUT { + self.awaiting = None; + self.net.send(NetCommand::Disconnect); + self.session = None; + self.core.reset_after_disconnect(); + let effects = self.core.handle_reply("ERROR Request timed out"); + self.apply(ctx, effects); + self.notice("Request timed out. Disconnected."); + } + } + + let game = self.core.current_game_type().map(str::to_string); + if self.fx.sync(game.as_deref()) { + self.balance_hold = None; + } + + match game.as_deref() { + Some("crash") => { + let effects = self.core.tick(); + self.apply(ctx, effects); + if let Some(View::Crash(view)) = self.core.view() { + self.fx.crash.tick(ctx, &view); + if view.status == "running" || view.status == "starting" { + ctx.request_repaint_after(Duration::from_millis(16)); + } + } + } + Some("slots") => { + if let Some(View::Slots(view)) = self.core.view() { + self.fx.slots.tick(ctx, &view); + } + } + Some("roulette") => { + if let Some(View::Roulette(view)) = self.core.view() { + self.fx.roulette.tick(ctx, &view); + } + } + _ => {} + } + + // Hold the balance at the staked amount until the reels or wheel stop, + // so the payout is not visible before the animation reveals it. + if self.balance_hold.is_some() && !self.result_animating(ctx, game.as_deref()) { + self.balance_hold = None; + } + + self.keys(ctx, game.as_deref()); + + // A successful manual login can be remembered for the next connect. + if let Some((user, credential)) = self.pending_login.clone() { + if self + .core + .view() + .as_ref() + .is_some_and(|view| matches!(view, View::Lobby(lobby) if lobby.user.is_some())) + { + if let Some(fingerprint) = self + .session + .as_ref() + .and_then(|session| session.fingerprint.as_deref()) + { + if let Some(db) = &self.db { + let _ = database::save_credentials(db, fingerprint, &user, &credential); + self.notice("Login saved for this server."); + } + } + self.pending_login = None; + } + } + + // Cache the header data, which game views do not all carry. + if let Some(view) = self.core.view() { + match view { + View::Lobby(lobby) => { + if let Some(session) = &mut self.session { + session.user = lobby.user.clone(); + } + if lobby.user.is_none() { + self.balance = None; + self.account = None; + } else { + if let Some(account) = lobby.account { + self.account = Some(account); + } + if let Some(balance) = lobby.balance { + if self.balance_hold.is_none() { + self.balance = Some(balance); + } + } + } + } + View::History(_) => {} + other => { + if self.balance_hold.is_none() { + if let Some(balance) = balance_of(&other) { + self.balance = Some(balance); + } + } + } + } + } + + self.notices + .retain(|notice| notice.at.elapsed() < Duration::from_secs(NOTICE_SECONDS)); + if !self.notices.is_empty() { + ctx.request_repaint_after(Duration::from_millis(500)); + } + } + + /// True while a spin's result is still being animated. + fn result_animating(&self, ctx: &Context, game: Option<&str>) -> bool { + match game { + Some("slots") => match self.core.view() { + Some(View::Slots(view)) if view.error.is_none() => { + !self.fx.slots.revealed(ctx, &view) + } + _ => false, + }, + Some("roulette") => match self.core.view() { + Some(View::Roulette(view)) if view.error.is_none() => { + !self.fx.roulette.revealed(&view) + } + _ => false, + }, + _ => false, + } + } + + /// The few keys that stay useful when the pointer is elsewhere. Buttons + /// keep focus, so this only fires when nothing is focused. + fn keys(&mut self, ctx: &Context, game: Option<&str>) { + if self.session.is_none() || !ctx.memory(|memory| memory.focused().is_none()) { + return; + } + let (space, clear, cash, hit, stand, double) = ctx.input(|input| { + ( + input.key_pressed(Key::Space), + input.key_pressed(Key::C), + input.key_pressed(Key::C), + input.key_pressed(Key::H), + input.key_pressed(Key::S), + input.key_pressed(Key::D), + ) + }); + let command = match game { + Some("roulette") => { + if space { + self.fx.roulette.spin_requested(); + Some(Command::Spin) + } else if clear { + Some(Command::ClearBets) + } else { + None + } + } + Some("slots") if space => Some(Command::Spin), + Some("crash") => { + if cash { + Some(Command::CashOut) + } else if space { + Some(Command::Start) + } else { + None + } + } + Some("blackjack") => { + if hit { + Some(Command::Hit) + } else if stand { + Some(Command::Stand) + } else if double { + Some(Command::Double) + } else if space { + Some(Command::Deal) + } else { + None + } + } + _ => None, + }; + if let Some(command) = command { + self.command(ctx, command); + } + } + + fn modals(&mut self, ctx: &Context) { + if let Some(trust) = &self.trust { + let addr = trust.addr.clone(); + let fingerprint = trust.fingerprint.clone(); + let mut decision = None; + egui::Modal::new(egui::Id::unique("trust")).show(ctx, |ui| { + ui.set_width(460.0); + ui.label( + RichText::new("New server certificate") + .font(theme::sans(20.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "This is the first time this client has seen this server. \ + Check the fingerprint before trusting it.", + ); + ui.add_space(theme::SPACE_3); + widgets::row(ui, "Address", &addr, theme::TEXT); + widgets::mono_text(ui, &fingerprint, theme::ACCENT); + ui.add_space(theme::SPACE_3); + widgets::note( + ui, + "Future connections must present the same key. A changed key is refused.", + ); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "TRUST", true, 130.0, 40.0) { + decision = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { + decision = Some(false); + } + }); + }); + if let Some(accept) = decision { + self.trust_decision(accept); + } + } + + if let Some(changed) = &self.key_changed { + let addr = changed.addr.clone(); + let expected = changed.expected.clone(); + let observed = changed.observed.clone(); + let mut action = None; + egui::Modal::new(egui::Id::unique("key-changed")).show(ctx, |ui| { + ui.set_width(520.0); + ui.label( + RichText::new("SERVER KEY CHANGED") + .font(theme::mono(16.0)) + .color(theme::LOSS), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "A server that used to present a different certificate is answering. \ + This can mean the server was reinstalled, or that someone is \ + intercepting the connection.", + ); + ui.add_space(theme::SPACE_3); + widgets::row(ui, "Address", &addr, theme::TEXT); + widgets::mono_text(ui, &format!("expected {expected}"), theme::TEXT_DIM); + widgets::mono_text(ui, &format!("observed {observed}"), theme::LOSS); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "FORGET AND RECONNECT", true, 220.0, 40.0) { + action = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { + action = Some(false); + } + }); + }); + match action { + Some(true) => self.forget_trusted_host(&addr), + Some(false) => { + self.key_changed = None; + self.connect.error = Some("Server key changed. Trust not reset.".to_string()); + } + None => {} + } + } + + if self.leave_confirm { + let mut action = None; + egui::Modal::new(egui::Id::unique("leave-confirm")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label( + RichText::new("Leave the table?") + .font(theme::sans(19.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + "A live hand or round is forfeited. The reserved bet is lost.", + ); + ui.add_space(theme::SPACE_4); + ui.horizontal(|ui| { + if widgets::primary(ui, "FORFEIT AND LEAVE", true, 190.0, 40.0) { + action = Some(true); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary(ui, "STAY", true, 100.0, 40.0) { + action = Some(false); + } + }); + }); + if let Some(leave) = action { + self.leave_confirm = false; + if leave { + let effects = self.core.command(Command::Leave); + self.apply(ctx, effects); + } + } + } + } + + fn toasts(&self, ctx: &Context) { + if self.notices.is_empty() { + return; + } + egui::Area::new(egui::Id::unique("toasts")) + .anchor(Align2::LEFT_BOTTOM, vec2(24.0, -24.0)) + .order(egui::Order::Foreground) + .interactable(false) + .show(ctx, |ui| { + ui.set_min_width(220.0); + ui.set_max_width(460.0); + ui.spacing_mut().item_spacing.y = theme::SPACE_2; + for notice in &self.notices { + let color = match notice.tone { + Tone::Bad => theme::LOSS, + Tone::Good => theme::WIN, + Tone::Info => theme::TEXT_DIM, + }; + Frame::new() + .fill(theme::SURFACE_RAISED) + .stroke(Stroke::new(1.0, theme::LINE)) + .corner_radius(theme::RADIUS_MD) + .inner_margin(Margin::symmetric(14, 8)) + .show(ui, |ui| { + ui.set_min_width(226.0); + ui.set_max_width(440.0); + ui.label( + RichText::new(¬ice.text) + .font(theme::sans(12.0)) + .color(color), + ); + }); + } + }); } } impl eframe::App for ZaganApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { - self.demo.tick(ui.ctx()); - self.demo.keys(ui.ctx()); - - Panel::top("header") - .exact_size(HEADER_HEIGHT) - .frame( - Frame::new() - .fill(theme::SURFACE) - .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), - ) - .show(ui, |ui| header(ui, &self.demo)); - - Panel::right("sidebar") - .exact_size(SIDEBAR_WIDTH) - .frame( - Frame::new() - .fill(theme::SURFACE) - .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), - ) - .show(ui, |ui| { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| sidebar(ui, &mut self.demo)); - }); + let ctx = ui.ctx().clone(); + self.pump(&ctx); + self.tick(&ctx); + header(ui, self); + self.screen(ui); + self.modals(&ctx); + self.toasts(&ctx); + } +} - CentralPanel::default() - .frame( - Frame::new() - .fill(theme::BG) - .inner_margin(Margin::same(theme::SPACE_5 as i8)), - ) - .show(ui, |ui| table(ui, &mut self.demo)); +fn balance_of(view: &View) -> Option { + match view { + View::Lobby(lobby) => lobby.balance, + View::Slots(view) => view.balance, + View::Roulette(view) => view.balance, + View::Blackjack(view) => view.balance, + View::Crash(view) => view.balance, + View::History(_) => None, + } +} + +fn normalize_addr(addr: &str) -> String { + let addr = addr.trim(); + if addr.is_empty() { + return "127.0.0.1:11525".to_string(); + } + if addr.contains(':') { + addr.to_string() + } else { + format!("{addr}:11525") } } @@ -77,50 +918,76 @@ impl eframe::App for ZaganApp { // Header // --------------------------------------------------------------------------- -fn header(ui: &mut Ui, demo: &Demo) { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); - ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); - ui.add_space(theme::SPACE_2); - ui.label( - RichText::new("ZAGAN") - .font(theme::mono(18.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("BET") - .font(theme::mono(18.0)) - .color(theme::WIN), - ); - ui.add_space(theme::SPACE_3); - pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); - ui.add_space(theme::SPACE_1); - pill(ui, "PREVIEW", theme::GOLD); - ui.add_space(theme::SPACE_5); - connection_pill(ui, demo); +fn header(ui: &mut Ui, app: &ZaganApp) { + Panel::top("header") + .exact_size(64.0) + .frame( + Frame::new() + .fill(theme::SURFACE) + .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), + ) + .show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); + ui.add_space(theme::SPACE_2); + ui.label( + RichText::new("ZAGAN") + .font(theme::mono(18.0)) + .color(theme::TEXT), + ); + ui.label( + RichText::new("BET") + .font(theme::mono(18.0)) + .color(theme::WIN), + ); + ui.add_space(theme::SPACE_3); + pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); + if app.unsafe_mode { + ui.add_space(theme::SPACE_1); + pill(ui, "UNSAFE", theme::LOSS); + } + ui.add_space(theme::SPACE_5); + match &app.session { + Some(session) => connection_pill(ui, session), + None => pill(ui, "OFFLINE", theme::TEXT_FAINT), + } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - stat_block( - ui, - 150.0, - "Balance", - &theme::money(demo.balance), - theme::mono(20.0), - theme::TEXT, - None, - ); - ui.add_space(theme::SPACE_5); - stat_block( - ui, - 190.0, - "Account", - demo.user, - theme::sans(theme::FONT_BODY), - theme::TEXT, - Some(demo.account), - ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let signed_in = app + .session + .as_ref() + .is_some_and(|session| session.user.is_some()); + if signed_in { + if let Some(balance) = app.balance_hold.or(app.balance) { + stat_block( + ui, + 150.0, + "Balance", + &theme::money(balance), + theme::mono(20.0), + theme::TEXT, + None, + ); + ui.add_space(theme::SPACE_5); + } + if let Some(user) = app.session.as_ref().and_then(|s| s.user.as_deref()) { + let account = + app.account.as_deref().map(|account| format!("#{account}")); + stat_block( + ui, + 190.0, + "Account", + user, + theme::sans(theme::FONT_BODY), + theme::TEXT, + account.as_deref(), + ); + } + } + }); + }); }); - }); } fn pill(ui: &mut Ui, text: &str, color: Color32) { @@ -133,7 +1000,7 @@ fn pill(ui: &mut Ui, text: &str, color: Color32) { }); } -fn connection_pill(ui: &mut Ui, demo: &Demo) { +fn connection_pill(ui: &mut Ui, session: &Session) { Frame::new() .fill(theme::SURFACE_RAISED) .corner_radius(theme::RADIUS_SM) @@ -148,12 +1015,12 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { .color(theme::WIN), ); ui.label( - RichText::new(demo.host) + RichText::new(&session.host) .font(theme::mono(12.0)) .color(theme::TEXT_DIM), ); ui.label( - RichText::new(format!("{} ms", demo.ping_ms)) + RichText::new(format!("{:.1} ms", session.ping_ms)) .font(theme::mono(12.0)) .color(theme::TEXT_FAINT), ); @@ -161,8 +1028,6 @@ fn connection_pill(ui: &mut Ui, demo: &Demo) { }); } -/// A label over a value, right-aligned in a fixed slot so the header's right -/// edge stays put as the numbers change. fn stat_block( ui: &mut Ui, width: f32, @@ -187,454 +1052,554 @@ fn stat_block( } // --------------------------------------------------------------------------- -// Sidebar +// Connect // --------------------------------------------------------------------------- -fn sidebar(ui: &mut Ui, demo: &mut Demo) { - ui.label( - RichText::new("Roulette") - .font(theme::sans(22.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("European · single zero") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); +fn connect_screen(ui: &mut Ui, app: &mut ZaganApp) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(60.0); + let width = 560.0; + ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(Align::Min), |ui| { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)) + .show(ui, |ui| { + ui.label( + RichText::new("Connect to a server") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + ui.add_space(theme::SPACE_1); + widgets::note( + ui, + "TLS with certificate pinning. First contact asks before \ + any credential is sent.", + ); + ui.add_space(theme::SPACE_4); - ui.add_space(theme::SPACE_4); - let view = demo.view(); - let wheel_size = ui.available_width().min(210.0); - ui.horizontal(|ui| { - let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); - ui.add_space(pad); - roulette::wheel(ui, wheel_size, &view, demo.rotation); - }); - ui.add_space(theme::SPACE_1); - result_line(ui, demo); + let response = ui.add( + TextEdit::singleline(&mut app.connect.addr) + .hint_text("host:port") + .desired_width(f32::INFINITY) + .font(theme::mono(14.0)), + ); + let submitted = response.lost_focus() + && ui.input(|input| input.key_pressed(Key::Enter)); - ui.add_space(theme::SPACE_3); - ui.separator(); - ui.add_space(theme::SPACE_3); + ui.add_space(theme::SPACE_3); + let connect = widgets::primary( + ui, + if app.connect.pending { + "CONNECTING…" + } else { + "CONNECT" + }, + !app.connect.pending, + ui.available_width(), + 44.0, + ); + if (connect || submitted) && !app.connect.pending { + let addr = app.connect.addr.clone(); + app.begin_connect(&addr); + } - ui.label(theme::label("Chip")); - ui.add_space(theme::SPACE_2); - let spinning = demo.phase == RoulettePhase::Spinning; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::SPACE_2; - for amount in CHIP_SIZES { - if chip_button(ui, amount, demo.chip == amount, !spinning).clicked() { - demo.chip = amount; - } - } - }); + if let Some(error) = &app.connect.error { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + if app.connect.version_issue { + ui.add_space(theme::SPACE_2); + widgets::note( + ui, + &format!( + "Download the matching client from {}", + zagan_bet::version::DEFAULT_DOWNLOAD_URL + ), + ); + } + }); - ui.add_space(theme::SPACE_3); - ui.label(theme::label("Bets")); - ui.add_space(theme::SPACE_1); - if demo.bets.chips.is_empty() { + ui.add_space(theme::SPACE_4); + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_4 as i8)) + .show(ui, |ui| { + ui.label(theme::label("Remembered hosts")); + ui.add_space(theme::SPACE_2); + let hosts = app + .db + .as_ref() + .map(database::get_all_hosts) + .unwrap_or_default(); + if hosts.is_empty() { + widgets::note(ui, "No remembered hosts yet."); + } + for (host, fingerprint) in hosts { + remembered_host(ui, app, &host, fingerprint.as_deref()); + } + }); + }); + }); + }); +} + +fn remembered_host(ui: &mut Ui, app: &mut ZaganApp, host: &str, fingerprint: Option<&str>) { + ui.horizontal(|ui| { ui.label( - RichText::new("No chips on the felt") - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), + RichText::new(host) + .font(theme::mono(13.0)) + .color(theme::TEXT), ); - } else { - for chip in &demo.bets.chips { - bet_row(ui, &roulette::target_name(&chip.target), chip.amount); + if let Some(fingerprint) = fingerprint { + ui.label( + RichText::new(short_fingerprint(fingerprint)) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); } - } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::ghost(ui, "Forget") { + if let Some(db) = &app.db { + if let Err(error) = database::forget_host(db, host) { + app.connect.error = Some(format!("Failed to forget host: {error}")); + } + } + } + if widgets::ghost(ui, "Connect") { + app.begin_connect(host); + } + }); + }); +} - ui.add_space(theme::SPACE_3); - ui.separator(); - ui.add_space(theme::SPACE_2); - bet_row(ui, "Total", demo.bets.total()); +fn short_fingerprint(fingerprint: &str) -> String { + let head: String = fingerprint.chars().take(16).collect(); + format!("{head}…") +} - ui.add_space(theme::SPACE_4); - let actions = ui.available_width(); - let clear_width = 108.0; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::SPACE_2; +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- - let spin = Button::new( - RichText::new("SPIN") - .font(theme::mono(theme::FONT_MONO)) - .color(theme::BG), +fn login_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) - .fill(if spinning { - theme::WIN.gamma_multiply(0.45) - } else { - theme::WIN - }) - .corner_radius(theme::RADIUS_MD) - .min_size(vec2(actions - clear_width - theme::SPACE_2, 44.0)); - let can_spin = !spinning && !demo.bets.is_empty(); - if ui.add_enabled(can_spin, spin).clicked() { - demo.start_spin(ui.ctx()); - } + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.allocate_ui_with_layout(vec2(420.0, 0.0), Layout::top_down(Align::Min), |ui| { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)) + .show(ui, |ui| { + ui.label( + RichText::new("Sign in") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + if let Some(session) = &app.session { + widgets::mono_text( + ui, + &format!( + "{} · {}", + session.host, + short_fingerprint( + session.fingerprint.as_deref().unwrap_or("unknown") + ) + ), + theme::TEXT_DIM, + ); + } + ui.add_space(theme::SPACE_4); - let clear = Button::new( - RichText::new("CLEAR") - .font(theme::mono(12.0)) - .color(theme::TEXT_DIM), - ) - .fill(theme::SURFACE_RAISED) - .stroke(Stroke::new(1.0, theme::LINE)) - .corner_radius(theme::RADIUS_MD) - .min_size(vec2(clear_width, 44.0)); - if ui - .add_enabled(!demo.bets.is_empty() && !spinning, clear) - .clicked() - { - demo.begin_placing(); - demo.bets = Bets::default(); - } - }); -} + ui.label(theme::label("Username")); + ui.add( + TextEdit::singleline(&mut app.login.user) + .desired_width(f32::INFINITY), + ); + ui.add_space(theme::SPACE_3); + ui.label(theme::label("Password")); + ui.add( + TextEdit::singleline(&mut app.login.password) + .password(true) + .desired_width(f32::INFINITY), + ); -fn result_line(ui: &mut Ui, demo: &Demo) { - ui.horizontal(|ui| match (demo.phase, demo.number) { - (RoulettePhase::Result, Some(number)) => { - ui.label(theme::label("Result")); - ui.label( - RichText::new(format!("{number} {}", wheel_color(number).to_uppercase())) - .font(theme::mono(14.0)) - .color(color_of(number)), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - payout(ui, demo); + ui.add_space(theme::SPACE_2); + ui.checkbox( + &mut app.login.remember, + RichText::new("Remember this login") + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ); + + let enter = ui.input(|input| input.key_pressed(Key::Enter)); + ui.add_space(theme::SPACE_4); + if widgets::primary(ui, "LOG IN", true, ui.available_width(), 44.0) + || enter + { + app.login(ui.ctx(), false); + } + ui.add_space(theme::SPACE_2); + if widgets::secondary( + ui, + "CREATE ACCOUNT", + true, + ui.available_width(), + 38.0, + ) { + app.login(ui.ctx(), true); + } + + if let Some(error) = + lobby.login_error.as_ref().or(app.login.error.as_ref()) + { + ui.add_space(theme::SPACE_3); + widgets::error(ui, error); + } + }); + + ui.add_space(theme::SPACE_3); + ui.allocate_ui_with_layout( + vec2(420.0, 24.0), + Layout::top_down(Align::Center), + |ui| { + if widgets::ghost(ui, "Disconnect") { + app.disconnect(); + } + }, + ); + }); }); - } - (RoulettePhase::Spinning, _) => { - ui.label( - RichText::new("SPINNING…") - .font(theme::mono(12.0)) - .color(theme::GOLD), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + }); +} + +// --------------------------------------------------------------------------- +// Lobby +// --------------------------------------------------------------------------- + +fn lobby_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.horizontal(|ui| { ui.label( - RichText::new(format!("{} chips", demo.bets.chips.len())) - .font(theme::mono(10.0)) - .color(theme::TEXT_FAINT), + RichText::new("Lobby") + .font(theme::sans(24.0)) + .color(theme::TEXT), ); - }); - } - _ => { - ui.label( - RichText::new("Place your bets") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label( - RichText::new(format!("{} chips", demo.bets.chips.len())) - .font(theme::mono(10.0)) - .color(theme::TEXT_FAINT), + RichText::new(format!("{} games", lobby.games.len())) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::secondary(ui, "LOG OUT", true, 100.0, 34.0) { + app.command(ui.ctx(), Command::Logout); + } + if widgets::secondary(ui, "HISTORY", true, 104.0, 34.0) { + let effects = app.core.command(Command::History { limit: 20 }); + app.apply(ui.ctx(), effects); + } + }); }); - } - }); + + if let Some(session) = &app.session { + if is_newer(&session.latest, VERSION) { + ui.add_space(theme::SPACE_3); + Frame::new() + .fill(theme::GOLD.gamma_multiply(0.10)) + .stroke(Stroke::new(1.0, theme::GOLD.gamma_multiply(0.5))) + .corner_radius(theme::RADIUS_MD) + .inner_margin(Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Update available: v{}", session.latest)) + .font(theme::mono(12.0)) + .color(theme::GOLD), + ); + widgets::mono_text(ui, &session.url, theme::TEXT_DIM); + }); + }); + } + } + + ui.add_space(theme::SPACE_4); + games_grid(ui, app, lobby); + + ui.add_space(theme::SPACE_4); + ui.separator(); + ui.add_space(theme::SPACE_2); + ui.horizontal(|ui| { + if let Some(session) = &app.session { + widgets::note( + ui, + &format!( + "Signed in to {} · server v{}", + session.host, session.server_version + ), + ); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::ghost(ui, "Forget saved login") { + app.forget_login(); + } + }); + }); + }); } -/// What the round paid, and the net for the spin. -fn payout(ui: &mut Ui, demo: &Demo) { - let winnings = demo.winnings.unwrap_or(0); - if winnings == 0 { - ui.label( - RichText::new("NO WIN") - .font(theme::mono(13.0)) - .color(theme::TEXT_FAINT), - ); - return; - } +fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { + const ORDER: [&str; 4] = ["slots", "roulette", "blackjack", "crash"]; - let profit = winnings as i64 - demo.spent as i64; - ui.label( - RichText::new(format!("({})", theme::signed_money(profit))) - .font(theme::sans(11.0)) - .color(if profit >= 0 { theme::WIN } else { theme::LOSS }), - ); - ui.label( - RichText::new(theme::money(winnings as i64)) - .font(theme::mono(15.0)) - .color(theme::WIN), - ); - ui.label(theme::label("Won")); + for row in ORDER.chunks(2) { + ui.columns(2, |columns| { + for (index, game) in row.iter().enumerate() { + let Some((key, name)) = lobby.games.iter().find(|(key, _)| key == game) else { + continue; + }; + game_card(&mut columns[index], app, key, name); + } + }); + ui.add_space(theme::SPACE_4); + } } -fn bet_row(ui: &mut Ui, name: &str, amount: u64) { - ui.horizontal(|ui| { - ui.label( - RichText::new(name) - .font(theme::sans(13.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { +fn game_card(ui: &mut Ui, app: &mut ZaganApp, key: &str, name: &str) { + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_4 as i8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); ui.label( - RichText::new(theme::money(amount as i64)) - .font(theme::mono(13.0)) - .color(theme::GOLD), + RichText::new(name) + .font(theme::sans(22.0)) + .color(theme::TEXT), ); + ui.add_space(2.0); + widgets::note(ui, tagline(key)); + ui.add_space(theme::SPACE_1); + ui.label( + RichText::new(meta(key)) + .font(theme::mono(10.0)) + .color(theme::TEXT_FAINT), + ); + ui.add_space(theme::SPACE_3); + if widgets::primary(ui, "PLAY", true, 110.0, 36.0) { + let effects = app.core.command(Command::Play { + game: key.to_string(), + bet: Some(DEFAULT_BET), + }); + app.apply(ui.ctx(), effects); + } }); - }); } -fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> egui::Response { - let sense = if enabled { - Sense::click() - } else { - Sense::hover() - }; - let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); - let center = rect.center(); - let radius = 20.0; - let painter = ui.painter(); - if selected || response.hovered() { - painter.circle_stroke( - center, - radius + 3.0, - Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), - ); +fn tagline(game: &str) -> &'static str { + match game { + "slots" => "Five reels, five paylines, one lever.", + "roulette" => "The European wheel, thirty-seven pockets.", + "blackjack" => "Six decks, dealer stands on seventeen. Pays 3:2.", + "crash" => "The multiplier rises. Cash out before it busts.", + _ => "", } - if enabled { - roulette::paint_chip(painter, center, radius, amount); - } else { - roulette::paint_chip_preview(painter, center, radius, amount); - } - response } -fn color_of(number: u8) -> Color32 { - match wheel_color(number) { - "red" => theme::ROULETTE_RED, - "green" => theme::WIN, - _ => theme::TEXT, +fn meta(game: &str) -> &'static str { + match game { + "slots" => "MIN 10 KR · RTP 96%", + "roulette" => "MIN 10 KR · SINGLE ZERO", + "blackjack" => "MIN 10 KR · S17 · 3:2", + "crash" => "MIN 10 KR · 1% EDGE", + _ => "", } } // --------------------------------------------------------------------------- -// Table +// History // --------------------------------------------------------------------------- -fn table(ui: &mut Ui, demo: &mut Demo) { - ui.horizontal(|ui| { - ui.label( - RichText::new("Table 01") - .font(theme::sans(15.0)) - .color(theme::TEXT), - ); - ui.label( - RichText::new("European · single zero") - .font(theme::sans(12.0)) - .color(theme::TEXT_DIM), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new("Limits 10 – 500 Kr") - .font(theme::mono(12.0)) - .color(theme::TEXT_FAINT), - ); - }); - }); - ui.add_space(theme::SPACE_4); - - let view = demo.view(); - let strip_height = 104.0; - let available = ui.available_size(); - let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); - let side_pad = ((available.x - size.x) * 0.5).max(0.0); - let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); - let chip = demo.chip; - let placing = demo.phase != RoulettePhase::Spinning; - ui.add_space(top_pad); - ui.horizontal(|ui| { - ui.add_space(side_pad); - match roulette::felt(ui, size, &view) { - Some(FeltAction::Place(target)) if placing => { - demo.begin_placing(); - demo.bets.add(target, chip); - } - Some(FeltAction::Reduce(target)) if placing => { - demo.begin_placing(); - demo.bets.reduce(&target, chip); +fn history_screen(ui: &mut Ui, app: &mut ZaganApp, records: &[PlayRecord]) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.horizontal(|ui| { + if widgets::ghost(ui, "Back to lobby") { + app.command(ui.ctx(), Command::ShowLobby); + } + ui.label( + RichText::new("Play history") + .font(theme::sans(24.0)) + .color(theme::TEXT), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + for limit in [50, 20, 10] { + if widgets::ghost(ui, &limit.to_string()) { + let effects = app.core.command(Command::History { limit }); + app.apply(ui.ctx(), effects); + } + } + ui.label(theme::label("Show")); + }); + }); + ui.add_space(theme::SPACE_4); + + if records.is_empty() { + widgets::note(ui, "No plays recorded yet."); + return; } - _ => {} - } - }); - ui.add_space(theme::SPACE_4); - ui.horizontal(|ui| { - ui.label(theme::label("Last")); - if demo.last.is_empty() { - ui.label( - RichText::new("No spins yet") - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), - ); - } - for number in &demo.last { - let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); - roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); - ui.add_space(theme::SPACE_1); - } - }); - ui.add_space(theme::SPACE_2); - ui.label( - RichText::new( - "Click to place · right or middle click takes back · C clears · space spins", - ) - .font(theme::sans(12.0)) - .color(theme::TEXT_FAINT), - ); -} + Frame::new() + .fill(theme::SURFACE) + .corner_radius(theme::RADIUS_LG) + .inner_margin(Margin::same(theme::SPACE_3 as i8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(ui.available_height()); -// --------------------------------------------------------------------------- -// Demo state -// --------------------------------------------------------------------------- + let widths = history_columns(ui.available_width()); + history_header(ui, &widths); + ui.add_space(theme::SPACE_1); + ui.separator(); -struct Spin { - started: f64, - from: f32, - delta: f32, - target: u8, -} - -struct Demo { - bets: Bets, - chip: u64, - phase: RoulettePhase, - number: Option, - rotation: f32, - spin: Option, - spins: usize, - last: Vec, - winnings: Option, - spent: u64, - balance: i64, - host: &'static str, - ping_ms: u32, - user: &'static str, - account: &'static str, -} - -impl Demo { - fn new() -> Self { - let mut bets = Bets::default(); - bets.add(BetTarget::Straight { number: 17 }, 50); - bets.add( - BetTarget::Corner { - numbers: [17, 18, 20, 21], - }, - 25, - ); - bets.add(BetTarget::Outside { kind: Outside::Red }, 25); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + for (index, record) in records.iter().enumerate() { + history_row(ui, &widths, record, index % 2 == 1); + } + }); + }); + }); +} - Self { - bets, - chip: 25, - phase: RoulettePhase::Placing, - number: None, - rotation: 0.0, - spin: None, - spins: 0, - last: Vec::new(), - winnings: None, - spent: 0, - balance: 1000, - host: "127.0.0.1:11525", - ping_ms: 41, - user: "alice", - account: "#4161673306", - } - } +/// Column widths for the play log, filling the available width. +fn history_columns(width: f32) -> [f32; 6] { + let usable = (width - theme::SPACE_3 * 5.0).max(320.0); + [ + usable * 0.18, + usable * 0.12, + usable * 0.10, + usable * 0.10, + usable * 0.12, + usable * 0.38, + ] +} - fn view(&self) -> RouletteView { - RouletteView { - bet_amount: 10, - balance: Some(self.balance), - chip: self.chip, - bets: self.bets.clone(), - cursor: Cursor::default(), - phase: self.phase, - number: self.number, - color: self.number.map(|number| wheel_color(number).to_string()), - winnings: self.winnings, - error: None, - } - } +fn history_header(ui: &mut Ui, widths: &[f32; 6]) { + history_line( + ui, + widths, + [ + theme::label("When"), + theme::label("Game"), + theme::label("Bet"), + theme::label("Won"), + theme::label("Balance"), + theme::label("Result"), + ], + false, + ); +} - /// A bet change after a result starts the next round: the wheel stays - /// where it stopped, but the payout and the winning highlight do not. - fn begin_placing(&mut self) { - if self.phase == RoulettePhase::Result { - self.phase = RoulettePhase::Placing; - self.winnings = None; - } - } +fn history_row(ui: &mut Ui, widths: &[f32; 6], record: &PlayRecord, stripe: bool) { + history_line( + ui, + widths, + [ + RichText::new(record.short_time()) + .font(theme::mono(11.0)) + .color(theme::TEXT_DIM), + RichText::new(record.game.to_uppercase()) + .font(theme::mono(11.0)) + .color(theme::TEXT), + RichText::new(theme::money(record.bet)) + .font(theme::mono(12.0)) + .color(theme::TEXT_DIM), + RichText::new(theme::money(record.winnings)) + .font(theme::mono(12.0)) + .color(if record.winnings > 0 { + theme::WIN + } else { + theme::TEXT_FAINT + }), + RichText::new(theme::money(record.balance_after)) + .font(theme::mono(12.0)) + .color(theme::TEXT), + RichText::new(record.summary()) + .font(theme::sans(12.0)) + .color(theme::TEXT_DIM), + ], + stripe, + ); +} - fn tick(&mut self, ctx: &Context) { - let now = ctx.input(|input| input.time); - let mut finished = None; - if let Some(spin) = &self.spin { - let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); - let eased = 1.0 - (1.0 - t).powi(3); - self.rotation = spin.from + spin.delta * eased; - if t >= 1.0 { - finished = Some(spin.target); - } - ctx.request_repaint(); - } - if let Some(number) = finished { - self.spin = None; - self.phase = RoulettePhase::Result; - self.number = Some(number); - self.winnings = Some(self.bets.winnings(number)); - self.last.insert(0, number); - self.last.truncate(12); - } +fn history_line(ui: &mut Ui, widths: &[f32; 6], cells: [RichText; 6], stripe: bool) { + let spacing = theme::SPACE_3; + let width = widths.iter().sum::() + spacing * 5.0; + let (rect, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); + if stripe { + ui.painter().rect_filled( + rect, + theme::RADIUS_SM, + theme::SURFACE_RAISED.gamma_multiply(0.6), + ); } - - /// Pointer-first: the felt reads the mouse. The keyboard keeps the two - /// verbs that have no pointer equivalent in profile (spin and clear). - fn keys(&mut self, ctx: &Context) { - if self.phase == RoulettePhase::Spinning { - return; - } - if ctx.memory(|memory| memory.focused().is_none()) { - if ctx.input(|input| input.key_pressed(Key::Space)) { - self.start_spin(ctx); - } - if ctx.input(|input| input.key_pressed(Key::C)) { - self.begin_placing(); - self.bets = Bets::default(); - } - } + let mut child = ui.new_child( + eframe::egui::UiBuilder::new() + .max_rect(rect.shrink2(vec2(6.0, 0.0))) + .layout(Layout::left_to_right(Align::Center)), + ); + child.spacing_mut().item_spacing.x = spacing; + for (width, cell) in widths.iter().zip(cells) { + child.add_sized( + vec2(*width, 18.0), + egui::Label::new(cell).selectable(false).truncate(), + ); } +} - fn start_spin(&mut self, ctx: &Context) { - if self.phase == RoulettePhase::Spinning || self.bets.is_empty() { - return; - } - let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; - self.spins += 1; - self.spent = self.bets.total(); - self.winnings = None; - - let segment = TAU / WHEEL.len() as f32; - let index = WHEEL - .iter() - .position(|number| *number == target) - .unwrap_or(0) as f32; - let desired = -index * segment; - let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; - - self.spin = Some(Spin { - started: ctx.input(|input| input.time), - from: self.rotation, - delta, - target, +fn fatal(ui: &mut Ui, message: &str) { + CentralPanel::default() + .frame( + Frame::new() + .fill(theme::BG) + .inner_margin(Margin::same(theme::SPACE_5 as i8)), + ) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(120.0); + ui.label( + RichText::new("Cannot start") + .font(theme::sans(24.0)) + .color(theme::LOSS), + ); + ui.add_space(theme::SPACE_2); + widgets::error(ui, message); + }); }); - self.phase = RoulettePhase::Spinning; - self.number = None; - ctx.request_repaint(); - } } -- cgit v1.2.3