diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/client/connection.rs | 6 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 95 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 47 | ||||
| -rw-r--r-- | src/bin/server/tls.rs | 27 | ||||
| -rw-r--r-- | src/client/core.rs | 182 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 21 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 9 | ||||
| -rw-r--r-- | src/client/transport.rs | 171 | ||||
| -rw-r--r-- | src/client/view.rs | 60 | ||||
| -rw-r--r-- | src/crash.rs | 54 | ||||
| -rw-r--r-- | src/protocol.rs | 226 | ||||
| -rw-r--r-- | src/server/core.rs | 81 | ||||
| -rw-r--r-- | src/server/games/crash.rs | 76 | ||||
| -rw-r--r-- | src/version.rs | 34 |
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; | |||
| 8 | use std::error::Error; | 8 | use std::error::Error; |
| 9 | use zagan_bet::client::transport::{self, TrustPrompt}; | 9 | use zagan_bet::client::transport::{self, TrustPrompt}; |
| 10 | use zagan_bet::client::ui::first_contact_lines; | 10 | use zagan_bet::client::ui::first_contact_lines; |
| 11 | use zagan_bet::version::{is_newer, VERSION}; | 11 | use zagan_bet::version::{VERSION, is_newer}; |
| 12 | 12 | ||
| 13 | pub use zagan_bet::client::transport::TlsStream; | 13 | pub use zagan_bet::client::transport::TlsStream; |
| 14 | 14 | ||
| @@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { | |||
| 21 | pub fn connect( | 21 | pub 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::{ | |||
| 14 | use sha2::{Digest, Sha256}; | 14 | use sha2::{Digest, Sha256}; |
| 15 | 15 | ||
| 16 | use zagan_bet::client::core::{ClientCore, Effect}; | 16 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 17 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | ||
| 18 | use zagan_bet::client::shell::{self, Shell}; | ||
| 19 | use zagan_bet::client::view::{RoulettePhase, View}; | ||
| 20 | use zagan_bet::client::database; | 17 | use zagan_bet::client::database; |
| 18 | use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; | ||
| 19 | use zagan_bet::client::shell::{self, Shell}; | ||
| 21 | use zagan_bet::client::ui::{ | 20 | use 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 | }; |
| 25 | use zagan_bet::protocol::io_failure_message; | 24 | use zagan_bet::client::view::{RoulettePhase, View}; |
| 25 | use zagan_bet::protocol::{Packet, io_failure_message}; | ||
| 26 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; | 26 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; |
| 27 | 27 | ||
| 28 | use crate::connection::TlsStream; | 28 | use 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 @@ | |||
| 1 | use std::time::Duration; | 1 | use std::time::Duration; |
| 2 | 2 | ||
| 3 | use crate::tls::TlsStream; | 3 | use crate::tls::TlsStream; |
| 4 | use zagan_bet::protocol::{event_line, parse_request, reply_line}; | ||
| 4 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 5 | use 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 @@ | |||
| 1 | use std::io::{Read, Write}; | 1 | use std::io::{Read, Write}; |
| 2 | use std::net::TcpStream; | 2 | use std::net::TcpStream; |
| 3 | 3 | ||
| 4 | use zagan_bet::protocol::LineBuffer; | 4 | use zagan_bet::protocol::{FrameBuffer, write_frame}; |
| 5 | 5 | ||
| 6 | #[derive(Clone)] | 6 | #[derive(Clone)] |
| 7 | pub struct TlsServer { | 7 | pub struct TlsServer { |
| @@ -44,34 +44,31 @@ impl TlsServer { | |||
| 44 | 44 | ||
| 45 | pub struct TlsStream { | 45 | pub struct TlsStream { |
| 46 | stream: native_tls::TlsStream<TcpStream>, | 46 | stream: native_tls::TlsStream<TcpStream>, |
| 47 | lines: LineBuffer, | 47 | frames: FrameBuffer, |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | impl TlsStream { | 50 | impl 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 | }; |
| 6 | use crate::client::ui::{game_lines, history_lines}; | ||
| 6 | use crate::client::view::{ | 7 | use crate::client::view::{ |
| 7 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, | 8 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, |
| 8 | }; | 9 | }; |
| 9 | use crate::client::ui::{game_lines, history_lines}; | ||
| 10 | use crate::crash::{CrashState, multiplier_at}; | 10 | use crate::crash::{CrashState, multiplier_at}; |
| 11 | use crate::history::{PlayRecord, MAX_HISTORY}; | 11 | use crate::history::{MAX_HISTORY, PlayRecord}; |
| 12 | use crate::roulette::{BetTarget, Bets}; | 12 | use crate::roulette::{BetTarget, Bets}; |
| 13 | use crate::version::{parse_pong, unix_time_ms}; | ||
| 13 | use std::time::{Duration, Instant}; | 14 | use 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 | ||
| 815 | fn auth_result(reply: &str) -> Result<(), String> { | 903 | 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; | |||
| 5 | use super::slots::SlotsGame; | 5 | use super::slots::SlotsGame; |
| 6 | use super::traits::{Game, GameError, GamePhase, GameState}; | 6 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 7 | use crate::blackjack::BlackjackState; | 7 | use crate::blackjack::BlackjackState; |
| 8 | use crate::crash::{payout, CrashState}; | 8 | use crate::crash::{CrashState, payout}; |
| 9 | use crate::roulette::Bets; | 9 | use crate::roulette::Bets; |
| 10 | use std::collections::HashMap; | 10 | use 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::client::view::{RoulettePhase, RouletteView}; | 2 | use crate::client::view::{RoulettePhase, RouletteView}; |
| 3 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; | 3 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; |
| 4 | use serde::{Deserialize, Serialize}; | ||
| 4 | 5 | ||
| 5 | pub const ROULETTE_BOARD_LINES: usize = 12; | 6 | pub const ROULETTE_BOARD_LINES: usize = 12; |
| 6 | 7 | ||
| @@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [ | |||
| 21 | 22 | ||
| 22 | pub struct RouletteGame; | 23 | pub struct RouletteGame; |
| 23 | 24 | ||
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 25 | pub enum Direction { | 26 | pub 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)] |
| 45 | pub enum Cursor { | 46 | pub 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 @@ | |||
| 7 | use std::fmt; | 7 | use std::fmt; |
| 8 | use std::io::{Read, Write}; | 8 | use std::io::{Read, Write}; |
| 9 | use std::net::TcpStream; | 9 | use std::net::TcpStream; |
| 10 | use std::sync::mpsc; | ||
| 11 | use std::sync::Arc; | 10 | use std::sync::Arc; |
| 11 | use std::sync::mpsc; | ||
| 12 | use std::thread; | 12 | use std::thread; |
| 13 | use std::time::{Duration, Instant}; | 13 | use std::time::{Duration, Instant}; |
| 14 | 14 | ||
| 15 | use parking_lot::Mutex; | 15 | use parking_lot::Mutex; |
| 16 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 17 | use rustls::DigitallySignedStruct; | ||
| 18 | use rusqlite::Connection; | 16 | use rusqlite::Connection; |
| 17 | use rustls::DigitallySignedStruct; | ||
| 18 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 19 | 19 | ||
| 20 | use crate::client::database; | 20 | use crate::client::database; |
| 21 | use crate::protocol::LineBuffer; | 21 | use crate::protocol::{ |
| 22 | use crate::tofu::{decide, spki_fingerprint, TrustDecision}; | 22 | FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame, |
| 23 | use crate::version::{parse_hello, HelloReply, VERSION}; | 23 | }; |
| 24 | use crate::tofu::{TrustDecision, decide, spki_fingerprint}; | ||
| 25 | use 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. | ||
| 29 | const HELLO_REQUEST_ID: u64 = u64::MAX; | ||
| 30 | const 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. |
| 26 | const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); | 33 | const 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 { | |||
| 51 | pub enum TransportError { | 60 | pub 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. | ||
| 181 | fn 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. |
| 177 | fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { | 195 | fn 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 { | |||
| 313 | struct Session { | 328 | struct 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 | ||
| 331 | impl TlsStream { | 346 | impl 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; | |||
| 7 | use crate::client::games::roulette::{Cursor, Direction}; | 7 | use crate::client::games::roulette::{Cursor, Direction}; |
| 8 | use crate::client::games::traits::{GamePhase, GameState}; | 8 | use crate::client::games::traits::{GamePhase, GameState}; |
| 9 | use crate::roulette::{BetTarget, Bets}; | 9 | use crate::roulette::{BetTarget, Bets}; |
| 10 | use 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")] | ||
| 12 | pub enum RoulettePhase { | 14 | pub 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)] |
| 19 | pub struct RouletteView { | 21 | pub 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")] | ||
| 86 | pub enum SlotsPhase { | 92 | pub 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)] |
| 93 | pub struct SlotsView { | 99 | pub 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)] |
| 153 | pub struct BlackjackView { | 159 | pub 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)] |
| 190 | pub struct CrashView { | 196 | pub 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)] |
| 246 | pub struct LobbyView { | 264 | pub 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)] |
| 258 | pub struct HistoryView { | 276 | pub 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")] | ||
| 264 | pub enum View { | 283 | pub 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)] |
| 275 | pub enum Command { | 294 | pub 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 | ||
| 8 | use serde::{Deserialize, Serialize}; | 8 | use serde::{Deserialize, Serialize}; |
| 9 | use sha2::{Digest, Sha256}; | ||
| 9 | use std::time::Duration; | 10 | use 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. | ||
| 53 | pub const SEED_LEN: usize = 32; | ||
| 54 | |||
| 55 | /// Hex-encoded SHA-256 of a round seed, sent before the round starts. | ||
| 56 | pub 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. | ||
| 62 | pub 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. | ||
| 71 | pub 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. | ||
| 77 | pub 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. | ||
| 91 | pub 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. |
| 51 | pub fn payout(bet: u64, multiplier: f64) -> u64 { | 99 | pub 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 | ||
| 81 | impl CrashState { | 135 | impl 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 | |||
| 17 | use std::fmt; | ||
| 18 | use 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. | ||
| 22 | pub const MAX_FRAME_LEN: usize = 64 * 1024; | ||
| 23 | |||
| 24 | /// How many request ids a connection remembers for idempotent retries. | ||
| 25 | pub const REPLAY_CACHE_LEN: usize = 256; | ||
| 26 | |||
| 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 28 | pub 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 | |||
| 35 | impl 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 | ||
| 6 | use std::io::{self, Read}; | 49 | impl 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)] |
| 9 | pub struct LineBuffer { | 53 | pub struct FrameBuffer { |
| 10 | buffer: Vec<u8>, | 54 | buffer: Vec<u8>, |
| 11 | } | 55 | } |
| 12 | 56 | ||
| 13 | impl LineBuffer { | 57 | impl 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. | ||
| 126 | pub 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. | ||
| 135 | pub 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)] | ||
| 143 | pub 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)] | ||
| 151 | pub enum Packet { | ||
| 152 | Reply { id: u64, body: String }, | ||
| 153 | Event { seq: u64, body: String }, | ||
| 154 | } | ||
| 155 | |||
| 156 | pub 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 | |||
| 163 | pub fn reply_line(id: u64, body: &str) -> String { | ||
| 164 | format!("RES {id} {body}") | ||
| 165 | } | ||
| 166 | |||
| 167 | pub 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. | ||
| 172 | pub 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. | ||
| 201 | pub 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 | |||
| 226 | fn 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 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | 1 | use bcrypt::{DEFAULT_COST, hash, verify}; |
| 2 | use rusqlite::Connection; | 2 | use rusqlite::Connection; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::{HashMap, VecDeque}; |
| 4 | use std::sync::Arc; | 4 | use std::sync::Arc; |
| 5 | 5 | ||
| 6 | use crate::blackjack::BlackjackState; | 6 | use crate::blackjack::BlackjackState; |
| 7 | use crate::crash::CrashState; | 7 | use crate::crash::CrashState; |
| 8 | use crate::protocol::{REPLAY_CACHE_LEN, Request}; | ||
| 8 | use crate::server::database; | 9 | use crate::server::database; |
| 9 | use crate::version::{download_url, hello_line, Version, VERSION}; | ||
| 10 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; | 10 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; |
| 11 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; | 11 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; |
| 12 | use crate::server::games::registry::GameRegistry; | 12 | use crate::server::games::registry::GameRegistry; |
| 13 | use crate::server::games::session::SessionManager; | 13 | use crate::server::games::session::SessionManager; |
| 14 | use crate::server::games::traits::{GameError, GamePhase, GameState}; | 14 | use crate::server::games::traits::{GameError, GamePhase, GameState}; |
| 15 | use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms}; | ||
| 15 | 16 | ||
| 16 | #[derive(Debug, Default)] | 17 | #[derive(Debug, Default)] |
| 17 | pub struct ConnectionState { | 18 | pub 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 | ||
| 44 | pub struct ServerCore { | 69 | pub 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 | ||
| 54 | impl ServerCore { | 81 | impl 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 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; |
| 2 | use crate::crash::{crash_point_from_uniform, payout, CrashState}; | 2 | use crate::crash::{ |
| 3 | CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform, | ||
| 4 | }; | ||
| 5 | use rand::RngCore; | ||
| 3 | use serde::{Deserialize, Serialize}; | 6 | use serde::{Deserialize, Serialize}; |
| 4 | use std::collections::{HashMap, VecDeque}; | 7 | use std::collections::{HashMap, VecDeque}; |
| 5 | use std::sync::{Arc, Mutex}; | 8 | use 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 | ||
| 76 | impl CrashRound { | 86 | impl 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. | ||
| 171 | fn 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. | ||
| 186 | fn 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 | |||
| 149 | impl CrashGame { | 198 | impl 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 | ||
| 7 | use std::env; | 7 | use std::env; |
| 8 | use 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. |
| 10 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); | 11 | pub 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. |
| 77 | pub fn hello_line(version: &str, latest: &str, url: &str) -> String { | 78 | pub 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>`. |
| 89 | pub fn parse_hello(line: &str) -> Option<HelloReply> { | 92 | pub 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>`. | ||
| 119 | pub fn pong_line(time: u64) -> String { | ||
| 120 | format!("PONG {time}") | ||
| 121 | } | ||
| 122 | |||
| 123 | /// Parse a `PONG` reply's server time. | ||
| 124 | pub 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. | ||
| 130 | pub 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. |
| 112 | pub fn download_url() -> String { | 138 | pub fn download_url() -> String { |
| 113 | env::var("DOWNLOAD_URL") | 139 | env::var("DOWNLOAD_URL") |
