From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- src/client/core.rs | 182 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 135 insertions(+), 47 deletions(-) (limited to 'src/client/core.rs') diff --git a/src/client/core.rs b/src/client/core.rs index 6d8294c..278dc00 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -3,17 +3,16 @@ use crate::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_history_response, parse_roulette_response, parse_spin_response, }; +use crate::client::ui::{game_lines, history_lines}; use crate::client::view::{ BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, }; -use crate::client::ui::{game_lines, history_lines}; use crate::crash::{CrashState, multiplier_at}; -use crate::history::{PlayRecord, MAX_HISTORY}; +use crate::history::{MAX_HISTORY, PlayRecord}; use crate::roulette::{BetTarget, Bets}; +use crate::version::{parse_pong, unix_time_ms}; use std::time::{Duration, Instant}; - - /// High-level actions produced by the client core. The terminal adapter is /// responsible for turning these into crossterm calls and network writes. #[derive(Debug, Clone, PartialEq)] @@ -24,8 +23,8 @@ pub enum Effect { Render(View), /// Remove the game board region. ClearRegion, - /// Send one protocol line and feed the reply to `handle_reply`. - SendLine(String), + /// Send one enveloped request and route its reply back by `id`. + SendRequest { id: u64, payload: String }, /// Drop the connection. Disconnect, /// Leave the program. @@ -61,6 +60,10 @@ pub struct ClientCore { connected: bool, logged_in: Option, pending: Option, + /// Id of the request whose reply fills `pending`. + pending_id: Option, + /// Next correlation id allocated on this connection. + next_request_id: u64, balance: Option, /// Account number from the last `INFO`, shown by both frontends. account: Option, @@ -68,6 +71,13 @@ pub struct ClientCore { fingerprint: Option, /// Local anchor for the crash multiplier, set when a round starts. crash_started: Option, + /// Server-time anchor for the crash multiplier, when the server supplied + /// `started_at`. + crash_started_ms: Option, + /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`. + server_offset_ms: i64, + /// True once a frontend has supplied a server timestamp. + clock_synced: bool, /// App screen shown when no game is open. screen: Screen, /// Last plays fetched with `history`. @@ -83,11 +93,16 @@ impl ClientCore { connected: false, logged_in: None, pending: None, + pending_id: None, + next_request_id: 1, balance: None, account: None, host: None, fingerprint: None, crash_started: None, + crash_started_ms: None, + server_offset_ms: 0, + clock_synced: false, screen: Screen::Lobby, play_history: Vec::new(), login_error: None, @@ -99,11 +114,19 @@ impl ClientCore { if !connected { self.logged_in = None; self.pending = None; + self.pending_id = None; self.crash_started = None; + self.crash_started_ms = None; + self.clock_synced = false; self.screen = Screen::Lobby; } } + /// The signed-in username, if any. + pub fn user(&self) -> Option<&str> { + self.logged_in.as_deref() + } + pub fn set_logged_in(&mut self, user: Option) { if user.is_some() { self.login_error = None; @@ -147,7 +170,44 @@ impl ClientCore { self.board_effects() } + /// Record the server's clock and the local time it was received. The + /// offset is for display; cash-out still settles on the server. + pub fn set_server_time(&mut self, server_ms: u64) { + self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64; + self.clock_synced = true; + } + + /// The game server's clock, as this client currently estimates it. + pub fn server_now_ms(&self) -> u64 { + (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64 + } + + /// Allocate a correlation id for a request the adapter sends directly + /// (the terminal's login prompt bypasses `ClientCore`). + pub fn allocate_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id = self.next_request_id.wrapping_add(1).max(1); + id + } + + fn begin_request(&mut self, pending: Pending, payload: String) -> Effect { + let id = self.allocate_request_id(); + self.pending = Some(pending); + self.pending_id = Some(id); + Effect::SendRequest { id, payload } + } + + /// Route a reply by correlation id. A reply we no longer expect (a late + /// response after a timeout) is ignored instead of corrupting the board. + pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec { + if self.pending_id != Some(id) { + return Vec::new(); + } + self.handle_reply(reply) + } + pub fn handle_reply(&mut self, reply: &str) -> Vec { + self.pending_id = None; match self.pending.take() { Some(Pending::Spin) => match parse_spin_response(reply) { Ok(result) => { @@ -265,7 +325,8 @@ impl ClientCore { } }, Some(Pending::Ping) => { - if reply.trim() == "PONG" { + if let Some(server_ms) = parse_pong(reply) { + self.set_server_time(server_ms); print_line("Pong") } else { self.connected = false; @@ -305,10 +366,8 @@ impl ClientCore { if self.game.begin_spin().is_err() { return Vec::new(); } - self.pending = Some(Pending::Spin); - let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::Spin, request)); effects } @@ -322,24 +381,24 @@ impl ClientCore { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; - self.pending = Some(Pending::Login { user: user.clone() }); - vec![Effect::SendLine(format!("AUTH {user} {credential}"))] + vec![self.begin_request( + Pending::Login { user: user.clone() }, + format!("AUTH {user} {credential}"), + )] } Command::Register { user, credential } => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; - self.pending = Some(Pending::Register); - vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] + vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))] } Command::Games => vec![Effect::Print(game_lines(&self.games()))], Command::Ping => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } - self.pending = Some(Pending::Ping); - vec![Effect::SendLine("PING".to_string())] + vec![self.begin_request(Pending::Ping, "PING".to_string())] } Command::Echo(message) => { if !self.connected { @@ -348,8 +407,7 @@ impl ClientCore { if message.trim().is_empty() { return print_line("Usage: send "); } - self.pending = Some(Pending::Echo); - vec![Effect::SendLine(message)] + vec![self.begin_request(Pending::Echo, message)] } Command::Info => self.info_effects(), Command::Play { game, bet } => self.play_game(&game, bet), @@ -458,6 +516,7 @@ impl ClientCore { fn exit_game(&mut self) -> Vec { self.crash_started = None; + self.crash_started_ms = None; self.screen = Screen::Lobby; self.game.end_game(); vec![ @@ -475,19 +534,17 @@ impl ClientCore { return self.exit_game(); }; self.game.set_blackjack_status("forfeiting"); - self.pending = Some(Pending::BlackjackForfeit); - vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} forfeit", - hand.session_id - ))] + vec![self.begin_request( + Pending::BlackjackForfeit, + format!("BLACKJACK_ACTION {} forfeit", hand.session_id), + )] } fn forfeit_crash(&mut self) -> Vec { let Some(session) = self.game.crash_session_id() else { return self.exit_game(); }; - self.pending = Some(Pending::CrashForfeit); - vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] + vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))] } fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec { @@ -533,10 +590,8 @@ impl ClientCore { if self.game.begin_spin().is_err() { return Vec::new(); } - self.pending = Some(Pending::RouletteSpin); - let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::RouletteSpin, request)); effects } @@ -566,9 +621,8 @@ impl ClientCore { } self.game.set_blackjack_status("dealing"); - self.pending = Some(Pending::BlackjackStart); let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::BlackjackStart, request)); effects } @@ -582,15 +636,21 @@ impl ClientCore { if action == "double" && !hand.actions.iter().any(|available| available == "double") { return Vec::new(); } - self.pending = Some(Pending::BlackjackAction); - vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} {}", - hand.session_id, action - ))] + vec![self.begin_request( + Pending::BlackjackAction, + format!("BLACKJACK_ACTION {} {}", hand.session_id, action), + )] } /// Redraw the rising multiplier. Called by the terminal loop on a timer. pub fn tick(&mut self) -> Vec { + if self.clock_synced { + if let Some(started_ms) = self.crash_started_ms { + let now = unix_time_ms() as i64 + self.server_offset_ms; + let elapsed_ms = (now - started_ms as i64).max(0) as u64; + return self.tick_at(Duration::from_millis(elapsed_ms)); + } + } let Some(started) = self.crash_started else { return Vec::new(); }; @@ -619,12 +679,16 @@ impl ClientCore { } pub fn is_async(&self, line: &str) -> bool { - line.trim_start().starts_with("CRASH_BUSTED ") + let line = line.trim_start(); + line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ") } /// Handle a server-initiated line. Returns `None` when the line is a normal /// request/response reply and belongs to `handle_reply`. pub fn handle_async_line(&mut self, line: &str) -> Option> { + if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") { + return Some(self.apply_crash_tick(tick)); + } if !self.is_async(line) { return None; } @@ -643,13 +707,43 @@ impl ClientCore { if self.crash_started.is_none() { self.crash_started = Some(Instant::now()); } + if state.started_at > 0 { + self.crash_started_ms = Some(state.started_at); + } } else { self.crash_started = None; + self.crash_started_ms = None; } self.game.apply_crash_state(state); self.board_effects() } + /// Apply `crash.tick session= t= m=`. + fn apply_crash_tick(&mut self, tick: &str) -> Vec { + let mut session = None; + let mut multiplier = None; + for token in tick.split_whitespace() { + if let Some(value) = token.strip_prefix("session=") { + session = Some(value); + } else if let Some(value) = token.strip_prefix("m=") { + multiplier = value.parse::().ok(); + } + } + let Some(multiplier) = multiplier else { + return Vec::new(); + }; + if let Some(session) = session { + if self.game.crash_session_id().as_deref() != Some(session) { + return Vec::new(); + } + } + if self.game.crash_status() != "running" { + return Vec::new(); + } + self.game.set_crash_multiplier(multiplier); + self.board_effects() + } + fn start_crash(&mut self) -> Vec { let status = self.game.crash_status(); if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { @@ -671,9 +765,8 @@ impl ClientCore { } self.game.set_crash_status("starting"); - self.pending = Some(Pending::CrashStart); let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::CrashStart, request)); effects } @@ -681,8 +774,7 @@ impl ClientCore { let Some(session) = self.game.crash_session_id() else { return Vec::new(); }; - self.pending = Some(Pending::CrashCashout); - vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] + vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))] } fn play_game(&mut self, game_type: &str, bet: Option) -> Vec { @@ -706,8 +798,7 @@ impl ClientCore { } let mut effects = self.board_effects(); - self.pending = Some(Pending::Info); - effects.push(Effect::SendLine("INFO".to_string())); + effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } Err(_) => print_line("Failed to enter game"), @@ -726,13 +817,12 @@ impl ClientCore { } self.screen = Screen::History; - self.pending = Some(Pending::History); let request = if limit == crate::history::DEFAULT_HISTORY { "HISTORY".to_string() } else { format!("HISTORY {limit}") }; - vec![Effect::SendLine(request)] + vec![self.begin_request(Pending::History, request)] } fn info_effects(&mut self) -> Vec { @@ -754,8 +844,7 @@ impl ClientCore { ])), } - self.pending = Some(Pending::Info); - effects.push(Effect::SendLine("INFO".to_string())); + effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } @@ -809,7 +898,6 @@ impl ClientCore { } self.view().map(Effect::Render).into_iter().collect() } - } fn auth_result(reply: &str) -> Result<(), String> { -- cgit v1.2.3