diff options
Diffstat (limited to '')
| -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 |
5 files changed, 310 insertions, 133 deletions
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), |
