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 ++++++++++++++++++++++++++++++++----------- src/client/games/manager.rs | 21 +++-- src/client/games/roulette.rs | 9 ++- src/client/transport.rs | 171 +++++++++++++++++++++++++--------------- src/client/view.rs | 60 ++++++++++---- 5 files changed, 310 insertions(+), 133 deletions(-) (limited to 'src/client') 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> { diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index 24ceeaa..d35308b 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -5,7 +5,7 @@ use super::roulette::RouletteGame; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; use crate::blackjack::BlackjackState; -use crate::crash::{payout, CrashState}; +use crate::crash::{CrashState, payout}; use crate::roulette::Bets; use std::collections::HashMap; @@ -244,9 +244,7 @@ impl GameManager { pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { if let Some(session) = self.current_session.as_mut() { - session - .data - .insert("cursor".to_string(), cursor.encoded()); + session.data.insert("cursor".to_string(), cursor.encoded()); } } @@ -339,7 +337,7 @@ impl GameManager { }; session.bet_amount = state.bet; session.data.remove("error"); - for key in ["crash_point", "payout"] { + for key in ["crash_point", "payout", "commit", "seed"] { session.data.remove(key); } session @@ -360,10 +358,21 @@ impl GameManager { session .data .insert("payout".to_string(), state.payout.to_string()); + session + .data + .insert("started_at".to_string(), state.started_at.to_string()); + if let Some(commit) = &state.commit { + session.data.insert("commit".to_string(), commit.clone()); + } + if let Some(seed) = &state.seed { + session.data.insert("seed".to_string(), seed.clone()); + } if let Some(crash_point) = state.crash_point { + // Keep full precision: the browser recomputes this from the + // revealed seed, and the GUI charts it. session .data - .insert("crash_point".to_string(), format!("{crash_point:.2}")); + .insert("crash_point".to_string(), crash_point.to_string()); } } diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index f0ed816..15cc6ed 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs @@ -1,6 +1,7 @@ use super::traits::{Game, GameState}; use crate::client::view::{RoulettePhase, RouletteView}; use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; +use serde::{Deserialize, Serialize}; pub const ROULETTE_BOARD_LINES: usize = 12; @@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [ pub struct RouletteGame; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Direction { Up, Down, @@ -41,7 +42,7 @@ pub enum Direction { /// odd,even line between rows split of the two numbers /// even,even corner of four cells corner /// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Cursor { Zero, Lattice { x: usize, y: usize }, @@ -67,7 +68,9 @@ impl Cursor { match parts.as_slice() { ["zero"] => Cursor::Zero, ["lattice", x, y] => match (x.parse::(), y.parse::()) { - (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { + (Ok(x), Ok(y)) + if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => + { Cursor::Lattice { x, y } } _ => Cursor::default(), diff --git a/src/client/transport.rs b/src/client/transport.rs index 97c9227..60e7ab6 100644 --- a/src/client/transport.rs +++ b/src/client/transport.rs @@ -7,20 +7,27 @@ use std::fmt; use std::io::{Read, Write}; use std::net::TcpStream; -use std::sync::mpsc; use std::sync::Arc; +use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; use parking_lot::Mutex; -use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -use rustls::DigitallySignedStruct; use rusqlite::Connection; +use rustls::DigitallySignedStruct; +use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use crate::client::database; -use crate::protocol::LineBuffer; -use crate::tofu::{decide, spki_fingerprint, TrustDecision}; -use crate::version::{parse_hello, HelloReply, VERSION}; +use crate::protocol::{ + FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame, +}; +use crate::tofu::{TrustDecision, decide, spki_fingerprint}; +use crate::version::{HelloReply, VERSION, parse_hello}; + +/// Reserved request ids for the transport handshake. Application requests +/// start at 1, so the two ranges cannot collide on one connection. +const HELLO_REQUEST_ID: u64 = u64::MAX; +const AUTH_REQUEST_ID: u64 = u64::MAX - 1; /// How long to wait for TCP + TLS before giving up. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -43,6 +50,8 @@ pub struct Connected { pub server_version: String, pub latest: String, pub url: String, + /// Server Unix time reported by `HELLO`, milliseconds. + pub server_time: u64, /// Round trip of the HELLO exchange. pub elapsed: Duration, } @@ -51,10 +60,7 @@ pub struct Connected { pub enum TransportError { Timeout, TrustDeclined, - KeyChanged { - expected: String, - observed: String, - }, + KeyChanged { expected: String, observed: String }, VersionMismatch(String), Closed, Unexpected(String), @@ -121,7 +127,7 @@ pub fn connect( return Err(TransportError::KeyChanged { expected, observed: actual, - }) + }); } } } @@ -131,12 +137,7 @@ pub fn connect( .map_err(io_failure)?; let start = Instant::now(); - tls.write_line(&format!("HELLO {VERSION}")) - .map_err(io_failure)?; - let mut response = String::new(); - if tls.read_line(&mut response).map_err(io_failure)? == 0 { - return Err(TransportError::Closed); - } + let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?; let elapsed = start.elapsed(); let response = response.trim(); @@ -151,10 +152,11 @@ pub fn connect( let mut user = None; let mut auth_error = None; if let Some((username, password)) = database::get_credentials(conn, &observed) { - tls.write_line(&format!("AUTH {username} {password}")) - .map_err(io_failure)?; - let mut auth = String::new(); - tls.read_line(&mut auth).map_err(io_failure)?; + let auth = exchange( + &mut tls, + AUTH_REQUEST_ID, + &format!("AUTH {username} {password}"), + )?; if auth.trim().starts_with("OK") { user = Some(username); } else { @@ -169,19 +171,31 @@ pub fn connect( server_version: hello.version, latest: hello.latest, url: hello.url, + server_time: hello.time, elapsed, }) } +/// Send one request and wait for the reply with the same id, ignoring events +/// that arrive in between. Used only for the handshake. +fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result { + stream + .write_request(id, None, payload) + .map_err(io_failure)?; + loop { + match stream.read_packet().map_err(io_failure)? { + None => return Err(TransportError::Closed), + Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body), + Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue, + } + } +} + /// TCP + TLS on a thread so a blocked handshake can time out. fn tls_connect(addr: &str) -> Result<(TlsStream, Option), TransportError> { let (sender, receiver) = mpsc::channel(); let addr = addr.to_string(); - let server_name = addr - .split(':') - .next() - .unwrap_or("127.0.0.1") - .to_string(); + let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string(); thread::spawn(move || { let outcome = (|| -> Result<(TlsStream, Option), TransportError> { @@ -241,7 +255,7 @@ impl TlsConnector { session: Arc::new(Mutex::new(Session { sock, tls: connection, - lines: LineBuffer::new(), + frames: FrameBuffer::new(), read_timeout: Some(READ_TIMEOUT), })), }) @@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier { _ocsp_response: &[u8], _now: std::time::SystemTime, ) -> Result { - let fingerprint = spki_fingerprint(&end_entity.0) - .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; + let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| { + rustls::Error::General(format!("certificate parse failed: {error}")) + })?; *self.observed.lock() = Some(fingerprint); Ok(ServerCertVerified::assertion()) } @@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier { struct Session { sock: TcpStream, tls: rustls::ClientConnection, - lines: LineBuffer, + frames: FrameBuffer, read_timeout: Option, } @@ -329,37 +344,58 @@ pub struct TlsStream { } impl TlsStream { - pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { + /// Write one enveloped request. Application code allocates `id`. + pub fn write_request( + &mut self, + id: u64, + idem: Option<&str>, + payload: &str, + ) -> std::io::Result<()> { + self.write_frame(&request_line(id, idem, payload)) + } + + /// Write one raw frame payload. + pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> { + let mut s = self.session.lock(); + write_protocol_frame(&mut s.tls.writer(), payload)?; + s.drive() + } + + /// Block until one whole frame arrives. + pub fn read_frame(&mut self) -> std::io::Result> { let mut s = self.session.lock(); loop { - if let Some(line) = s.lines.next_line() { - buf.push_str(&line); - buf.push('\n'); - return Ok(line.len() + 1); + match s.frames.next_frame() { + Some(Ok(frame)) => return Ok(Some(frame)), + Some(Err(error)) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + None => {} } let mut chunk = [0u8; 4096]; match s.tls.reader().read(&mut chunk) { - Ok(0) => { - if let Some(rest) = s.lines.take_rest() { - let read = rest.len(); - buf.push_str(&rest); - return Ok(read); - } - return Ok(0); - } - Ok(n) => s.lines.push(&chunk[..n]), + Ok(0) => return Ok(None), + Ok(n) => s.frames.push(&chunk[..n]), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, Err(error) => return Err(error), } } } - pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { - let mut s = self.session.lock(); - s.tls.writer().write_all(message.as_bytes())?; - s.tls.writer().write_all(b"\n")?; - s.drive() + /// Block until one `RES` or `EVT` arrives. + pub fn read_packet(&mut self) -> std::io::Result> { + loop { + let Some(frame) = self.read_frame()? else { + return Ok(None); + }; + match parse_packet(&frame) { + Ok(packet) => return Ok(Some(packet)), + Err(error) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + } + } } pub fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { @@ -368,24 +404,36 @@ impl TlsStream { s.sock.set_read_timeout(duration) } - /// Return one complete line if the socket already has one, without - /// blocking. Used to pick up server pushes between requests. - pub fn read_available_line(&mut self) -> std::io::Result> { + /// Return one packet if the socket already has one, without blocking. + /// Used to pick up server pushes between requests. + pub fn read_available_packet(&mut self) -> std::io::Result> { let mut s = self.session.lock(); - if let Some(line) = s.lines.next_line() { - return Ok(Some(line)); - } // `set_read_timeout(Some(0))` is rejected by std, so poll with the // shortest timeout the socket accepts instead of a nonblocking mode. s.sock .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; - let drained = (|| -> std::io::Result<()> { + let drained = (|| -> std::io::Result> { loop { + match s.frames.next_frame() { + Some(Ok(frame)) => { + return match parse_packet(&frame) { + Ok(packet) => Ok(Some(packet)), + Err(error) => { + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + } + }; + } + Some(Err(error)) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + None => {} + } + let mut buf = [0u8; 4096]; match s.tls.reader().read(&mut buf) { - Ok(0) => return Ok(()), - Ok(n) => s.lines.push(&buf[..n]), + Ok(0) => return Ok(None), + Ok(n) => s.frames.push(&buf[..n]), // The reader only drains plaintext rustls has already // decrypted; `drive` moves bytes off the socket. Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { @@ -397,7 +445,7 @@ impl TlsStream { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { - return Ok(()); + return Ok(None); } Err(error) => return Err(error), } @@ -408,15 +456,14 @@ impl TlsStream { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { - return Ok(()); + return Ok(None); } Err(error) => return Err(error), } } })(); s.sock.set_read_timeout(s.read_timeout)?; - drained?; - Ok(s.lines.next_line()) + drained } } diff --git a/src/client/view.rs b/src/client/view.rs index 4615720..0886d64 100644 --- a/src/client/view.rs +++ b/src/client/view.rs @@ -7,15 +7,17 @@ use crate::blackjack::BlackjackState; use crate::client::games::roulette::{Cursor, Direction}; use crate::client::games::traits::{GamePhase, GameState}; use crate::roulette::{BetTarget, Bets}; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum RoulettePhase { Placing, Spinning, Result, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RouletteView { pub bet_amount: u64, pub balance: Option, @@ -51,7 +53,10 @@ impl RouletteView { Some("result") => RoulettePhase::Result, _ => RoulettePhase::Placing, }; - let number = state.data.get("number").and_then(|value| value.parse().ok()); + let number = state + .data + .get("number") + .and_then(|value| value.parse().ok()); let color = state.data.get("color").cloned(); let winnings = state .data @@ -82,14 +87,15 @@ impl RouletteView { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum SlotsPhase { Ready, Spinning, Result, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SlotsView { pub bet: u64, pub balance: Option, @@ -149,7 +155,7 @@ impl SlotsView { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BlackjackView { pub status: String, pub balance: Option, @@ -186,7 +192,7 @@ impl BlackjackView { } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CrashView { pub status: String, pub balance: Option, @@ -195,6 +201,11 @@ pub struct CrashView { pub potential: u64, pub crash_point: Option, pub payout: u64, + pub started_at: u64, + /// `sha256(seed)` for this round, before it ends. + pub commit: Option, + /// The revealed seed, after the round ends. + pub seed: Option, pub error: Option, } @@ -232,6 +243,13 @@ impl CrashView { .get("payout") .and_then(|value| value.parse::().ok()) .unwrap_or(0), + started_at: state + .data + .get("started_at") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + commit: state.data.get("commit").cloned(), + seed: state.data.get("seed").cloned(), error: state .data .get("error") @@ -242,7 +260,7 @@ impl CrashView { } /// The lobby: who is logged in, the balance, and what can be played. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LobbyView { pub connected: bool, pub user: Option, @@ -254,13 +272,14 @@ pub struct LobbyView { } /// The play log, already parsed. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct HistoryView { pub records: Vec, } /// Everything a frontend can draw. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "screen", rename_all = "snake_case")] pub enum View { Lobby(LobbyView), History(HistoryView), @@ -271,17 +290,28 @@ pub enum View { } /// Semantic input. Frontends turn keys and clicks into these. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Command { /// The credential is already hashed by the frontend. - Login { user: String, credential: String }, + Login { + user: String, + credential: String, + }, Games, Ping, Echo(String), Info, - Register { user: String, credential: String }, - Play { game: String, bet: Option }, - History { limit: u32 }, + Register { + user: String, + credential: String, + }, + Play { + game: String, + bet: Option, + }, + History { + limit: u32, + }, ShowLobby, Logout, SetBet(u64), -- cgit v1.2.3