summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /src
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
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.
Diffstat (limited to '')
-rw-r--r--src/bin/client/connection.rs6
-rw-r--r--src/bin/client/repl.rs95
-rw-r--r--src/bin/server/handler.rs47
-rw-r--r--src/bin/server/tls.rs27
-rw-r--r--src/client/core.rs182
-rw-r--r--src/client/games/manager.rs21
-rw-r--r--src/client/games/roulette.rs9
-rw-r--r--src/client/transport.rs171
-rw-r--r--src/client/view.rs60
-rw-r--r--src/crash.rs54
-rw-r--r--src/protocol.rs226
-rw-r--r--src/server/core.rs81
-rw-r--r--src/server/games/crash.rs76
-rw-r--r--src/version.rs34
14 files changed, 829 insertions, 260 deletions
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index f88f62b..e808d90 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -8,7 +8,7 @@ use parking_lot::Mutex;
8use std::error::Error; 8use std::error::Error;
9use zagan_bet::client::transport::{self, TrustPrompt}; 9use zagan_bet::client::transport::{self, TrustPrompt};
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, VERSION}; 11use zagan_bet::version::{VERSION, is_newer};
12 12
13pub use zagan_bet::client::transport::TlsStream; 13pub use zagan_bet::client::transport::TlsStream;
14 14
@@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) {
21pub fn connect( 21pub fn connect(
22 addr: &str, 22 addr: &str,
23 conn: &rusqlite::Connection, 23 conn: &rusqlite::Connection,
24) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { 24) -> Result<(TlsStream, Option<String>, u64), Box<dyn Error>> {
25 let unsafe_mode = *UNSAFE_MODE.lock(); 25 let unsafe_mode = *UNSAFE_MODE.lock();
26 26
27 // The terminal has no dialog: print the first-contact notice and trust. 27 // The terminal has no dialog: print the first-contact notice and trust.
@@ -53,5 +53,5 @@ pub fn connect(
53 println!("Authentication failed: {error}"); 53 println!("Authentication failed: {error}");
54 } 54 }
55 55
56 Ok((connected.stream, connected.user)) 56 Ok((connected.stream, connected.user, connected.server_time))
57} 57}
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index cfdcdd5..deb5743 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -14,15 +14,15 @@ use crossterm::{
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect}; 16use zagan_bet::client::core::{ClientCore, Effect};
17use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
18use zagan_bet::client::shell::{self, Shell};
19use zagan_bet::client::view::{RoulettePhase, View};
20use zagan_bet::client::database; 17use zagan_bet::client::database;
18use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input};
19use zagan_bet::client::shell::{self, Shell};
21use zagan_bet::client::ui::{ 20use zagan_bet::client::ui::{
22 display_width, format_pong, help_lines, host_lines, input_cursor_column, 21 MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column,
23 lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, 22 lines_to_move_up, terminal_size_warning, visual_line_count,
24}; 23};
25use zagan_bet::protocol::io_failure_message; 24use zagan_bet::client::view::{RoulettePhase, View};
25use zagan_bet::protocol::{Packet, io_failure_message};
26use zagan_bet::roulette::{WHEEL, color as roulette_color}; 26use zagan_bet::roulette::{WHEEL, color as roulette_color};
27 27
28use crate::connection::TlsStream; 28use crate::connection::TlsStream;
@@ -78,9 +78,10 @@ impl ClientState {
78 pub fn connect_to(&mut self, addr: &str) { 78 pub fn connect_to(&mut self, addr: &str) {
79 self.addr = Some(addr.to_string()); 79 self.addr = Some(addr.to_string());
80 match crate::connection::connect(addr, &self.conn) { 80 match crate::connection::connect(addr, &self.conn) {
81 Ok((stream, authed_user)) => { 81 Ok((stream, authed_user, server_time)) => {
82 self.stream = Some(stream); 82 self.stream = Some(stream);
83 self.core.set_connected(true); 83 self.core.set_connected(true);
84 self.core.set_server_time(server_time);
84 self.core.set_logged_in(authed_user); 85 self.core.set_logged_in(authed_user);
85 let fingerprint = database::get_fingerprint(&self.conn, addr); 86 let fingerprint = database::get_fingerprint(&self.conn, addr);
86 self.core.set_host_info(Some(addr.to_string()), fingerprint); 87 self.core.set_host_info(Some(addr.to_string()), fingerprint);
@@ -126,9 +127,7 @@ impl ClientState {
126 key, 127 key,
127 ) { 128 ) {
128 GameInput::Quit => vec![Effect::Quit], 129 GameInput::Quit => vec![Effect::Quit],
129 GameInput::Command(command) => { 130 GameInput::Command(command) => self.core.command(command),
130 self.core.command(command)
131 }
132 GameInput::None => Vec::new(), 131 GameInput::None => Vec::new(),
133 } 132 }
134 } else { 133 } else {
@@ -189,10 +188,10 @@ impl ClientState {
189 let Some(mut stream) = self.stream.take() else { 188 let Some(mut stream) = self.stream.take() else {
190 return false; 189 return false;
191 }; 190 };
192 let line = match stream.read_available_line() { 191 let packet = match stream.read_available_packet() {
193 Ok(line) => { 192 Ok(packet) => {
194 self.stream = Some(stream); 193 self.stream = Some(stream);
195 line 194 packet
196 } 195 }
197 Err(_) => { 196 Err(_) => {
198 self.core.set_connected(false); 197 self.core.set_connected(false);
@@ -201,13 +200,15 @@ impl ClientState {
201 } 200 }
202 }; 201 };
203 202
204 let Some(line) = line else { 203 let Some(packet) = packet else {
205 return false; 204 return false;
206 }; 205 };
207 206
208 let effects = match self.core.handle_async_line(&line) { 207 let effects = match packet {
209 Some(effects) => effects, 208 Packet::Event { body, .. } => {
210 None => self.core.handle_reply(&line), 209 self.core.handle_async_line(&body).unwrap_or_default()
210 }
211 Packet::Reply { id, body } => self.core.handle_reply_id(id, &body),
211 }; 212 };
212 if self.apply_effects(effects) { 213 if self.apply_effects(effects) {
213 return true; 214 return true;
@@ -259,13 +260,13 @@ impl ClientState {
259 self.draw_region(&lines); 260 self.draw_region(&lines);
260 } 261 }
261 Effect::ClearRegion => self.clear_region(), 262 Effect::ClearRegion => self.clear_region(),
262 Effect::SendLine(line) => { 263 Effect::SendRequest { id, payload } => {
263 let is_ping = line == "PING"; 264 let is_ping = payload == "PING";
264 let started = Instant::now(); 265 let started = Instant::now();
265 let reply = self.send_and_read(&line); 266 let reply = self.send_and_read(id, &payload);
266 let elapsed = started.elapsed(); 267 let elapsed = started.elapsed();
267 let mut more = self.core.handle_reply(&reply); 268 let mut more = self.core.handle_reply_id(id, &reply);
268 if is_ping && reply.trim() == "PONG" { 269 if is_ping && reply.trim().starts_with("PONG") {
269 for effect in &mut more { 270 for effect in &mut more {
270 if let Effect::Print(lines) = effect { 271 if let Effect::Print(lines) = effect {
271 for text in lines { 272 for text in lines {
@@ -488,31 +489,30 @@ impl ClientState {
488 self.prompt_text = text.to_string(); 489 self.prompt_text = text.to_string();
489 } 490 }
490 491
491 fn send_and_read(&mut self, line: &str) -> String { 492 fn send_and_read(&mut self, id: u64, line: &str) -> String {
492 let Some(mut stream) = self.stream.take() else { 493 let Some(mut stream) = self.stream.take() else {
493 return "ERROR Not connected".to_string(); 494 return "ERROR Not connected".to_string();
494 }; 495 };
495 496
496 // A crash event can be pushed while a request is in flight. Route those 497 // A crash event can be pushed while a request is in flight. Route those
497 // lines as they arrive and keep waiting for the actual reply. 498 // events as they arrive and keep waiting for the reply with this id.
498 let mut pushed: Vec<String> = Vec::new(); 499 let mut pushed: Vec<String> = Vec::new();
499 let result = (|| -> std::io::Result<String> { 500 let result = (|| -> std::io::Result<String> {
500 stream.write_line(line)?; 501 stream.write_request(id, None, line)?;
501 loop { 502 loop {
502 let mut response = String::new(); 503 match stream.read_packet()? {
503 let read = stream.read_line(&mut response)?; 504 None => {
504 if read == 0 { 505 return Err(std::io::Error::new(
505 return Err(std::io::Error::new( 506 std::io::ErrorKind::UnexpectedEof,
506 std::io::ErrorKind::UnexpectedEof, 507 "connection closed",
507 "connection closed", 508 ));
508 )); 509 }
509 } 510 Some(Packet::Event { body, .. }) => pushed.push(body),
510 let response = response.trim_end().to_string(); 511 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => {
511 if self.core.is_async(&response) { 512 return Ok(body);
512 pushed.push(response); 513 }
513 continue; 514 Some(Packet::Reply { .. }) => continue,
514 } 515 }
515 return Ok(response);
516 } 516 }
517 })(); 517 })();
518 518
@@ -614,7 +614,11 @@ impl ClientState {
614 short_fingerprint(&identity), 614 short_fingerprint(&identity),
615 host 615 host
616 ), 616 ),
617 Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), 617 Ok(false) => println!(
618 "No saved login for server {} ({}).",
619 short_fingerprint(&identity),
620 host
621 ),
618 Err(error) => println!("Failed to forget the saved login: {}", error), 622 Err(error) => println!("Failed to forget the saved login: {}", error),
619 } 623 }
620 } 624 }
@@ -625,10 +629,9 @@ impl ClientState {
625 return; 629 return;
626 }; 630 };
627 match database::forget_host(&self.conn, &target) { 631 match database::forget_host(&self.conn, &target) {
628 Ok(outcome) if outcome.credentials_removed => println!( 632 Ok(outcome) if outcome.credentials_removed => {
629 "Cleared trust and stored credentials for {}.", 633 println!("Cleared trust and stored credentials for {}.", target)
630 target 634 }
631 ),
632 Ok(outcome) => { 635 Ok(outcome) => {
633 println!("Cleared trust for {}.", target); 636 println!("Cleared trust for {}.", target);
634 if !outcome.siblings.is_empty() { 637 if !outcome.siblings.is_empty() {
@@ -665,7 +668,8 @@ impl ClientState {
665 668
666 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 669 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
667 let message = format!("AUTH {} {}", username, hashed); 670 let message = format!("AUTH {} {}", username, hashed);
668 let response = self.send_and_read(&message); 671 let id = self.core.allocate_request_id();
672 let response = self.send_and_read(id, &message);
669 let response = response.trim(); 673 let response = response.trim();
670 674
671 if response.starts_with("OK") { 675 if response.starts_with("OK") {
@@ -709,7 +713,8 @@ impl ClientState {
709 713
710 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 714 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
711 let message = format!("REGISTER {} {}", username, hashed); 715 let message = format!("REGISTER {} {}", username, hashed);
712 let response = self.send_and_read(&message); 716 let id = self.core.allocate_request_id();
717 let response = self.send_and_read(id, &message);
713 let response = response.trim(); 718 let response = response.trim();
714 719
715 if response == format!("REGISTERED {}", username) { 720 if response == format!("REGISTERED {}", username) {
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs
index dffae69..e6b623f 100644
--- a/src/bin/server/handler.rs
+++ b/src/bin/server/handler.rs
@@ -1,6 +1,7 @@
1use std::time::Duration; 1use std::time::Duration;
2 2
3use crate::tls::TlsStream; 3use crate::tls::TlsStream;
4use zagan_bet::protocol::{event_line, parse_request, reply_line};
4use zagan_bet::server::core::{ConnectionState, ServerCore}; 5use zagan_bet::server::core::{ConnectionState, ServerCore};
5 6
6/// How long the connection waits for a request before advancing timed games. 7/// How long the connection waits for a request before advancing timed games.
@@ -30,24 +31,35 @@ pub fn handle_client_tls(mut stream: TlsStream) {
30 } 31 }
31 32
32 let mut state = ConnectionState::default(); 33 let mut state = ConnectionState::default();
34 let mut event_seq = 0u64;
33 println!("[TLS Client connected] {client_addr}"); 35 println!("[TLS Client connected] {client_addr}");
34 36
35 'connection: loop { 37 'connection: loop {
36 let mut line = String::new(); 38 let mut frame = String::new();
37 match stream.read_line(&mut line) { 39 match stream.read_frame(&mut frame) {
38 Ok(0) => break, 40 Ok(0) => break,
39 Ok(_) => { 41 Ok(_) => {
40 let line = line.trim(); 42 let user = state.user().unwrap_or("guest").to_string();
41 if !line.is_empty() { 43 println!("[{user} @ {client_addr}] Received: {frame}");
42 let user = state.user().unwrap_or("guest").to_string(); 44 match parse_request(&frame) {
43 println!("[{user} @ {client_addr}] Received: {line}"); 45 Ok(request) => {
44 let response = core.handle_message(&mut state, line); 46 let response = core.handle_request(&mut state, &request);
45 if let Err(error) = stream.write_line(&response) { 47 let reply = reply_line(request.id, &response);
46 eprintln!("Write error: {error}"); 48 if let Err(error) = stream.write_frame(&reply) {
47 break; 49 eprintln!("Write error: {error}");
50 break;
51 }
52 let user = state.user().unwrap_or("guest");
53 println!("[{user} @ {client_addr}] Sent: {reply}");
54 }
55 Err(message) => {
56 let reply = reply_line(0, &format!("ERROR {message}"));
57 if let Err(error) = stream.write_frame(&reply) {
58 eprintln!("Write error: {error}");
59 break;
60 }
61 eprintln!("Malformed request from {client_addr}: {reply}");
48 } 62 }
49 let user = state.user().unwrap_or("guest");
50 println!("[{user} @ {client_addr}] Sent: {response}");
51 } 63 }
52 } 64 }
53 Err(error) 65 Err(error)
@@ -65,12 +77,17 @@ pub fn handle_client_tls(mut stream: TlsStream) {
65 } 77 }
66 78
67 for push in core.poll(&state) { 79 for push in core.poll(&state) {
68 if let Err(error) = stream.write_line(&push) { 80 event_seq = event_seq.wrapping_add(1);
81 let event = event_line(event_seq, &push);
82 if let Err(error) = stream.write_frame(&event) {
69 eprintln!("Write error: {error}"); 83 eprintln!("Write error: {error}");
70 break 'connection; 84 break 'connection;
71 } 85 }
72 let user = state.user().unwrap_or("guest"); 86 // Ticks are 10 Hz per live round; logging them floods the disk.
73 println!("[{user} @ {client_addr}] Pushed: {push}"); 87 if !push.starts_with("crash.tick ") {
88 let user = state.user().unwrap_or("guest");
89 println!("[{user} @ {client_addr}] Pushed: {event}");
90 }
74 } 91 }
75 } 92 }
76 93
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs
index 420b5b4..eb94637 100644
--- a/src/bin/server/tls.rs
+++ b/src/bin/server/tls.rs
@@ -1,7 +1,7 @@
1use std::io::{Read, Write}; 1use std::io::{Read, Write};
2use std::net::TcpStream; 2use std::net::TcpStream;
3 3
4use zagan_bet::protocol::LineBuffer; 4use zagan_bet::protocol::{FrameBuffer, write_frame};
5 5
6#[derive(Clone)] 6#[derive(Clone)]
7pub struct TlsServer { 7pub struct TlsServer {
@@ -44,34 +44,31 @@ impl TlsServer {
44 44
45pub struct TlsStream { 45pub struct TlsStream {
46 stream: native_tls::TlsStream<TcpStream>, 46 stream: native_tls::TlsStream<TcpStream>,
47 lines: LineBuffer, 47 frames: FrameBuffer,
48} 48}
49 49
50impl TlsStream { 50impl TlsStream {
51 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { 51 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self {
52 Self { 52 Self {
53 stream, 53 stream,
54 lines: LineBuffer::new(), 54 frames: FrameBuffer::new(),
55 } 55 }
56 } 56 }
57 57
58 pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { 58 /// Read one protocol frame. `Ok(0)` is EOF with no partial frame.
59 match self.lines.read_line(&mut self.stream)? { 59 pub fn read_frame(&mut self, out: &mut String) -> std::io::Result<usize> {
60 Some(line) => { 60 match self.frames.read_frame(&mut self.stream)? {
61 s.push_str(&line); 61 Some(frame) => {
62 s.push('\n'); 62 let length = frame.len();
63 Ok(line.len() + 1) 63 out.push_str(&frame);
64 Ok(length)
64 } 65 }
65 None => Ok(0), 66 None => Ok(0),
66 } 67 }
67 } 68 }
68 69
69 pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { 70 pub fn write_frame(&mut self, msg: &str) -> std::io::Result<()> {
70 use std::io::Write; 71 write_frame(&mut self.stream, msg)
71 self.stream.write_all(msg.as_bytes())?;
72 self.stream.write_all(b"\n")?;
73 self.stream.flush()?;
74 Ok(())
75 } 72 }
76 73
77 pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { 74 pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> {
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::{
3 parse_blackjack_response, parse_crash_response, parse_history_response, 3 parse_blackjack_response, parse_crash_response, parse_history_response,
4 parse_roulette_response, parse_spin_response, 4 parse_roulette_response, parse_spin_response,
5}; 5};
6use crate::client::ui::{game_lines, history_lines};
6use crate::client::view::{ 7use crate::client::view::{
7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 8 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
8}; 9};
9use crate::client::ui::{game_lines, history_lines};
10use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
11use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{MAX_HISTORY, PlayRecord};
12use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
13use crate::version::{parse_pong, unix_time_ms};
13use std::time::{Duration, Instant}; 14use std::time::{Duration, Instant};
14 15
15
16
17/// High-level actions produced by the client core. The terminal adapter is 16/// High-level actions produced by the client core. The terminal adapter is
18/// responsible for turning these into crossterm calls and network writes. 17/// responsible for turning these into crossterm calls and network writes.
19#[derive(Debug, Clone, PartialEq)] 18#[derive(Debug, Clone, PartialEq)]
@@ -24,8 +23,8 @@ pub enum Effect {
24 Render(View), 23 Render(View),
25 /// Remove the game board region. 24 /// Remove the game board region.
26 ClearRegion, 25 ClearRegion,
27 /// Send one protocol line and feed the reply to `handle_reply`. 26 /// Send one enveloped request and route its reply back by `id`.
28 SendLine(String), 27 SendRequest { id: u64, payload: String },
29 /// Drop the connection. 28 /// Drop the connection.
30 Disconnect, 29 Disconnect,
31 /// Leave the program. 30 /// Leave the program.
@@ -61,6 +60,10 @@ pub struct ClientCore {
61 connected: bool, 60 connected: bool,
62 logged_in: Option<String>, 61 logged_in: Option<String>,
63 pending: Option<Pending>, 62 pending: Option<Pending>,
63 /// Id of the request whose reply fills `pending`.
64 pending_id: Option<u64>,
65 /// Next correlation id allocated on this connection.
66 next_request_id: u64,
64 balance: Option<i64>, 67 balance: Option<i64>,
65 /// Account number from the last `INFO`, shown by both frontends. 68 /// Account number from the last `INFO`, shown by both frontends.
66 account: Option<String>, 69 account: Option<String>,
@@ -68,6 +71,13 @@ pub struct ClientCore {
68 fingerprint: Option<String>, 71 fingerprint: Option<String>,
69 /// Local anchor for the crash multiplier, set when a round starts. 72 /// Local anchor for the crash multiplier, set when a round starts.
70 crash_started: Option<Instant>, 73 crash_started: Option<Instant>,
74 /// Server-time anchor for the crash multiplier, when the server supplied
75 /// `started_at`.
76 crash_started_ms: Option<u64>,
77 /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`.
78 server_offset_ms: i64,
79 /// True once a frontend has supplied a server timestamp.
80 clock_synced: bool,
71 /// App screen shown when no game is open. 81 /// App screen shown when no game is open.
72 screen: Screen, 82 screen: Screen,
73 /// Last plays fetched with `history`. 83 /// Last plays fetched with `history`.
@@ -83,11 +93,16 @@ impl ClientCore {
83 connected: false, 93 connected: false,
84 logged_in: None, 94 logged_in: None,
85 pending: None, 95 pending: None,
96 pending_id: None,
97 next_request_id: 1,
86 balance: None, 98 balance: None,
87 account: None, 99 account: None,
88 host: None, 100 host: None,
89 fingerprint: None, 101 fingerprint: None,
90 crash_started: None, 102 crash_started: None,
103 crash_started_ms: None,
104 server_offset_ms: 0,
105 clock_synced: false,
91 screen: Screen::Lobby, 106 screen: Screen::Lobby,
92 play_history: Vec::new(), 107 play_history: Vec::new(),
93 login_error: None, 108 login_error: None,
@@ -99,11 +114,19 @@ impl ClientCore {
99 if !connected { 114 if !connected {
100 self.logged_in = None; 115 self.logged_in = None;
101 self.pending = None; 116 self.pending = None;
117 self.pending_id = None;
102 self.crash_started = None; 118 self.crash_started = None;
119 self.crash_started_ms = None;
120 self.clock_synced = false;
103 self.screen = Screen::Lobby; 121 self.screen = Screen::Lobby;
104 } 122 }
105 } 123 }
106 124
125 /// The signed-in username, if any.
126 pub fn user(&self) -> Option<&str> {
127 self.logged_in.as_deref()
128 }
129
107 pub fn set_logged_in(&mut self, user: Option<String>) { 130 pub fn set_logged_in(&mut self, user: Option<String>) {
108 if user.is_some() { 131 if user.is_some() {
109 self.login_error = None; 132 self.login_error = None;
@@ -147,7 +170,44 @@ impl ClientCore {
147 self.board_effects() 170 self.board_effects()
148 } 171 }
149 172
173 /// Record the server's clock and the local time it was received. The
174 /// offset is for display; cash-out still settles on the server.
175 pub fn set_server_time(&mut self, server_ms: u64) {
176 self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64;
177 self.clock_synced = true;
178 }
179
180 /// The game server's clock, as this client currently estimates it.
181 pub fn server_now_ms(&self) -> u64 {
182 (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64
183 }
184
185 /// Allocate a correlation id for a request the adapter sends directly
186 /// (the terminal's login prompt bypasses `ClientCore`).
187 pub fn allocate_request_id(&mut self) -> u64 {
188 let id = self.next_request_id;
189 self.next_request_id = self.next_request_id.wrapping_add(1).max(1);
190 id
191 }
192
193 fn begin_request(&mut self, pending: Pending, payload: String) -> Effect {
194 let id = self.allocate_request_id();
195 self.pending = Some(pending);
196 self.pending_id = Some(id);
197 Effect::SendRequest { id, payload }
198 }
199
200 /// Route a reply by correlation id. A reply we no longer expect (a late
201 /// response after a timeout) is ignored instead of corrupting the board.
202 pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec<Effect> {
203 if self.pending_id != Some(id) {
204 return Vec::new();
205 }
206 self.handle_reply(reply)
207 }
208
150 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { 209 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> {
210 self.pending_id = None;
151 match self.pending.take() { 211 match self.pending.take() {
152 Some(Pending::Spin) => match parse_spin_response(reply) { 212 Some(Pending::Spin) => match parse_spin_response(reply) {
153 Ok(result) => { 213 Ok(result) => {
@@ -265,7 +325,8 @@ impl ClientCore {
265 } 325 }
266 }, 326 },
267 Some(Pending::Ping) => { 327 Some(Pending::Ping) => {
268 if reply.trim() == "PONG" { 328 if let Some(server_ms) = parse_pong(reply) {
329 self.set_server_time(server_ms);
269 print_line("Pong") 330 print_line("Pong")
270 } else { 331 } else {
271 self.connected = false; 332 self.connected = false;
@@ -305,10 +366,8 @@ impl ClientCore {
305 if self.game.begin_spin().is_err() { 366 if self.game.begin_spin().is_err() {
306 return Vec::new(); 367 return Vec::new();
307 } 368 }
308 self.pending = Some(Pending::Spin);
309
310 let mut effects = self.board_effects(); 369 let mut effects = self.board_effects();
311 effects.push(Effect::SendLine(request)); 370 effects.push(self.begin_request(Pending::Spin, request));
312 effects 371 effects
313 } 372 }
314 373
@@ -322,24 +381,24 @@ impl ClientCore {
322 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 381 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
323 } 382 }
324 self.login_error = None; 383 self.login_error = None;
325 self.pending = Some(Pending::Login { user: user.clone() }); 384 vec![self.begin_request(
326 vec![Effect::SendLine(format!("AUTH {user} {credential}"))] 385 Pending::Login { user: user.clone() },
386 format!("AUTH {user} {credential}"),
387 )]
327 } 388 }
328 Command::Register { user, credential } => { 389 Command::Register { user, credential } => {
329 if !self.connected { 390 if !self.connected {
330 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 391 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
331 } 392 }
332 self.login_error = None; 393 self.login_error = None;
333 self.pending = Some(Pending::Register); 394 vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))]
334 vec![Effect::SendLine(format!("REGISTER {user} {credential}"))]
335 } 395 }
336 Command::Games => vec![Effect::Print(game_lines(&self.games()))], 396 Command::Games => vec![Effect::Print(game_lines(&self.games()))],
337 Command::Ping => { 397 Command::Ping => {
338 if !self.connected { 398 if !self.connected {
339 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 399 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
340 } 400 }
341 self.pending = Some(Pending::Ping); 401 vec![self.begin_request(Pending::Ping, "PING".to_string())]
342 vec![Effect::SendLine("PING".to_string())]
343 } 402 }
344 Command::Echo(message) => { 403 Command::Echo(message) => {
345 if !self.connected { 404 if !self.connected {
@@ -348,8 +407,7 @@ impl ClientCore {
348 if message.trim().is_empty() { 407 if message.trim().is_empty() {
349 return print_line("Usage: send <message>"); 408 return print_line("Usage: send <message>");
350 } 409 }
351 self.pending = Some(Pending::Echo); 410 vec![self.begin_request(Pending::Echo, message)]
352 vec![Effect::SendLine(message)]
353 } 411 }
354 Command::Info => self.info_effects(), 412 Command::Info => self.info_effects(),
355 Command::Play { game, bet } => self.play_game(&game, bet), 413 Command::Play { game, bet } => self.play_game(&game, bet),
@@ -458,6 +516,7 @@ impl ClientCore {
458 516
459 fn exit_game(&mut self) -> Vec<Effect> { 517 fn exit_game(&mut self) -> Vec<Effect> {
460 self.crash_started = None; 518 self.crash_started = None;
519 self.crash_started_ms = None;
461 self.screen = Screen::Lobby; 520 self.screen = Screen::Lobby;
462 self.game.end_game(); 521 self.game.end_game();
463 vec![ 522 vec![
@@ -475,19 +534,17 @@ impl ClientCore {
475 return self.exit_game(); 534 return self.exit_game();
476 }; 535 };
477 self.game.set_blackjack_status("forfeiting"); 536 self.game.set_blackjack_status("forfeiting");
478 self.pending = Some(Pending::BlackjackForfeit); 537 vec![self.begin_request(
479 vec![Effect::SendLine(format!( 538 Pending::BlackjackForfeit,
480 "BLACKJACK_ACTION {} forfeit", 539 format!("BLACKJACK_ACTION {} forfeit", hand.session_id),
481 hand.session_id 540 )]
482 ))]
483 } 541 }
484 542
485 fn forfeit_crash(&mut self) -> Vec<Effect> { 543 fn forfeit_crash(&mut self) -> Vec<Effect> {
486 let Some(session) = self.game.crash_session_id() else { 544 let Some(session) = self.game.crash_session_id() else {
487 return self.exit_game(); 545 return self.exit_game();
488 }; 546 };
489 self.pending = Some(Pending::CrashForfeit); 547 vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))]
490 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
491 } 548 }
492 549
493 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { 550 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> {
@@ -533,10 +590,8 @@ impl ClientCore {
533 if self.game.begin_spin().is_err() { 590 if self.game.begin_spin().is_err() {
534 return Vec::new(); 591 return Vec::new();
535 } 592 }
536 self.pending = Some(Pending::RouletteSpin);
537
538 let mut effects = self.board_effects(); 593 let mut effects = self.board_effects();
539 effects.push(Effect::SendLine(request)); 594 effects.push(self.begin_request(Pending::RouletteSpin, request));
540 effects 595 effects
541 } 596 }
542 597
@@ -566,9 +621,8 @@ impl ClientCore {
566 } 621 }
567 622
568 self.game.set_blackjack_status("dealing"); 623 self.game.set_blackjack_status("dealing");
569 self.pending = Some(Pending::BlackjackStart);
570 let mut effects = self.board_effects(); 624 let mut effects = self.board_effects();
571 effects.push(Effect::SendLine(request)); 625 effects.push(self.begin_request(Pending::BlackjackStart, request));
572 effects 626 effects
573 } 627 }
574 628
@@ -582,15 +636,21 @@ impl ClientCore {
582 if action == "double" && !hand.actions.iter().any(|available| available == "double") { 636 if action == "double" && !hand.actions.iter().any(|available| available == "double") {
583 return Vec::new(); 637 return Vec::new();
584 } 638 }
585 self.pending = Some(Pending::BlackjackAction); 639 vec![self.begin_request(
586 vec![Effect::SendLine(format!( 640 Pending::BlackjackAction,
587 "BLACKJACK_ACTION {} {}", 641 format!("BLACKJACK_ACTION {} {}", hand.session_id, action),
588 hand.session_id, action 642 )]
589 ))]
590 } 643 }
591 644
592 /// Redraw the rising multiplier. Called by the terminal loop on a timer. 645 /// Redraw the rising multiplier. Called by the terminal loop on a timer.
593 pub fn tick(&mut self) -> Vec<Effect> { 646 pub fn tick(&mut self) -> Vec<Effect> {
647 if self.clock_synced {
648 if let Some(started_ms) = self.crash_started_ms {
649 let now = unix_time_ms() as i64 + self.server_offset_ms;
650 let elapsed_ms = (now - started_ms as i64).max(0) as u64;
651 return self.tick_at(Duration::from_millis(elapsed_ms));
652 }
653 }
594 let Some(started) = self.crash_started else { 654 let Some(started) = self.crash_started else {
595 return Vec::new(); 655 return Vec::new();
596 }; 656 };
@@ -619,12 +679,16 @@ impl ClientCore {
619 } 679 }
620 680
621 pub fn is_async(&self, line: &str) -> bool { 681 pub fn is_async(&self, line: &str) -> bool {
622 line.trim_start().starts_with("CRASH_BUSTED ") 682 let line = line.trim_start();
683 line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ")
623 } 684 }
624 685
625 /// Handle a server-initiated line. Returns `None` when the line is a normal 686 /// Handle a server-initiated line. Returns `None` when the line is a normal
626 /// request/response reply and belongs to `handle_reply`. 687 /// request/response reply and belongs to `handle_reply`.
627 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { 688 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> {
689 if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") {
690 return Some(self.apply_crash_tick(tick));
691 }
628 if !self.is_async(line) { 692 if !self.is_async(line) {
629 return None; 693 return None;
630 } 694 }
@@ -643,13 +707,43 @@ impl ClientCore {
643 if self.crash_started.is_none() { 707 if self.crash_started.is_none() {
644 self.crash_started = Some(Instant::now()); 708 self.crash_started = Some(Instant::now());
645 } 709 }
710 if state.started_at > 0 {
711 self.crash_started_ms = Some(state.started_at);
712 }
646 } else { 713 } else {
647 self.crash_started = None; 714 self.crash_started = None;
715 self.crash_started_ms = None;
648 } 716 }
649 self.game.apply_crash_state(state); 717 self.game.apply_crash_state(state);
650 self.board_effects() 718 self.board_effects()
651 } 719 }
652 720
721 /// Apply `crash.tick session=<id> t=<server_ms> m=<multiplier>`.
722 fn apply_crash_tick(&mut self, tick: &str) -> Vec<Effect> {
723 let mut session = None;
724 let mut multiplier = None;
725 for token in tick.split_whitespace() {
726 if let Some(value) = token.strip_prefix("session=") {
727 session = Some(value);
728 } else if let Some(value) = token.strip_prefix("m=") {
729 multiplier = value.parse::<f64>().ok();
730 }
731 }
732 let Some(multiplier) = multiplier else {
733 return Vec::new();
734 };
735 if let Some(session) = session {
736 if self.game.crash_session_id().as_deref() != Some(session) {
737 return Vec::new();
738 }
739 }
740 if self.game.crash_status() != "running" {
741 return Vec::new();
742 }
743 self.game.set_crash_multiplier(multiplier);
744 self.board_effects()
745 }
746
653 fn start_crash(&mut self) -> Vec<Effect> { 747 fn start_crash(&mut self) -> Vec<Effect> {
654 let status = self.game.crash_status(); 748 let status = self.game.crash_status();
655 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { 749 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") {
@@ -671,9 +765,8 @@ impl ClientCore {
671 } 765 }
672 766
673 self.game.set_crash_status("starting"); 767 self.game.set_crash_status("starting");
674 self.pending = Some(Pending::CrashStart);
675 let mut effects = self.board_effects(); 768 let mut effects = self.board_effects();
676 effects.push(Effect::SendLine(request)); 769 effects.push(self.begin_request(Pending::CrashStart, request));
677 effects 770 effects
678 } 771 }
679 772
@@ -681,8 +774,7 @@ impl ClientCore {
681 let Some(session) = self.game.crash_session_id() else { 774 let Some(session) = self.game.crash_session_id() else {
682 return Vec::new(); 775 return Vec::new();
683 }; 776 };
684 self.pending = Some(Pending::CrashCashout); 777 vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))]
685 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))]
686 } 778 }
687 779
688 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { 780 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> {
@@ -706,8 +798,7 @@ impl ClientCore {
706 } 798 }
707 799
708 let mut effects = self.board_effects(); 800 let mut effects = self.board_effects();
709 self.pending = Some(Pending::Info); 801 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
710 effects.push(Effect::SendLine("INFO".to_string()));
711 effects 802 effects
712 } 803 }
713 Err(_) => print_line("Failed to enter game"), 804 Err(_) => print_line("Failed to enter game"),
@@ -726,13 +817,12 @@ impl ClientCore {
726 } 817 }
727 818
728 self.screen = Screen::History; 819 self.screen = Screen::History;
729 self.pending = Some(Pending::History);
730 let request = if limit == crate::history::DEFAULT_HISTORY { 820 let request = if limit == crate::history::DEFAULT_HISTORY {
731 "HISTORY".to_string() 821 "HISTORY".to_string()
732 } else { 822 } else {
733 format!("HISTORY {limit}") 823 format!("HISTORY {limit}")
734 }; 824 };
735 vec![Effect::SendLine(request)] 825 vec![self.begin_request(Pending::History, request)]
736 } 826 }
737 827
738 fn info_effects(&mut self) -> Vec<Effect> { 828 fn info_effects(&mut self) -> Vec<Effect> {
@@ -754,8 +844,7 @@ impl ClientCore {
754 ])), 844 ])),
755 } 845 }
756 846
757 self.pending = Some(Pending::Info); 847 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
758 effects.push(Effect::SendLine("INFO".to_string()));
759 effects 848 effects
760 } 849 }
761 850
@@ -809,7 +898,6 @@ impl ClientCore {
809 } 898 }
810 self.view().map(Effect::Render).into_iter().collect() 899 self.view().map(Effect::Render).into_iter().collect()
811 } 900 }
812
813} 901}
814 902
815fn auth_result(reply: &str) -> Result<(), String> { 903fn 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;
5use super::slots::SlotsGame; 5use super::slots::SlotsGame;
6use super::traits::{Game, GameError, GamePhase, GameState}; 6use super::traits::{Game, GameError, GamePhase, GameState};
7use crate::blackjack::BlackjackState; 7use crate::blackjack::BlackjackState;
8use crate::crash::{payout, CrashState}; 8use crate::crash::{CrashState, payout};
9use crate::roulette::Bets; 9use crate::roulette::Bets;
10use std::collections::HashMap; 10use std::collections::HashMap;
11 11
@@ -244,9 +244,7 @@ impl GameManager {
244 244
245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { 245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) {
246 if let Some(session) = self.current_session.as_mut() { 246 if let Some(session) = self.current_session.as_mut() {
247 session 247 session.data.insert("cursor".to_string(), cursor.encoded());
248 .data
249 .insert("cursor".to_string(), cursor.encoded());
250 } 248 }
251 } 249 }
252 250
@@ -339,7 +337,7 @@ impl GameManager {
339 }; 337 };
340 session.bet_amount = state.bet; 338 session.bet_amount = state.bet;
341 session.data.remove("error"); 339 session.data.remove("error");
342 for key in ["crash_point", "payout"] { 340 for key in ["crash_point", "payout", "commit", "seed"] {
343 session.data.remove(key); 341 session.data.remove(key);
344 } 342 }
345 session 343 session
@@ -360,10 +358,21 @@ impl GameManager {
360 session 358 session
361 .data 359 .data
362 .insert("payout".to_string(), state.payout.to_string()); 360 .insert("payout".to_string(), state.payout.to_string());
361 session
362 .data
363 .insert("started_at".to_string(), state.started_at.to_string());
364 if let Some(commit) = &state.commit {
365 session.data.insert("commit".to_string(), commit.clone());
366 }
367 if let Some(seed) = &state.seed {
368 session.data.insert("seed".to_string(), seed.clone());
369 }
363 if let Some(crash_point) = state.crash_point { 370 if let Some(crash_point) = state.crash_point {
371 // Keep full precision: the browser recomputes this from the
372 // revealed seed, and the GUI charts it.
364 session 373 session
365 .data 374 .data
366 .insert("crash_point".to_string(), format!("{crash_point:.2}")); 375 .insert("crash_point".to_string(), crash_point.to_string());
367 } 376 }
368 } 377 }
369 378
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 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::{RoulettePhase, RouletteView}; 2use crate::client::view::{RoulettePhase, RouletteView};
3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; 3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd};
4use serde::{Deserialize, Serialize};
4 5
5pub const ROULETTE_BOARD_LINES: usize = 12; 6pub const ROULETTE_BOARD_LINES: usize = 12;
6 7
@@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [
21 22
22pub struct RouletteGame; 23pub struct RouletteGame;
23 24
24#[derive(Debug, Clone, Copy, PartialEq, Eq)] 25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum Direction { 26pub enum Direction {
26 Up, 27 Up,
27 Down, 28 Down,
@@ -41,7 +42,7 @@ pub enum Direction {
41/// odd,even line between rows split of the two numbers 42/// odd,even line between rows split of the two numbers
42/// even,even corner of four cells corner 43/// even,even corner of four cells corner
43/// ``` 44/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq)] 45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum Cursor { 46pub enum Cursor {
46 Zero, 47 Zero,
47 Lattice { x: usize, y: usize }, 48 Lattice { x: usize, y: usize },
@@ -67,7 +68,9 @@ impl Cursor {
67 match parts.as_slice() { 68 match parts.as_slice() {
68 ["zero"] => Cursor::Zero, 69 ["zero"] => Cursor::Zero,
69 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { 70 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) {
70 (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { 71 (Ok(x), Ok(y))
72 if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) =>
73 {
71 Cursor::Lattice { x, y } 74 Cursor::Lattice { x, y }
72 } 75 }
73 _ => Cursor::default(), 76 _ => 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 @@
7use std::fmt; 7use std::fmt;
8use std::io::{Read, Write}; 8use std::io::{Read, Write};
9use std::net::TcpStream; 9use std::net::TcpStream;
10use std::sync::mpsc;
11use std::sync::Arc; 10use std::sync::Arc;
11use std::sync::mpsc;
12use std::thread; 12use std::thread;
13use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
14 14
15use parking_lot::Mutex; 15use parking_lot::Mutex;
16use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
17use rustls::DigitallySignedStruct;
18use rusqlite::Connection; 16use rusqlite::Connection;
17use rustls::DigitallySignedStruct;
18use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
19 19
20use crate::client::database; 20use crate::client::database;
21use crate::protocol::LineBuffer; 21use crate::protocol::{
22use crate::tofu::{decide, spki_fingerprint, TrustDecision}; 22 FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame,
23use crate::version::{parse_hello, HelloReply, VERSION}; 23};
24use crate::tofu::{TrustDecision, decide, spki_fingerprint};
25use crate::version::{HelloReply, VERSION, parse_hello};
26
27/// Reserved request ids for the transport handshake. Application requests
28/// start at 1, so the two ranges cannot collide on one connection.
29const HELLO_REQUEST_ID: u64 = u64::MAX;
30const AUTH_REQUEST_ID: u64 = u64::MAX - 1;
24 31
25/// How long to wait for TCP + TLS before giving up. 32/// How long to wait for TCP + TLS before giving up.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 33const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -43,6 +50,8 @@ pub struct Connected {
43 pub server_version: String, 50 pub server_version: String,
44 pub latest: String, 51 pub latest: String,
45 pub url: String, 52 pub url: String,
53 /// Server Unix time reported by `HELLO`, milliseconds.
54 pub server_time: u64,
46 /// Round trip of the HELLO exchange. 55 /// Round trip of the HELLO exchange.
47 pub elapsed: Duration, 56 pub elapsed: Duration,
48} 57}
@@ -51,10 +60,7 @@ pub struct Connected {
51pub enum TransportError { 60pub enum TransportError {
52 Timeout, 61 Timeout,
53 TrustDeclined, 62 TrustDeclined,
54 KeyChanged { 63 KeyChanged { expected: String, observed: String },
55 expected: String,
56 observed: String,
57 },
58 VersionMismatch(String), 64 VersionMismatch(String),
59 Closed, 65 Closed,
60 Unexpected(String), 66 Unexpected(String),
@@ -121,7 +127,7 @@ pub fn connect(
121 return Err(TransportError::KeyChanged { 127 return Err(TransportError::KeyChanged {
122 expected, 128 expected,
123 observed: actual, 129 observed: actual,
124 }) 130 });
125 } 131 }
126 } 132 }
127 } 133 }
@@ -131,12 +137,7 @@ pub fn connect(
131 .map_err(io_failure)?; 137 .map_err(io_failure)?;
132 138
133 let start = Instant::now(); 139 let start = Instant::now();
134 tls.write_line(&format!("HELLO {VERSION}")) 140 let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?;
135 .map_err(io_failure)?;
136 let mut response = String::new();
137 if tls.read_line(&mut response).map_err(io_failure)? == 0 {
138 return Err(TransportError::Closed);
139 }
140 let elapsed = start.elapsed(); 141 let elapsed = start.elapsed();
141 142
142 let response = response.trim(); 143 let response = response.trim();
@@ -151,10 +152,11 @@ pub fn connect(
151 let mut user = None; 152 let mut user = None;
152 let mut auth_error = None; 153 let mut auth_error = None;
153 if let Some((username, password)) = database::get_credentials(conn, &observed) { 154 if let Some((username, password)) = database::get_credentials(conn, &observed) {
154 tls.write_line(&format!("AUTH {username} {password}")) 155 let auth = exchange(
155 .map_err(io_failure)?; 156 &mut tls,
156 let mut auth = String::new(); 157 AUTH_REQUEST_ID,
157 tls.read_line(&mut auth).map_err(io_failure)?; 158 &format!("AUTH {username} {password}"),
159 )?;
158 if auth.trim().starts_with("OK") { 160 if auth.trim().starts_with("OK") {
159 user = Some(username); 161 user = Some(username);
160 } else { 162 } else {
@@ -169,19 +171,31 @@ pub fn connect(
169 server_version: hello.version, 171 server_version: hello.version,
170 latest: hello.latest, 172 latest: hello.latest,
171 url: hello.url, 173 url: hello.url,
174 server_time: hello.time,
172 elapsed, 175 elapsed,
173 }) 176 })
174} 177}
175 178
179/// Send one request and wait for the reply with the same id, ignoring events
180/// that arrive in between. Used only for the handshake.
181fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result<String, TransportError> {
182 stream
183 .write_request(id, None, payload)
184 .map_err(io_failure)?;
185 loop {
186 match stream.read_packet().map_err(io_failure)? {
187 None => return Err(TransportError::Closed),
188 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body),
189 Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue,
190 }
191 }
192}
193
176/// TCP + TLS on a thread so a blocked handshake can time out. 194/// TCP + TLS on a thread so a blocked handshake can time out.
177fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { 195fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> {
178 let (sender, receiver) = mpsc::channel(); 196 let (sender, receiver) = mpsc::channel();
179 let addr = addr.to_string(); 197 let addr = addr.to_string();
180 let server_name = addr 198 let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string();
181 .split(':')
182 .next()
183 .unwrap_or("127.0.0.1")
184 .to_string();
185 199
186 thread::spawn(move || { 200 thread::spawn(move || {
187 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { 201 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> {
@@ -241,7 +255,7 @@ impl TlsConnector {
241 session: Arc::new(Mutex::new(Session { 255 session: Arc::new(Mutex::new(Session {
242 sock, 256 sock,
243 tls: connection, 257 tls: connection,
244 lines: LineBuffer::new(), 258 frames: FrameBuffer::new(),
245 read_timeout: Some(READ_TIMEOUT), 259 read_timeout: Some(READ_TIMEOUT),
246 })), 260 })),
247 }) 261 })
@@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier {
268 _ocsp_response: &[u8], 282 _ocsp_response: &[u8],
269 _now: std::time::SystemTime, 283 _now: std::time::SystemTime,
270 ) -> Result<ServerCertVerified, rustls::Error> { 284 ) -> Result<ServerCertVerified, rustls::Error> {
271 let fingerprint = spki_fingerprint(&end_entity.0) 285 let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| {
272 .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; 286 rustls::Error::General(format!("certificate parse failed: {error}"))
287 })?;
273 *self.observed.lock() = Some(fingerprint); 288 *self.observed.lock() = Some(fingerprint);
274 Ok(ServerCertVerified::assertion()) 289 Ok(ServerCertVerified::assertion())
275 } 290 }
@@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier {
313struct Session { 328struct Session {
314 sock: TcpStream, 329 sock: TcpStream,
315 tls: rustls::ClientConnection, 330 tls: rustls::ClientConnection,
316 lines: LineBuffer, 331 frames: FrameBuffer,
317 read_timeout: Option<Duration>, 332 read_timeout: Option<Duration>,
318} 333}
319 334
@@ -329,37 +344,58 @@ pub struct TlsStream {
329} 344}
330 345
331impl TlsStream { 346impl TlsStream {
332 pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { 347 /// Write one enveloped request. Application code allocates `id`.
348 pub fn write_request(
349 &mut self,
350 id: u64,
351 idem: Option<&str>,
352 payload: &str,
353 ) -> std::io::Result<()> {
354 self.write_frame(&request_line(id, idem, payload))
355 }
356
357 /// Write one raw frame payload.
358 pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> {
359 let mut s = self.session.lock();
360 write_protocol_frame(&mut s.tls.writer(), payload)?;
361 s.drive()
362 }
363
364 /// Block until one whole frame arrives.
365 pub fn read_frame(&mut self) -> std::io::Result<Option<String>> {
333 let mut s = self.session.lock(); 366 let mut s = self.session.lock();
334 loop { 367 loop {
335 if let Some(line) = s.lines.next_line() { 368 match s.frames.next_frame() {
336 buf.push_str(&line); 369 Some(Ok(frame)) => return Ok(Some(frame)),
337 buf.push('\n'); 370 Some(Err(error)) => {
338 return Ok(line.len() + 1); 371 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
372 }
373 None => {}
339 } 374 }
340 375
341 let mut chunk = [0u8; 4096]; 376 let mut chunk = [0u8; 4096];
342 match s.tls.reader().read(&mut chunk) { 377 match s.tls.reader().read(&mut chunk) {
343 Ok(0) => { 378 Ok(0) => return Ok(None),
344 if let Some(rest) = s.lines.take_rest() { 379 Ok(n) => s.frames.push(&chunk[..n]),
345 let read = rest.len();
346 buf.push_str(&rest);
347 return Ok(read);
348 }
349 return Ok(0);
350 }
351 Ok(n) => s.lines.push(&chunk[..n]),
352 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, 380 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?,
353 Err(error) => return Err(error), 381 Err(error) => return Err(error),
354 } 382 }
355 } 383 }
356 } 384 }
357 385
358 pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { 386 /// Block until one `RES` or `EVT` arrives.
359 let mut s = self.session.lock(); 387 pub fn read_packet(&mut self) -> std::io::Result<Option<Packet>> {
360 s.tls.writer().write_all(message.as_bytes())?; 388 loop {
361 s.tls.writer().write_all(b"\n")?; 389 let Some(frame) = self.read_frame()? else {
362 s.drive() 390 return Ok(None);
391 };
392 match parse_packet(&frame) {
393 Ok(packet) => return Ok(Some(packet)),
394 Err(error) => {
395 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
396 }
397 }
398 }
363 } 399 }
364 400
365 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { 401 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> {
@@ -368,24 +404,36 @@ impl TlsStream {
368 s.sock.set_read_timeout(duration) 404 s.sock.set_read_timeout(duration)
369 } 405 }
370 406
371 /// Return one complete line if the socket already has one, without 407 /// Return one packet if the socket already has one, without blocking.
372 /// blocking. Used to pick up server pushes between requests. 408 /// Used to pick up server pushes between requests.
373 pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { 409 pub fn read_available_packet(&mut self) -> std::io::Result<Option<Packet>> {
374 let mut s = self.session.lock(); 410 let mut s = self.session.lock();
375 if let Some(line) = s.lines.next_line() {
376 return Ok(Some(line));
377 }
378 411
379 // `set_read_timeout(Some(0))` is rejected by std, so poll with the 412 // `set_read_timeout(Some(0))` is rejected by std, so poll with the
380 // shortest timeout the socket accepts instead of a nonblocking mode. 413 // shortest timeout the socket accepts instead of a nonblocking mode.
381 s.sock 414 s.sock
382 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; 415 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?;
383 let drained = (|| -> std::io::Result<()> { 416 let drained = (|| -> std::io::Result<Option<Packet>> {
384 loop { 417 loop {
418 match s.frames.next_frame() {
419 Some(Ok(frame)) => {
420 return match parse_packet(&frame) {
421 Ok(packet) => Ok(Some(packet)),
422 Err(error) => {
423 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
424 }
425 };
426 }
427 Some(Err(error)) => {
428 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
429 }
430 None => {}
431 }
432
385 let mut buf = [0u8; 4096]; 433 let mut buf = [0u8; 4096];
386 match s.tls.reader().read(&mut buf) { 434 match s.tls.reader().read(&mut buf) {
387 Ok(0) => return Ok(()), 435 Ok(0) => return Ok(None),
388 Ok(n) => s.lines.push(&buf[..n]), 436 Ok(n) => s.frames.push(&buf[..n]),
389 // The reader only drains plaintext rustls has already 437 // The reader only drains plaintext rustls has already
390 // decrypted; `drive` moves bytes off the socket. 438 // decrypted; `drive` moves bytes off the socket.
391 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 439 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
@@ -397,7 +445,7 @@ impl TlsStream {
397 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 445 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
398 ) => 446 ) =>
399 { 447 {
400 return Ok(()); 448 return Ok(None);
401 } 449 }
402 Err(error) => return Err(error), 450 Err(error) => return Err(error),
403 } 451 }
@@ -408,15 +456,14 @@ impl TlsStream {
408 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 456 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
409 ) => 457 ) =>
410 { 458 {
411 return Ok(()); 459 return Ok(None);
412 } 460 }
413 Err(error) => return Err(error), 461 Err(error) => return Err(error),
414 } 462 }
415 } 463 }
416 })(); 464 })();
417 s.sock.set_read_timeout(s.read_timeout)?; 465 s.sock.set_read_timeout(s.read_timeout)?;
418 drained?; 466 drained
419 Ok(s.lines.next_line())
420 } 467 }
421} 468}
422 469
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;
7use crate::client::games::roulette::{Cursor, Direction}; 7use crate::client::games::roulette::{Cursor, Direction};
8use crate::client::games::traits::{GamePhase, GameState}; 8use crate::client::games::traits::{GamePhase, GameState};
9use crate::roulette::{BetTarget, Bets}; 9use crate::roulette::{BetTarget, Bets};
10use serde::{Deserialize, Serialize};
10 11
11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
12pub enum RoulettePhase { 14pub enum RoulettePhase {
13 Placing, 15 Placing,
14 Spinning, 16 Spinning,
15 Result, 17 Result,
16} 18}
17 19
18#[derive(Debug, Clone, PartialEq, Eq)] 20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct RouletteView { 21pub struct RouletteView {
20 pub bet_amount: u64, 22 pub bet_amount: u64,
21 pub balance: Option<i64>, 23 pub balance: Option<i64>,
@@ -51,7 +53,10 @@ impl RouletteView {
51 Some("result") => RoulettePhase::Result, 53 Some("result") => RoulettePhase::Result,
52 _ => RoulettePhase::Placing, 54 _ => RoulettePhase::Placing,
53 }; 55 };
54 let number = state.data.get("number").and_then(|value| value.parse().ok()); 56 let number = state
57 .data
58 .get("number")
59 .and_then(|value| value.parse().ok());
55 let color = state.data.get("color").cloned(); 60 let color = state.data.get("color").cloned();
56 let winnings = state 61 let winnings = state
57 .data 62 .data
@@ -82,14 +87,15 @@ impl RouletteView {
82 } 87 }
83} 88}
84 89
85#[derive(Debug, Clone, Copy, PartialEq, Eq)] 90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
86pub enum SlotsPhase { 92pub enum SlotsPhase {
87 Ready, 93 Ready,
88 Spinning, 94 Spinning,
89 Result, 95 Result,
90} 96}
91 97
92#[derive(Debug, Clone, PartialEq, Eq)] 98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SlotsView { 99pub struct SlotsView {
94 pub bet: u64, 100 pub bet: u64,
95 pub balance: Option<i64>, 101 pub balance: Option<i64>,
@@ -149,7 +155,7 @@ impl SlotsView {
149 } 155 }
150} 156}
151 157
152#[derive(Debug, Clone, PartialEq, Eq)] 158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct BlackjackView { 159pub struct BlackjackView {
154 pub status: String, 160 pub status: String,
155 pub balance: Option<i64>, 161 pub balance: Option<i64>,
@@ -186,7 +192,7 @@ impl BlackjackView {
186 } 192 }
187} 193}
188 194
189#[derive(Debug, Clone, PartialEq)] 195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct CrashView { 196pub struct CrashView {
191 pub status: String, 197 pub status: String,
192 pub balance: Option<i64>, 198 pub balance: Option<i64>,
@@ -195,6 +201,11 @@ pub struct CrashView {
195 pub potential: u64, 201 pub potential: u64,
196 pub crash_point: Option<f64>, 202 pub crash_point: Option<f64>,
197 pub payout: u64, 203 pub payout: u64,
204 pub started_at: u64,
205 /// `sha256(seed)` for this round, before it ends.
206 pub commit: Option<String>,
207 /// The revealed seed, after the round ends.
208 pub seed: Option<String>,
198 pub error: Option<String>, 209 pub error: Option<String>,
199} 210}
200 211
@@ -232,6 +243,13 @@ impl CrashView {
232 .get("payout") 243 .get("payout")
233 .and_then(|value| value.parse::<u64>().ok()) 244 .and_then(|value| value.parse::<u64>().ok())
234 .unwrap_or(0), 245 .unwrap_or(0),
246 started_at: state
247 .data
248 .get("started_at")
249 .and_then(|value| value.parse::<u64>().ok())
250 .unwrap_or(0),
251 commit: state.data.get("commit").cloned(),
252 seed: state.data.get("seed").cloned(),
235 error: state 253 error: state
236 .data 254 .data
237 .get("error") 255 .get("error")
@@ -242,7 +260,7 @@ impl CrashView {
242} 260}
243 261
244/// The lobby: who is logged in, the balance, and what can be played. 262/// The lobby: who is logged in, the balance, and what can be played.
245#[derive(Debug, Clone, PartialEq, Eq)] 263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct LobbyView { 264pub struct LobbyView {
247 pub connected: bool, 265 pub connected: bool,
248 pub user: Option<String>, 266 pub user: Option<String>,
@@ -254,13 +272,14 @@ pub struct LobbyView {
254} 272}
255 273
256/// The play log, already parsed. 274/// The play log, already parsed.
257#[derive(Debug, Clone, PartialEq)] 275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct HistoryView { 276pub struct HistoryView {
259 pub records: Vec<crate::history::PlayRecord>, 277 pub records: Vec<crate::history::PlayRecord>,
260} 278}
261 279
262/// Everything a frontend can draw. 280/// Everything a frontend can draw.
263#[derive(Debug, Clone, PartialEq)] 281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282#[serde(tag = "screen", rename_all = "snake_case")]
264pub enum View { 283pub enum View {
265 Lobby(LobbyView), 284 Lobby(LobbyView),
266 History(HistoryView), 285 History(HistoryView),
@@ -271,17 +290,28 @@ pub enum View {
271} 290}
272 291
273/// Semantic input. Frontends turn keys and clicks into these. 292/// Semantic input. Frontends turn keys and clicks into these.
274#[derive(Debug, Clone, PartialEq, Eq)] 293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub enum Command { 294pub enum Command {
276 /// The credential is already hashed by the frontend. 295 /// The credential is already hashed by the frontend.
277 Login { user: String, credential: String }, 296 Login {
297 user: String,
298 credential: String,
299 },
278 Games, 300 Games,
279 Ping, 301 Ping,
280 Echo(String), 302 Echo(String),
281 Info, 303 Info,
282 Register { user: String, credential: String }, 304 Register {
283 Play { game: String, bet: Option<u64> }, 305 user: String,
284 History { limit: u32 }, 306 credential: String,
307 },
308 Play {
309 game: String,
310 bet: Option<u64>,
311 },
312 History {
313 limit: u32,
314 },
285 ShowLobby, 315 ShowLobby,
286 Logout, 316 Logout,
287 SetBet(u64), 317 SetBet(u64),
diff --git a/src/crash.rs b/src/crash.rs
index 9d6e98c..40b8743 100644
--- a/src/crash.rs
+++ b/src/crash.rs
@@ -6,6 +6,7 @@
6//! clock, never from anything the client sends. 6//! clock, never from anything the client sends.
7 7
8use serde::{Deserialize, Serialize}; 8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
9use std::time::Duration; 10use std::time::Duration;
10 11
11/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. 12/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds.
@@ -47,6 +48,53 @@ pub fn crash_point_from_uniform(u: f64) -> f64 {
47 ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) 48 ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER)
48} 49}
49 50
51/// How many bytes a round seed contains. The first eight bytes choose the
52/// crash point; the rest are revealed and hashed with it.
53pub const SEED_LEN: usize = 32;
54
55/// Hex-encoded SHA-256 of a round seed, sent before the round starts.
56pub fn commit_seed(seed: &[u8]) -> String {
57 let digest = Sha256::digest(seed);
58 format!("{digest:x}")
59}
60
61/// Uniform draw encoded by a seed: the first eight bytes, big-endian.
62pub fn seed_to_uniform(seed: &[u8]) -> f64 {
63 let mut bytes = [0u8; 8];
64 let take = seed.len().min(8);
65 bytes[..take].copy_from_slice(&seed[..take]);
66 u64::from_be_bytes(bytes) as f64 / 18_446_744_073_709_551_616.0
67}
68
69/// The crash point a seed commits to. A client recomputes this after the
70/// reveal and checks it against the published commit.
71pub fn crash_point_from_seed(seed: &[u8]) -> f64 {
72 crash_point_from_uniform(seed_to_uniform(seed))
73}
74
75/// Decode a lowercase or uppercase hex seed. Returns `None` for odd lengths,
76/// bad digits or a seed that is not exactly [`SEED_LEN`] bytes.
77pub fn seed_from_hex(raw: &str) -> Option<Vec<u8>> {
78 if raw.len() != SEED_LEN * 2 {
79 return None;
80 }
81 let mut seed = Vec::with_capacity(SEED_LEN);
82 for pair in raw.as_bytes().chunks_exact(2) {
83 let high = (pair[0] as char).to_digit(16)?;
84 let low = (pair[1] as char).to_digit(16)?;
85 seed.push((high * 16 + low) as u8);
86 }
87 Some(seed)
88}
89
90/// Check a revealed seed against the commit published before the round.
91pub fn verify_seed(seed_hex: &str, commit: &str) -> bool {
92 let Some(seed) = seed_from_hex(seed_hex) else {
93 return false;
94 };
95 commit_seed(&seed).eq_ignore_ascii_case(commit)
96}
97
50/// Whole-Kr cashout value. Balances are integers, so the payout is floored. 98/// Whole-Kr cashout value. Balances are integers, so the payout is floored.
51pub fn payout(bet: u64, multiplier: f64) -> u64 { 99pub fn payout(bet: u64, multiplier: f64) -> u64 {
52 if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { 100 if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 {
@@ -72,10 +120,16 @@ pub struct CrashState {
72 pub multiplier: f64, 120 pub multiplier: f64,
73 /// `floor(bet * multiplier)` for the value above. 121 /// `floor(bet * multiplier)` for the value above.
74 pub potential: u64, 122 pub potential: u64,
123 /// Server Unix time when the round started, milliseconds.
124 pub started_at: u64,
75 /// Hidden until the round ends. 125 /// Hidden until the round ends.
76 pub crash_point: Option<f64>, 126 pub crash_point: Option<f64>,
77 pub payout: u64, 127 pub payout: u64,
78 pub result: String, 128 pub result: String,
129 /// `sha256(seed)`, present for the whole round.
130 pub commit: Option<String>,
131 /// The seed itself, present only after the round ends.
132 pub seed: Option<String>,
79} 133}
80 134
81impl CrashState { 135impl CrashState {
diff --git a/src/protocol.rs b/src/protocol.rs
index 11f54a1..e838b08 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -1,16 +1,60 @@
1//! Newline-delimited protocol helpers shared by the client and server. 1//! Protocol v5: bounded frames and the request/reply/event envelope.
2//! 2//!
3//! TLS reads can return more than one line at a time. Keeping the leftover 3//! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and
4//! bytes is essential: dropping them silently loses the next message. 4//! capped, so a peer cannot make the reader allocate without bound. Inside a
5//! frame the payload is text:
6//!
7//! ```text
8//! REQ <id> [idem=<token>] <command>
9//! RES <id> <body>
10//! EVT <seq> <body>
11//! ```
12//!
13//! The command and body grammar is the same text protocol the games already
14//! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds
15//! correlation, event numbering and a hard size limit.
16
17use std::fmt;
18use std::io::{self, Read, Write};
19
20/// Largest payload a peer may send in one frame. A game message is a few
21/// hundred bytes; 64 KiB is generous and still bounds the reader.
22pub const MAX_FRAME_LEN: usize = 64 * 1024;
23
24/// How many request ids a connection remembers for idempotent retries.
25pub const REPLAY_CACHE_LEN: usize = 256;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FrameError {
29 /// The declared payload length exceeds [`MAX_FRAME_LEN`].
30 TooLarge(usize),
31 /// The payload was not valid UTF-8.
32 InvalidUtf8,
33}
34
35impl fmt::Display for FrameError {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 FrameError::TooLarge(length) => {
39 write!(
40 f,
41 "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit"
42 )
43 }
44 FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"),
45 }
46 }
47}
5 48
6use std::io::{self, Read}; 49impl std::error::Error for FrameError {}
7 50
51/// A bounded frame decoder. Feed it bytes from a socket, take whole frames.
8#[derive(Debug, Default)] 52#[derive(Debug, Default)]
9pub struct LineBuffer { 53pub struct FrameBuffer {
10 buffer: Vec<u8>, 54 buffer: Vec<u8>,
11} 55}
12 56
13impl LineBuffer { 57impl FrameBuffer {
14 pub fn new() -> Self { 58 pub fn new() -> Self {
15 Self::default() 59 Self::default()
16 } 60 }
@@ -19,46 +63,172 @@ impl LineBuffer {
19 self.buffer.extend_from_slice(bytes); 63 self.buffer.extend_from_slice(bytes);
20 } 64 }
21 65
22 /// Return the next complete line, including a line that ended at EOF. 66 /// Return the next complete frame, if the buffer holds one.
23 pub fn next_line(&mut self) -> Option<String> { 67 ///
24 let end = self.buffer.iter().position(|byte| *byte == b'\n')?; 68 /// A declared length above the limit is reported immediately instead of
25 let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); 69 /// waiting for bytes that will never be accepted; the caller should close
26 line.pop(); // '\n' 70 /// the connection.
27 if line.last() == Some(&b'\r') { 71 pub fn next_frame(&mut self) -> Option<Result<String, FrameError>> {
28 line.pop(); 72 if self.buffer.len() < 4 {
73 return None;
29 } 74 }
30 Some(String::from_utf8_lossy(&line).into_owned())
31 }
32 75
33 /// Return the unterminated remainder at EOF, if any. 76 let length = u32::from_be_bytes([
34 pub fn take_rest(&mut self) -> Option<String> { 77 self.buffer[0],
35 if self.buffer.is_empty() { 78 self.buffer[1],
79 self.buffer[2],
80 self.buffer[3],
81 ]) as usize;
82
83 if length > MAX_FRAME_LEN {
84 self.buffer.clear();
85 return Some(Err(FrameError::TooLarge(length)));
86 }
87 if self.buffer.len() < 4 + length {
36 return None; 88 return None;
37 } 89 }
38 let mut rest = std::mem::take(&mut self.buffer); 90
39 if rest.last() == Some(&b'\r') { 91 let payload: Vec<u8> = self.buffer[4..4 + length].to_vec();
40 rest.pop(); 92 self.buffer.drain(..4 + length);
93 match String::from_utf8(payload) {
94 Ok(text) => Some(Ok(text)),
95 Err(_) => Some(Err(FrameError::InvalidUtf8)),
41 } 96 }
42 Some(String::from_utf8_lossy(&rest).into_owned())
43 } 97 }
44 98
45 /// Read one line from a blocking reader, preserving any extra lines for 99 /// Read one frame from a blocking reader, preserving leftover bytes.
46 /// the next call. Returns `None` at EOF with nothing buffered. 100 /// `None` means EOF with no complete frame buffered.
47 pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { 101 pub fn read_frame<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> {
48 loop { 102 loop {
49 if let Some(line) = self.next_line() { 103 match self.next_frame() {
50 return Ok(Some(line)); 104 Some(Ok(frame)) => return Ok(Some(frame)),
105 Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
106 None => {}
51 } 107 }
52 108
53 let mut chunk = [0u8; 4096]; 109 let mut chunk = [0u8; 4096];
54 match reader.read(&mut chunk) { 110 match reader.read(&mut chunk) {
55 Ok(0) => return Ok(self.take_rest()), 111 Ok(0) => return Ok(None),
56 Ok(n) => self.push(&chunk[..n]), 112 Ok(n) => self.push(&chunk[..n]),
57 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, 113 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
58 Err(error) => return Err(error), 114 Err(error) => return Err(error),
59 } 115 }
60 } 116 }
61 } 117 }
118
119 /// True when a partial frame is buffered (used by tests and diagnostics).
120 pub fn is_empty(&self) -> bool {
121 self.buffer.is_empty()
122 }
123}
124
125/// Encode one payload as a length-prefixed frame.
126pub fn encode_frame(payload: &str) -> Vec<u8> {
127 let length = payload.len() as u32;
128 let mut bytes = Vec::with_capacity(4 + payload.len());
129 bytes.extend_from_slice(&length.to_be_bytes());
130 bytes.extend_from_slice(payload.as_bytes());
131 bytes
132}
133
134/// Write one payload as a length-prefixed frame.
135pub fn write_frame<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> {
136 writer.write_all(&encode_frame(payload))?;
137 writer.flush()
138}
139
140/// One client request. `id` is the correlation key; `idem` is optional
141/// metadata for logs and a future cross-connection cache.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Request {
144 pub id: u64,
145 pub idem: Option<String>,
146 pub payload: String,
147}
148
149/// A frame received by a client.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum Packet {
152 Reply { id: u64, body: String },
153 Event { seq: u64, body: String },
154}
155
156pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String {
157 match idem {
158 Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"),
159 _ => format!("REQ {id} {payload}"),
160 }
161}
162
163pub fn reply_line(id: u64, body: &str) -> String {
164 format!("RES {id} {body}")
165}
166
167pub fn event_line(seq: u64, body: &str) -> String {
168 format!("EVT {seq} {body}")
169}
170
171/// Parse a `REQ` payload. Errors are safe to return to the peer.
172pub fn parse_request(frame: &str) -> Result<Request, String> {
173 let frame = frame.trim_end();
174 let rest = frame
175 .strip_prefix("REQ ")
176 .ok_or_else(|| "Malformed request: expected REQ".to_string())?;
177
178 let (id_token, mut rest) = split_token(rest);
179 let id: u64 = id_token
180 .parse()
181 .map_err(|_| "Malformed request id".to_string())?;
182
183 let mut idem = None;
184 let (next, after) = split_token(rest);
185 if let Some(value) = next.strip_prefix("idem=") {
186 if value.is_empty() {
187 return Err("Malformed idempotency key".to_string());
188 }
189 idem = Some(value.to_string());
190 rest = after;
191 }
192
193 Ok(Request {
194 id,
195 idem,
196 payload: rest.to_string(),
197 })
198}
199
200/// Parse a `RES` or `EVT` payload on the client side.
201pub fn parse_packet(frame: &str) -> Result<Packet, String> {
202 let frame = frame.trim_end();
203 if let Some(rest) = frame.strip_prefix("RES ") {
204 let (id_token, body) = split_token(rest);
205 let id: u64 = id_token
206 .parse()
207 .map_err(|_| "Malformed reply id".to_string())?;
208 return Ok(Packet::Reply {
209 id,
210 body: body.to_string(),
211 });
212 }
213 if let Some(rest) = frame.strip_prefix("EVT ") {
214 let (seq_token, body) = split_token(rest);
215 let seq: u64 = seq_token
216 .parse()
217 .map_err(|_| "Malformed event sequence".to_string())?;
218 return Ok(Packet::Event {
219 seq,
220 body: body.to_string(),
221 });
222 }
223 Err(format!("Unexpected packet: {frame}"))
224}
225
226fn split_token(rest: &str) -> (&str, &str) {
227 let rest = rest.trim_start();
228 match rest.find(' ') {
229 Some(index) => (&rest[..index], rest[index + 1..].trim_start()),
230 None => (rest, ""),
231 }
62} 232}
63 233
64/// A stable, user-facing message for a failed request. 234/// A stable, user-facing message for a failed request.
diff --git a/src/server/core.rs b/src/server/core.rs
index 3cd6469..0b2efb4 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -1,22 +1,26 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{DEFAULT_COST, hash, verify};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap; 3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc; 4use std::sync::Arc;
5 5
6use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState; 7use crate::crash::CrashState;
8use crate::protocol::{REPLAY_CACHE_LEN, Request};
8use crate::server::database; 9use crate::server::database;
9use crate::version::{download_url, hello_line, Version, VERSION};
10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; 11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
12use crate::server::games::registry::GameRegistry; 12use crate::server::games::registry::GameRegistry;
13use crate::server::games::session::SessionManager; 13use crate::server::games::session::SessionManager;
14use crate::server::games::traits::{GameError, GamePhase, GameState}; 14use crate::server::games::traits::{GameError, GamePhase, GameState};
15use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms};
15 16
16#[derive(Debug, Default)] 17#[derive(Debug, Default)]
17pub struct ConnectionState { 18pub struct ConnectionState {
18 user: Option<String>, 19 user: Option<String>,
19 greeted: bool, 20 greeted: bool,
21 /// Last replies on this connection, oldest first. Request ids are unique
22 /// while cached, so a retried request replays its first result.
23 replays: VecDeque<(u64, String, String)>,
20} 24}
21 25
22/// Settle reservations left behind by a previous process. 26/// Settle reservations left behind by a previous process.
@@ -39,6 +43,27 @@ impl ConnectionState {
39 pub fn greeted(&self) -> bool { 43 pub fn greeted(&self) -> bool {
40 self.greeted 44 self.greeted
41 } 45 }
46
47 fn replay(&self, id: u64, payload: &str) -> Option<Result<String, String>> {
48 self.replays
49 .iter()
50 .find(|(cached_id, _, _)| *cached_id == id)
51 .map(|(_, cached_payload, reply)| {
52 if cached_payload == payload {
53 Ok(reply.clone())
54 } else {
55 Err("ERROR Request id reused with a different command".to_string())
56 }
57 })
58 }
59
60 fn remember(&mut self, id: u64, payload: &str, reply: &str) {
61 if self.replays.len() >= REPLAY_CACHE_LEN {
62 self.replays.pop_front();
63 }
64 self.replays
65 .push_back((id, payload.to_string(), reply.to_string()));
66 }
42} 67}
43 68
44pub struct ServerCore { 69pub struct ServerCore {
@@ -49,6 +74,8 @@ pub struct ServerCore {
49 blackjack: BlackjackGame, 74 blackjack: BlackjackGame,
50 crash: CrashGame, 75 crash: CrashGame,
51 clock: Arc<dyn Clock>, 76 clock: Arc<dyn Clock>,
77 /// Last time a crash tick was pushed, in seconds on the game clock.
78 last_tick: HashMap<String, f64>,
52} 79}
53 80
54impl ServerCore { 81impl ServerCore {
@@ -65,6 +92,7 @@ impl ServerCore {
65 blackjack: BlackjackGame::new(), 92 blackjack: BlackjackGame::new(),
66 crash: CrashGame::new(), 93 crash: CrashGame::new(),
67 clock: Arc::new(RealClock::new()), 94 clock: Arc::new(RealClock::new()),
95 last_tick: HashMap::new(),
68 } 96 }
69 } 97 }
70 98
@@ -96,6 +124,21 @@ impl ServerCore {
96 Ok(Self::new(conn)) 124 Ok(Self::new(conn))
97 } 125 }
98 126
127 /// Handle one enveloped request, replaying a cached reply for a repeated
128 /// request id. The payload grammar is unchanged; the envelope only adds
129 /// correlation and idempotency.
130 pub fn handle_request(&mut self, state: &mut ConnectionState, request: &Request) -> String {
131 if let Some(reply) = state.replay(request.id, &request.payload) {
132 return match reply {
133 Ok(reply) => reply,
134 Err(message) => message,
135 };
136 }
137 let reply = self.handle_message(state, &request.payload);
138 state.remember(request.id, &request.payload, &reply);
139 reply
140 }
141
99 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { 142 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
100 let parts: Vec<&str> = msg.splitn(5, ' ').collect(); 143 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
101 144
@@ -201,7 +244,7 @@ impl ServerCore {
201 } 244 }
202 } 245 }
203 } 246 }
204 "PING" => "PONG".to_string(), 247 "PING" => pong_line(unix_time_ms()),
205 "INFO" => self.handle_info(state), 248 "INFO" => self.handle_info(state),
206 _ => msg.to_string(), 249 _ => msg.to_string(),
207 } 250 }
@@ -212,7 +255,8 @@ impl ServerCore {
212 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string(); 255 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string();
213 } 256 }
214 257
215 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { 258 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION))
259 else {
216 return "ERROR Invalid version".to_string(); 260 return "ERROR Invalid version".to_string();
217 }; 261 };
218 262
@@ -225,7 +269,7 @@ impl ServerCore {
225 } 269 }
226 270
227 state.greeted = true; 271 state.greeted = true;
228 hello_line(VERSION, VERSION, &download_url()) 272 hello_line(VERSION, VERSION, &download_url(), unix_time_ms())
229 } 273 }
230 274
231 fn handle_register(&mut self, username: &str, credential: &str) -> String { 275 fn handle_register(&mut self, username: &str, credential: &str) -> String {
@@ -529,8 +573,8 @@ impl ServerCore {
529 573
530 // The session is the audit guard for the deduction, so it exists before 574 // The session is the audit guard for the deduction, so it exists before
531 // the balance is touched. 575 // the balance is touched.
532 let crash_point = self.crash.next_crash_point(); 576 let seed = self.crash.next_seed();
533 let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); 577 let round = CrashRound::new(bet as u64, seed, self.clock.now(), unix_time_ms());
534 let mut data = HashMap::new(); 578 let mut data = HashMap::new();
535 data.insert("round".to_string(), round.to_json()); 579 data.insert("round".to_string(), round.to_json());
536 data.insert("balance".to_string(), balance.to_string()); 580 data.insert("balance".to_string(), balance.to_string());
@@ -643,11 +687,27 @@ impl ServerCore {
643 if !round.is_running() { 687 if !round.is_running() {
644 continue; 688 continue;
645 } 689 }
646 let elapsed = (self.clock.now() - round.started_at).max(0.0); 690 let now = self.clock.now();
647 if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { 691 let elapsed = (now - round.started_at).max(0.0);
692 let multiplier = crate::crash::multiplier_at_secs(elapsed);
693 if multiplier < round.crash_point {
694 let last = self
695 .last_tick
696 .get(&session.session_id)
697 .copied()
698 .unwrap_or(f64::NEG_INFINITY);
699 if now - last >= 0.1 {
700 self.last_tick.insert(session.session_id.clone(), now);
701 out.push(format!(
702 "crash.tick session={} t={} m={multiplier:.4}",
703 session.session_id,
704 unix_time_ms()
705 ));
706 }
648 continue; 707 continue;
649 } 708 }
650 round.crash(); 709 round.crash();
710 self.last_tick.remove(&session.session_id);
651 match self.settle_crash(user, &session.session_id, &round, 0) { 711 match self.settle_crash(user, &session.session_id, &round, 0) {
652 Ok(balance) => { 712 Ok(balance) => {
653 let state = round.state(&session.session_id, balance); 713 let state = round.state(&session.session_id, balance);
@@ -678,6 +738,7 @@ impl ServerCore {
678 payout as i64, 738 payout as i64,
679 &details, 739 &details,
680 ); 740 );
741 self.last_tick.remove(session_id);
681 let _ = self.sessions.remove_session(session_id); 742 let _ = self.sessions.remove_session(session_id);
682 result.map_err(|error| error.to_string()) 743 result.map_err(|error| error.to_string())
683 } 744 }
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs
index ba73c7f..1728afc 100644
--- a/src/server/games/crash.rs
+++ b/src/server/games/crash.rs
@@ -1,5 +1,8 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; 1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::crash::{crash_point_from_uniform, payout, CrashState}; 2use crate::crash::{
3 CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform,
4};
5use rand::RngCore;
3use serde::{Deserialize, Serialize}; 6use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, VecDeque}; 7use std::collections::{HashMap, VecDeque};
5use std::sync::{Arc, Mutex}; 8use std::sync::{Arc, Mutex};
@@ -64,7 +67,14 @@ pub struct CrashRound {
64 pub bet: u64, 67 pub bet: u64,
65 /// Drawn at start, revealed only when the round ends. 68 /// Drawn at start, revealed only when the round ends.
66 pub crash_point: f64, 69 pub crash_point: f64,
70 /// The 32 random bytes the crash point commits to, hex encoded. Kept on
71 /// the server until the round ends.
72 pub seed: String,
73 /// `sha256(seed)` in hex, sent with the start reply.
74 pub commit: String,
67 pub started_at: f64, 75 pub started_at: f64,
76 /// Server Unix time at start, milliseconds, for clock sync.
77 pub started_at_ms: u64,
68 /// `running`, `cashed`, `crashed` or `forfeit`. 78 /// `running`, `cashed`, `crashed` or `forfeit`.
69 pub phase: String, 79 pub phase: String,
70 pub result: String, 80 pub result: String,
@@ -74,11 +84,15 @@ pub struct CrashRound {
74} 84}
75 85
76impl CrashRound { 86impl CrashRound {
77 pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { 87 pub fn new(bet: u64, seed: [u8; SEED_LEN], started_at: f64, started_at_ms: u64) -> Self {
88 let crash_point = crash_point_from_seed(&seed);
78 Self { 89 Self {
79 bet, 90 bet,
80 crash_point, 91 crash_point,
92 seed: seed_hex(&seed),
93 commit: commit_seed(&seed),
81 started_at, 94 started_at,
95 started_at_ms,
82 phase: "running".to_string(), 96 phase: "running".to_string(),
83 result: "playing".to_string(), 97 result: "playing".to_string(),
84 multiplier: 1.0, 98 multiplier: 1.0,
@@ -120,6 +134,7 @@ impl CrashRound {
120 balance, 134 balance,
121 multiplier: self.multiplier, 135 multiplier: self.multiplier,
122 potential: payout(self.bet, self.multiplier), 136 potential: payout(self.bet, self.multiplier),
137 started_at: self.started_at_ms,
123 crash_point: if self.is_running() { 138 crash_point: if self.is_running() {
124 None 139 None
125 } else { 140 } else {
@@ -127,6 +142,12 @@ impl CrashRound {
127 }, 142 },
128 payout: self.payout, 143 payout: self.payout,
129 result: self.result.clone(), 144 result: self.result.clone(),
145 commit: Some(self.commit.clone()),
146 seed: if self.is_running() {
147 None
148 } else {
149 Some(self.seed.clone())
150 },
130 } 151 }
131 } 152 }
132 153
@@ -146,6 +167,34 @@ pub struct CrashGame {
146 uniforms: VecDeque<f64>, 167 uniforms: VecDeque<f64>,
147} 168}
148 169
170/// Hex encode a round seed. Lowercase, no separators.
171fn seed_hex(seed: &[u8]) -> String {
172 let mut out = String::with_capacity(seed.len() * 2);
173 for byte in seed {
174 use std::fmt::Write as _;
175 let _ = write!(out, "{byte:02x}");
176 }
177 out
178}
179
180/// Draw a seed whose committed point matches a queued test uniform.
181///
182/// The first eight bytes are the big-endian integer closest to
183/// `uniform * 2^64`; the remaining bytes are zero. This keeps the existing
184/// deterministic crash-point tests meaningful while every round still has a
185/// real seed.
186fn seed_from_uniform(uniform: f64) -> [u8; SEED_LEN] {
187 let uniform = if uniform.is_finite() {
188 uniform.clamp(0.0, 0.999_999_999_999_999_9)
189 } else {
190 0.0
191 };
192 let value = (uniform * 18_446_744_073_709_551_616.0) as u64;
193 let mut seed = [0u8; SEED_LEN];
194 seed[..8].copy_from_slice(&value.to_be_bytes());
195 seed
196}
197
149impl CrashGame { 198impl CrashGame {
150 pub fn new() -> Self { 199 pub fn new() -> Self {
151 Self { 200 Self {
@@ -163,12 +212,25 @@ impl CrashGame {
163 } 212 }
164 } 213 }
165 214
215 /// Draw the next round seed. Tests supply uniforms; production uses the
216 /// operating system's random source.
217 pub fn next_seed(&mut self) -> [u8; SEED_LEN] {
218 if let Some(uniform) = self.uniforms.pop_front() {
219 return seed_from_uniform(uniform);
220 }
221 let mut seed = [0u8; SEED_LEN];
222 rand::thread_rng().fill_bytes(&mut seed);
223 seed
224 }
225
226 /// Kept for callers that only need the point a queued uniform encodes.
166 pub fn next_crash_point(&mut self) -> f64 { 227 pub fn next_crash_point(&mut self) -> f64 {
167 let uniform = self 228 crash_point_from_seed(&self.next_seed())
168 .uniforms 229 }
169 .pop_front() 230
170 .unwrap_or_else(rand::random::<f64>); 231 /// The uniform encoded by a seed, exposed for verification tests.
171 crash_point_from_uniform(uniform) 232 pub fn uniform_of(seed: &[u8; SEED_LEN]) -> f64 {
233 seed_to_uniform(seed)
172 } 234 }
173} 235}
174 236
diff --git a/src/version.rs b/src/version.rs
index c1dcee5..adc2a8d 100644
--- a/src/version.rs
+++ b/src/version.rs
@@ -5,6 +5,7 @@
5//! accepted until the server has answered it. 5//! accepted until the server has answered it.
6 6
7use std::env; 7use std::env;
8use std::time::{SystemTime, UNIX_EPOCH};
8 9
9/// Version this build reports. The client and the server share it. 10/// Version this build reports. The client and the server share it.
10pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -73,9 +74,9 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
73 } 74 }
74} 75}
75 76
76/// The server line that accepts a handshake. 77/// The server line that accepts a handshake. `time` is Unix milliseconds.
77pub fn hello_line(version: &str, latest: &str, url: &str) -> String { 78pub fn hello_line(version: &str, latest: &str, url: &str, time: u64) -> String {
78 format!("HELLO {version} latest={latest} url={url}") 79 format!("HELLO {version} latest={latest} url={url} time={time}")
79} 80}
80 81
81#[derive(Debug, Clone, PartialEq, Eq)] 82#[derive(Debug, Clone, PartialEq, Eq)]
@@ -83,9 +84,11 @@ pub struct HelloReply {
83 pub version: String, 84 pub version: String,
84 pub latest: String, 85 pub latest: String,
85 pub url: String, 86 pub url: String,
87 /// Server clock at the moment the reply was built, Unix milliseconds.
88 pub time: u64,
86} 89}
87 90
88/// Parse `HELLO <version> latest=<version> url=<url>`. 91/// Parse `HELLO <version> latest=<version> url=<url> time=<unix_ms>`.
89pub fn parse_hello(line: &str) -> Option<HelloReply> { 92pub fn parse_hello(line: &str) -> Option<HelloReply> {
90 let rest = line.trim().strip_prefix("HELLO ")?; 93 let rest = line.trim().strip_prefix("HELLO ")?;
91 let mut tokens = rest.split_whitespace(); 94 let mut tokens = rest.split_whitespace();
@@ -93,11 +96,14 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> {
93 96
94 let mut latest = None; 97 let mut latest = None;
95 let mut url = None; 98 let mut url = None;
99 let mut time = None;
96 for token in tokens { 100 for token in tokens {
97 if let Some(value) = token.strip_prefix("latest=") { 101 if let Some(value) = token.strip_prefix("latest=") {
98 latest = Some(value.to_string()); 102 latest = Some(value.to_string());
99 } else if let Some(value) = token.strip_prefix("url=") { 103 } else if let Some(value) = token.strip_prefix("url=") {
100 url = Some(value.to_string()); 104 url = Some(value.to_string());
105 } else if let Some(value) = token.strip_prefix("time=") {
106 time = value.parse().ok();
101 } 107 }
102 } 108 }
103 109
@@ -105,9 +111,29 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> {
105 version, 111 version,
106 latest: latest?, 112 latest: latest?,
107 url: url?, 113 url: url?,
114 time: time?,
108 }) 115 })
109} 116}
110 117
118/// The reply to `PING`: `PONG <unix_ms>`.
119pub fn pong_line(time: u64) -> String {
120 format!("PONG {time}")
121}
122
123/// Parse a `PONG` reply's server time.
124pub fn parse_pong(line: &str) -> Option<u64> {
125 line.trim().strip_prefix("PONG ")?.trim().parse().ok()
126}
127
128/// Current Unix time in milliseconds. Used for handshake timestamps and crash
129/// anchors; a system clock before the epoch reads as zero.
130pub fn unix_time_ms() -> u64 {
131 SystemTime::now()
132 .duration_since(UNIX_EPOCH)
133 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
134 .unwrap_or(0)
135}
136
111/// Download page advertised by the server. `DOWNLOAD_URL` wins when set. 137/// Download page advertised by the server. `DOWNLOAD_URL` wins when set.
112pub fn download_url() -> String { 138pub fn download_url() -> String {
113 env::var("DOWNLOAD_URL") 139 env::var("DOWNLOAD_URL")