summaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/client/core.rs182
-rw-r--r--src/client/games/manager.rs21
-rw-r--r--src/client/games/roulette.rs9
-rw-r--r--src/client/transport.rs171
-rw-r--r--src/client/view.rs60
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};
6use crate::client::ui::{game_lines, history_lines};
6use crate::client::view::{ 7use crate::client::view::{
7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 8 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
8}; 9};
9use crate::client::ui::{game_lines, history_lines};
10use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
11use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{MAX_HISTORY, PlayRecord};
12use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
13use crate::version::{parse_pong, unix_time_ms};
13use std::time::{Duration, Instant}; 14use std::time::{Duration, Instant};
14 15
15
16
17/// High-level actions produced by the client core. The terminal adapter is 16/// High-level actions produced by the client core. The terminal adapter is
18/// responsible for turning these into crossterm calls and network writes. 17/// responsible for turning these into crossterm calls and network writes.
19#[derive(Debug, Clone, PartialEq)] 18#[derive(Debug, Clone, PartialEq)]
@@ -24,8 +23,8 @@ pub enum Effect {
24 Render(View), 23 Render(View),
25 /// Remove the game board region. 24 /// Remove the game board region.
26 ClearRegion, 25 ClearRegion,
27 /// Send one protocol line and feed the reply to `handle_reply`. 26 /// Send one enveloped request and route its reply back by `id`.
28 SendLine(String), 27 SendRequest { id: u64, payload: String },
29 /// Drop the connection. 28 /// Drop the connection.
30 Disconnect, 29 Disconnect,
31 /// Leave the program. 30 /// Leave the program.
@@ -61,6 +60,10 @@ pub struct ClientCore {
61 connected: bool, 60 connected: bool,
62 logged_in: Option<String>, 61 logged_in: Option<String>,
63 pending: Option<Pending>, 62 pending: Option<Pending>,
63 /// Id of the request whose reply fills `pending`.
64 pending_id: Option<u64>,
65 /// Next correlation id allocated on this connection.
66 next_request_id: u64,
64 balance: Option<i64>, 67 balance: Option<i64>,
65 /// Account number from the last `INFO`, shown by both frontends. 68 /// Account number from the last `INFO`, shown by both frontends.
66 account: Option<String>, 69 account: Option<String>,
@@ -68,6 +71,13 @@ pub struct ClientCore {
68 fingerprint: Option<String>, 71 fingerprint: Option<String>,
69 /// Local anchor for the crash multiplier, set when a round starts. 72 /// Local anchor for the crash multiplier, set when a round starts.
70 crash_started: Option<Instant>, 73 crash_started: Option<Instant>,
74 /// Server-time anchor for the crash multiplier, when the server supplied
75 /// `started_at`.
76 crash_started_ms: Option<u64>,
77 /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`.
78 server_offset_ms: i64,
79 /// True once a frontend has supplied a server timestamp.
80 clock_synced: bool,
71 /// App screen shown when no game is open. 81 /// App screen shown when no game is open.
72 screen: Screen, 82 screen: Screen,
73 /// Last plays fetched with `history`. 83 /// Last plays fetched with `history`.
@@ -83,11 +93,16 @@ impl ClientCore {
83 connected: false, 93 connected: false,
84 logged_in: None, 94 logged_in: None,
85 pending: None, 95 pending: None,
96 pending_id: None,
97 next_request_id: 1,
86 balance: None, 98 balance: None,
87 account: None, 99 account: None,
88 host: None, 100 host: None,
89 fingerprint: None, 101 fingerprint: None,
90 crash_started: None, 102 crash_started: None,
103 crash_started_ms: None,
104 server_offset_ms: 0,
105 clock_synced: false,
91 screen: Screen::Lobby, 106 screen: Screen::Lobby,
92 play_history: Vec::new(), 107 play_history: Vec::new(),
93 login_error: None, 108 login_error: None,
@@ -99,11 +114,19 @@ impl ClientCore {
99 if !connected { 114 if !connected {
100 self.logged_in = None; 115 self.logged_in = None;
101 self.pending = None; 116 self.pending = None;
117 self.pending_id = None;
102 self.crash_started = None; 118 self.crash_started = None;
119 self.crash_started_ms = None;
120 self.clock_synced = false;
103 self.screen = Screen::Lobby; 121 self.screen = Screen::Lobby;
104 } 122 }
105 } 123 }
106 124
125 /// The signed-in username, if any.
126 pub fn user(&self) -> Option<&str> {
127 self.logged_in.as_deref()
128 }
129
107 pub fn set_logged_in(&mut self, user: Option<String>) { 130 pub fn set_logged_in(&mut self, user: Option<String>) {
108 if user.is_some() { 131 if user.is_some() {
109 self.login_error = None; 132 self.login_error = None;
@@ -147,7 +170,44 @@ impl ClientCore {
147 self.board_effects() 170 self.board_effects()
148 } 171 }
149 172
173 /// Record the server's clock and the local time it was received. The
174 /// offset is for display; cash-out still settles on the server.
175 pub fn set_server_time(&mut self, server_ms: u64) {
176 self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64;
177 self.clock_synced = true;
178 }
179
180 /// The game server's clock, as this client currently estimates it.
181 pub fn server_now_ms(&self) -> u64 {
182 (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64
183 }
184
185 /// Allocate a correlation id for a request the adapter sends directly
186 /// (the terminal's login prompt bypasses `ClientCore`).
187 pub fn allocate_request_id(&mut self) -> u64 {
188 let id = self.next_request_id;
189 self.next_request_id = self.next_request_id.wrapping_add(1).max(1);
190 id
191 }
192
193 fn begin_request(&mut self, pending: Pending, payload: String) -> Effect {
194 let id = self.allocate_request_id();
195 self.pending = Some(pending);
196 self.pending_id = Some(id);
197 Effect::SendRequest { id, payload }
198 }
199
200 /// Route a reply by correlation id. A reply we no longer expect (a late
201 /// response after a timeout) is ignored instead of corrupting the board.
202 pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec<Effect> {
203 if self.pending_id != Some(id) {
204 return Vec::new();
205 }
206 self.handle_reply(reply)
207 }
208
150 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { 209 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> {
210 self.pending_id = None;
151 match self.pending.take() { 211 match self.pending.take() {
152 Some(Pending::Spin) => match parse_spin_response(reply) { 212 Some(Pending::Spin) => match parse_spin_response(reply) {
153 Ok(result) => { 213 Ok(result) => {
@@ -265,7 +325,8 @@ impl ClientCore {
265 } 325 }
266 }, 326 },
267 Some(Pending::Ping) => { 327 Some(Pending::Ping) => {
268 if reply.trim() == "PONG" { 328 if let Some(server_ms) = parse_pong(reply) {
329 self.set_server_time(server_ms);
269 print_line("Pong") 330 print_line("Pong")
270 } else { 331 } else {
271 self.connected = false; 332 self.connected = false;
@@ -305,10 +366,8 @@ impl ClientCore {
305 if self.game.begin_spin().is_err() { 366 if self.game.begin_spin().is_err() {
306 return Vec::new(); 367 return Vec::new();
307 } 368 }
308 self.pending = Some(Pending::Spin);
309
310 let mut effects = self.board_effects(); 369 let mut effects = self.board_effects();
311 effects.push(Effect::SendLine(request)); 370 effects.push(self.begin_request(Pending::Spin, request));
312 effects 371 effects
313 } 372 }
314 373
@@ -322,24 +381,24 @@ impl ClientCore {
322 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 381 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
323 } 382 }
324 self.login_error = None; 383 self.login_error = None;
325 self.pending = Some(Pending::Login { user: user.clone() }); 384 vec![self.begin_request(
326 vec![Effect::SendLine(format!("AUTH {user} {credential}"))] 385 Pending::Login { user: user.clone() },
386 format!("AUTH {user} {credential}"),
387 )]
327 } 388 }
328 Command::Register { user, credential } => { 389 Command::Register { user, credential } => {
329 if !self.connected { 390 if !self.connected {
330 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 391 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
331 } 392 }
332 self.login_error = None; 393 self.login_error = None;
333 self.pending = Some(Pending::Register); 394 vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))]
334 vec![Effect::SendLine(format!("REGISTER {user} {credential}"))]
335 } 395 }
336 Command::Games => vec![Effect::Print(game_lines(&self.games()))], 396 Command::Games => vec![Effect::Print(game_lines(&self.games()))],
337 Command::Ping => { 397 Command::Ping => {
338 if !self.connected { 398 if !self.connected {
339 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 399 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
340 } 400 }
341 self.pending = Some(Pending::Ping); 401 vec![self.begin_request(Pending::Ping, "PING".to_string())]
342 vec![Effect::SendLine("PING".to_string())]
343 } 402 }
344 Command::Echo(message) => { 403 Command::Echo(message) => {
345 if !self.connected { 404 if !self.connected {
@@ -348,8 +407,7 @@ impl ClientCore {
348 if message.trim().is_empty() { 407 if message.trim().is_empty() {
349 return print_line("Usage: send <message>"); 408 return print_line("Usage: send <message>");
350 } 409 }
351 self.pending = Some(Pending::Echo); 410 vec![self.begin_request(Pending::Echo, message)]
352 vec![Effect::SendLine(message)]
353 } 411 }
354 Command::Info => self.info_effects(), 412 Command::Info => self.info_effects(),
355 Command::Play { game, bet } => self.play_game(&game, bet), 413 Command::Play { game, bet } => self.play_game(&game, bet),
@@ -458,6 +516,7 @@ impl ClientCore {
458 516
459 fn exit_game(&mut self) -> Vec<Effect> { 517 fn exit_game(&mut self) -> Vec<Effect> {
460 self.crash_started = None; 518 self.crash_started = None;
519 self.crash_started_ms = None;
461 self.screen = Screen::Lobby; 520 self.screen = Screen::Lobby;
462 self.game.end_game(); 521 self.game.end_game();
463 vec![ 522 vec![
@@ -475,19 +534,17 @@ impl ClientCore {
475 return self.exit_game(); 534 return self.exit_game();
476 }; 535 };
477 self.game.set_blackjack_status("forfeiting"); 536 self.game.set_blackjack_status("forfeiting");
478 self.pending = Some(Pending::BlackjackForfeit); 537 vec![self.begin_request(
479 vec![Effect::SendLine(format!( 538 Pending::BlackjackForfeit,
480 "BLACKJACK_ACTION {} forfeit", 539 format!("BLACKJACK_ACTION {} forfeit", hand.session_id),
481 hand.session_id 540 )]
482 ))]
483 } 541 }
484 542
485 fn forfeit_crash(&mut self) -> Vec<Effect> { 543 fn forfeit_crash(&mut self) -> Vec<Effect> {
486 let Some(session) = self.game.crash_session_id() else { 544 let Some(session) = self.game.crash_session_id() else {
487 return self.exit_game(); 545 return self.exit_game();
488 }; 546 };
489 self.pending = Some(Pending::CrashForfeit); 547 vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))]
490 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
491 } 548 }
492 549
493 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { 550 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> {
@@ -533,10 +590,8 @@ impl ClientCore {
533 if self.game.begin_spin().is_err() { 590 if self.game.begin_spin().is_err() {
534 return Vec::new(); 591 return Vec::new();
535 } 592 }
536 self.pending = Some(Pending::RouletteSpin);
537
538 let mut effects = self.board_effects(); 593 let mut effects = self.board_effects();
539 effects.push(Effect::SendLine(request)); 594 effects.push(self.begin_request(Pending::RouletteSpin, request));
540 effects 595 effects
541 } 596 }
542 597
@@ -566,9 +621,8 @@ impl ClientCore {
566 } 621 }
567 622
568 self.game.set_blackjack_status("dealing"); 623 self.game.set_blackjack_status("dealing");
569 self.pending = Some(Pending::BlackjackStart);
570 let mut effects = self.board_effects(); 624 let mut effects = self.board_effects();
571 effects.push(Effect::SendLine(request)); 625 effects.push(self.begin_request(Pending::BlackjackStart, request));
572 effects 626 effects
573 } 627 }
574 628
@@ -582,15 +636,21 @@ impl ClientCore {
582 if action == "double" && !hand.actions.iter().any(|available| available == "double") { 636 if action == "double" && !hand.actions.iter().any(|available| available == "double") {
583 return Vec::new(); 637 return Vec::new();
584 } 638 }
585 self.pending = Some(Pending::BlackjackAction); 639 vec![self.begin_request(
586 vec![Effect::SendLine(format!( 640 Pending::BlackjackAction,
587 "BLACKJACK_ACTION {} {}", 641 format!("BLACKJACK_ACTION {} {}", hand.session_id, action),
588 hand.session_id, action 642 )]
589 ))]
590 } 643 }
591 644
592 /// Redraw the rising multiplier. Called by the terminal loop on a timer. 645 /// Redraw the rising multiplier. Called by the terminal loop on a timer.
593 pub fn tick(&mut self) -> Vec<Effect> { 646 pub fn tick(&mut self) -> Vec<Effect> {
647 if self.clock_synced {
648 if let Some(started_ms) = self.crash_started_ms {
649 let now = unix_time_ms() as i64 + self.server_offset_ms;
650 let elapsed_ms = (now - started_ms as i64).max(0) as u64;
651 return self.tick_at(Duration::from_millis(elapsed_ms));
652 }
653 }
594 let Some(started) = self.crash_started else { 654 let Some(started) = self.crash_started else {
595 return Vec::new(); 655 return Vec::new();
596 }; 656 };
@@ -619,12 +679,16 @@ impl ClientCore {
619 } 679 }
620 680
621 pub fn is_async(&self, line: &str) -> bool { 681 pub fn is_async(&self, line: &str) -> bool {
622 line.trim_start().starts_with("CRASH_BUSTED ") 682 let line = line.trim_start();
683 line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ")
623 } 684 }
624 685
625 /// Handle a server-initiated line. Returns `None` when the line is a normal 686 /// Handle a server-initiated line. Returns `None` when the line is a normal
626 /// request/response reply and belongs to `handle_reply`. 687 /// request/response reply and belongs to `handle_reply`.
627 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { 688 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> {
689 if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") {
690 return Some(self.apply_crash_tick(tick));
691 }
628 if !self.is_async(line) { 692 if !self.is_async(line) {
629 return None; 693 return None;
630 } 694 }
@@ -643,13 +707,43 @@ impl ClientCore {
643 if self.crash_started.is_none() { 707 if self.crash_started.is_none() {
644 self.crash_started = Some(Instant::now()); 708 self.crash_started = Some(Instant::now());
645 } 709 }
710 if state.started_at > 0 {
711 self.crash_started_ms = Some(state.started_at);
712 }
646 } else { 713 } else {
647 self.crash_started = None; 714 self.crash_started = None;
715 self.crash_started_ms = None;
648 } 716 }
649 self.game.apply_crash_state(state); 717 self.game.apply_crash_state(state);
650 self.board_effects() 718 self.board_effects()
651 } 719 }
652 720
721 /// Apply `crash.tick session=<id> t=<server_ms> m=<multiplier>`.
722 fn apply_crash_tick(&mut self, tick: &str) -> Vec<Effect> {
723 let mut session = None;
724 let mut multiplier = None;
725 for token in tick.split_whitespace() {
726 if let Some(value) = token.strip_prefix("session=") {
727 session = Some(value);
728 } else if let Some(value) = token.strip_prefix("m=") {
729 multiplier = value.parse::<f64>().ok();
730 }
731 }
732 let Some(multiplier) = multiplier else {
733 return Vec::new();
734 };
735 if let Some(session) = session {
736 if self.game.crash_session_id().as_deref() != Some(session) {
737 return Vec::new();
738 }
739 }
740 if self.game.crash_status() != "running" {
741 return Vec::new();
742 }
743 self.game.set_crash_multiplier(multiplier);
744 self.board_effects()
745 }
746
653 fn start_crash(&mut self) -> Vec<Effect> { 747 fn start_crash(&mut self) -> Vec<Effect> {
654 let status = self.game.crash_status(); 748 let status = self.game.crash_status();
655 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { 749 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") {
@@ -671,9 +765,8 @@ impl ClientCore {
671 } 765 }
672 766
673 self.game.set_crash_status("starting"); 767 self.game.set_crash_status("starting");
674 self.pending = Some(Pending::CrashStart);
675 let mut effects = self.board_effects(); 768 let mut effects = self.board_effects();
676 effects.push(Effect::SendLine(request)); 769 effects.push(self.begin_request(Pending::CrashStart, request));
677 effects 770 effects
678 } 771 }
679 772
@@ -681,8 +774,7 @@ impl ClientCore {
681 let Some(session) = self.game.crash_session_id() else { 774 let Some(session) = self.game.crash_session_id() else {
682 return Vec::new(); 775 return Vec::new();
683 }; 776 };
684 self.pending = Some(Pending::CrashCashout); 777 vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))]
685 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))]
686 } 778 }
687 779
688 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { 780 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> {
@@ -706,8 +798,7 @@ impl ClientCore {
706 } 798 }
707 799
708 let mut effects = self.board_effects(); 800 let mut effects = self.board_effects();
709 self.pending = Some(Pending::Info); 801 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
710 effects.push(Effect::SendLine("INFO".to_string()));
711 effects 802 effects
712 } 803 }
713 Err(_) => print_line("Failed to enter game"), 804 Err(_) => print_line("Failed to enter game"),
@@ -726,13 +817,12 @@ impl ClientCore {
726 } 817 }
727 818
728 self.screen = Screen::History; 819 self.screen = Screen::History;
729 self.pending = Some(Pending::History);
730 let request = if limit == crate::history::DEFAULT_HISTORY { 820 let request = if limit == crate::history::DEFAULT_HISTORY {
731 "HISTORY".to_string() 821 "HISTORY".to_string()
732 } else { 822 } else {
733 format!("HISTORY {limit}") 823 format!("HISTORY {limit}")
734 }; 824 };
735 vec![Effect::SendLine(request)] 825 vec![self.begin_request(Pending::History, request)]
736 } 826 }
737 827
738 fn info_effects(&mut self) -> Vec<Effect> { 828 fn info_effects(&mut self) -> Vec<Effect> {
@@ -754,8 +844,7 @@ impl ClientCore {
754 ])), 844 ])),
755 } 845 }
756 846
757 self.pending = Some(Pending::Info); 847 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
758 effects.push(Effect::SendLine("INFO".to_string()));
759 effects 848 effects
760 } 849 }
761 850
@@ -809,7 +898,6 @@ impl ClientCore {
809 } 898 }
810 self.view().map(Effect::Render).into_iter().collect() 899 self.view().map(Effect::Render).into_iter().collect()
811 } 900 }
812
813} 901}
814 902
815fn auth_result(reply: &str) -> Result<(), String> { 903fn auth_result(reply: &str) -> Result<(), String> {
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 24ceeaa..d35308b 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -5,7 +5,7 @@ use super::roulette::RouletteGame;
5use super::slots::SlotsGame; 5use super::slots::SlotsGame;
6use super::traits::{Game, GameError, GamePhase, GameState}; 6use super::traits::{Game, GameError, GamePhase, GameState};
7use crate::blackjack::BlackjackState; 7use crate::blackjack::BlackjackState;
8use crate::crash::{payout, CrashState}; 8use crate::crash::{CrashState, payout};
9use crate::roulette::Bets; 9use crate::roulette::Bets;
10use std::collections::HashMap; 10use std::collections::HashMap;
11 11
@@ -244,9 +244,7 @@ impl GameManager {
244 244
245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { 245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) {
246 if let Some(session) = self.current_session.as_mut() { 246 if let Some(session) = self.current_session.as_mut() {
247 session 247 session.data.insert("cursor".to_string(), cursor.encoded());
248 .data
249 .insert("cursor".to_string(), cursor.encoded());
250 } 248 }
251 } 249 }
252 250
@@ -339,7 +337,7 @@ impl GameManager {
339 }; 337 };
340 session.bet_amount = state.bet; 338 session.bet_amount = state.bet;
341 session.data.remove("error"); 339 session.data.remove("error");
342 for key in ["crash_point", "payout"] { 340 for key in ["crash_point", "payout", "commit", "seed"] {
343 session.data.remove(key); 341 session.data.remove(key);
344 } 342 }
345 session 343 session
@@ -360,10 +358,21 @@ impl GameManager {
360 session 358 session
361 .data 359 .data
362 .insert("payout".to_string(), state.payout.to_string()); 360 .insert("payout".to_string(), state.payout.to_string());
361 session
362 .data
363 .insert("started_at".to_string(), state.started_at.to_string());
364 if let Some(commit) = &state.commit {
365 session.data.insert("commit".to_string(), commit.clone());
366 }
367 if let Some(seed) = &state.seed {
368 session.data.insert("seed".to_string(), seed.clone());
369 }
363 if let Some(crash_point) = state.crash_point { 370 if let Some(crash_point) = state.crash_point {
371 // Keep full precision: the browser recomputes this from the
372 // revealed seed, and the GUI charts it.
364 session 373 session
365 .data 374 .data
366 .insert("crash_point".to_string(), format!("{crash_point:.2}")); 375 .insert("crash_point".to_string(), crash_point.to_string());
367 } 376 }
368 } 377 }
369 378
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
index f0ed816..15cc6ed 100644
--- a/src/client/games/roulette.rs
+++ b/src/client/games/roulette.rs
@@ -1,6 +1,7 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::{RoulettePhase, RouletteView}; 2use crate::client::view::{RoulettePhase, RouletteView};
3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; 3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd};
4use serde::{Deserialize, Serialize};
4 5
5pub const ROULETTE_BOARD_LINES: usize = 12; 6pub const ROULETTE_BOARD_LINES: usize = 12;
6 7
@@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [
21 22
22pub struct RouletteGame; 23pub struct RouletteGame;
23 24
24#[derive(Debug, Clone, Copy, PartialEq, Eq)] 25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum Direction { 26pub enum Direction {
26 Up, 27 Up,
27 Down, 28 Down,
@@ -41,7 +42,7 @@ pub enum Direction {
41/// odd,even line between rows split of the two numbers 42/// odd,even line between rows split of the two numbers
42/// even,even corner of four cells corner 43/// even,even corner of four cells corner
43/// ``` 44/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq)] 45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum Cursor { 46pub enum Cursor {
46 Zero, 47 Zero,
47 Lattice { x: usize, y: usize }, 48 Lattice { x: usize, y: usize },
@@ -67,7 +68,9 @@ impl Cursor {
67 match parts.as_slice() { 68 match parts.as_slice() {
68 ["zero"] => Cursor::Zero, 69 ["zero"] => Cursor::Zero,
69 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { 70 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) {
70 (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { 71 (Ok(x), Ok(y))
72 if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) =>
73 {
71 Cursor::Lattice { x, y } 74 Cursor::Lattice { x, y }
72 } 75 }
73 _ => Cursor::default(), 76 _ => Cursor::default(),
diff --git a/src/client/transport.rs b/src/client/transport.rs
index 97c9227..60e7ab6 100644
--- a/src/client/transport.rs
+++ b/src/client/transport.rs
@@ -7,20 +7,27 @@
7use std::fmt; 7use std::fmt;
8use std::io::{Read, Write}; 8use std::io::{Read, Write};
9use std::net::TcpStream; 9use std::net::TcpStream;
10use std::sync::mpsc;
11use std::sync::Arc; 10use std::sync::Arc;
11use std::sync::mpsc;
12use std::thread; 12use std::thread;
13use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
14 14
15use parking_lot::Mutex; 15use parking_lot::Mutex;
16use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
17use rustls::DigitallySignedStruct;
18use rusqlite::Connection; 16use rusqlite::Connection;
17use rustls::DigitallySignedStruct;
18use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
19 19
20use crate::client::database; 20use crate::client::database;
21use crate::protocol::LineBuffer; 21use crate::protocol::{
22use crate::tofu::{decide, spki_fingerprint, TrustDecision}; 22 FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame,
23use crate::version::{parse_hello, HelloReply, VERSION}; 23};
24use crate::tofu::{TrustDecision, decide, spki_fingerprint};
25use crate::version::{HelloReply, VERSION, parse_hello};
26
27/// Reserved request ids for the transport handshake. Application requests
28/// start at 1, so the two ranges cannot collide on one connection.
29const HELLO_REQUEST_ID: u64 = u64::MAX;
30const AUTH_REQUEST_ID: u64 = u64::MAX - 1;
24 31
25/// How long to wait for TCP + TLS before giving up. 32/// How long to wait for TCP + TLS before giving up.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 33const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -43,6 +50,8 @@ pub struct Connected {
43 pub server_version: String, 50 pub server_version: String,
44 pub latest: String, 51 pub latest: String,
45 pub url: String, 52 pub url: String,
53 /// Server Unix time reported by `HELLO`, milliseconds.
54 pub server_time: u64,
46 /// Round trip of the HELLO exchange. 55 /// Round trip of the HELLO exchange.
47 pub elapsed: Duration, 56 pub elapsed: Duration,
48} 57}
@@ -51,10 +60,7 @@ pub struct Connected {
51pub enum TransportError { 60pub enum TransportError {
52 Timeout, 61 Timeout,
53 TrustDeclined, 62 TrustDeclined,
54 KeyChanged { 63 KeyChanged { expected: String, observed: String },
55 expected: String,
56 observed: String,
57 },
58 VersionMismatch(String), 64 VersionMismatch(String),
59 Closed, 65 Closed,
60 Unexpected(String), 66 Unexpected(String),
@@ -121,7 +127,7 @@ pub fn connect(
121 return Err(TransportError::KeyChanged { 127 return Err(TransportError::KeyChanged {
122 expected, 128 expected,
123 observed: actual, 129 observed: actual,
124 }) 130 });
125 } 131 }
126 } 132 }
127 } 133 }
@@ -131,12 +137,7 @@ pub fn connect(
131 .map_err(io_failure)?; 137 .map_err(io_failure)?;
132 138
133 let start = Instant::now(); 139 let start = Instant::now();
134 tls.write_line(&format!("HELLO {VERSION}")) 140 let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?;
135 .map_err(io_failure)?;
136 let mut response = String::new();
137 if tls.read_line(&mut response).map_err(io_failure)? == 0 {
138 return Err(TransportError::Closed);
139 }
140 let elapsed = start.elapsed(); 141 let elapsed = start.elapsed();
141 142
142 let response = response.trim(); 143 let response = response.trim();
@@ -151,10 +152,11 @@ pub fn connect(
151 let mut user = None; 152 let mut user = None;
152 let mut auth_error = None; 153 let mut auth_error = None;
153 if let Some((username, password)) = database::get_credentials(conn, &observed) { 154 if let Some((username, password)) = database::get_credentials(conn, &observed) {
154 tls.write_line(&format!("AUTH {username} {password}")) 155 let auth = exchange(
155 .map_err(io_failure)?; 156 &mut tls,
156 let mut auth = String::new(); 157 AUTH_REQUEST_ID,
157 tls.read_line(&mut auth).map_err(io_failure)?; 158 &format!("AUTH {username} {password}"),
159 )?;
158 if auth.trim().starts_with("OK") { 160 if auth.trim().starts_with("OK") {
159 user = Some(username); 161 user = Some(username);
160 } else { 162 } else {
@@ -169,19 +171,31 @@ pub fn connect(
169 server_version: hello.version, 171 server_version: hello.version,
170 latest: hello.latest, 172 latest: hello.latest,
171 url: hello.url, 173 url: hello.url,
174 server_time: hello.time,
172 elapsed, 175 elapsed,
173 }) 176 })
174} 177}
175 178
179/// Send one request and wait for the reply with the same id, ignoring events
180/// that arrive in between. Used only for the handshake.
181fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result<String, TransportError> {
182 stream
183 .write_request(id, None, payload)
184 .map_err(io_failure)?;
185 loop {
186 match stream.read_packet().map_err(io_failure)? {
187 None => return Err(TransportError::Closed),
188 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body),
189 Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue,
190 }
191 }
192}
193
176/// TCP + TLS on a thread so a blocked handshake can time out. 194/// TCP + TLS on a thread so a blocked handshake can time out.
177fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { 195fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> {
178 let (sender, receiver) = mpsc::channel(); 196 let (sender, receiver) = mpsc::channel();
179 let addr = addr.to_string(); 197 let addr = addr.to_string();
180 let server_name = addr 198 let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string();
181 .split(':')
182 .next()
183 .unwrap_or("127.0.0.1")
184 .to_string();
185 199
186 thread::spawn(move || { 200 thread::spawn(move || {
187 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { 201 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> {
@@ -241,7 +255,7 @@ impl TlsConnector {
241 session: Arc::new(Mutex::new(Session { 255 session: Arc::new(Mutex::new(Session {
242 sock, 256 sock,
243 tls: connection, 257 tls: connection,
244 lines: LineBuffer::new(), 258 frames: FrameBuffer::new(),
245 read_timeout: Some(READ_TIMEOUT), 259 read_timeout: Some(READ_TIMEOUT),
246 })), 260 })),
247 }) 261 })
@@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier {
268 _ocsp_response: &[u8], 282 _ocsp_response: &[u8],
269 _now: std::time::SystemTime, 283 _now: std::time::SystemTime,
270 ) -> Result<ServerCertVerified, rustls::Error> { 284 ) -> Result<ServerCertVerified, rustls::Error> {
271 let fingerprint = spki_fingerprint(&end_entity.0) 285 let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| {
272 .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; 286 rustls::Error::General(format!("certificate parse failed: {error}"))
287 })?;
273 *self.observed.lock() = Some(fingerprint); 288 *self.observed.lock() = Some(fingerprint);
274 Ok(ServerCertVerified::assertion()) 289 Ok(ServerCertVerified::assertion())
275 } 290 }
@@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier {
313struct Session { 328struct Session {
314 sock: TcpStream, 329 sock: TcpStream,
315 tls: rustls::ClientConnection, 330 tls: rustls::ClientConnection,
316 lines: LineBuffer, 331 frames: FrameBuffer,
317 read_timeout: Option<Duration>, 332 read_timeout: Option<Duration>,
318} 333}
319 334
@@ -329,37 +344,58 @@ pub struct TlsStream {
329} 344}
330 345
331impl TlsStream { 346impl TlsStream {
332 pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { 347 /// Write one enveloped request. Application code allocates `id`.
348 pub fn write_request(
349 &mut self,
350 id: u64,
351 idem: Option<&str>,
352 payload: &str,
353 ) -> std::io::Result<()> {
354 self.write_frame(&request_line(id, idem, payload))
355 }
356
357 /// Write one raw frame payload.
358 pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> {
359 let mut s = self.session.lock();
360 write_protocol_frame(&mut s.tls.writer(), payload)?;
361 s.drive()
362 }
363
364 /// Block until one whole frame arrives.
365 pub fn read_frame(&mut self) -> std::io::Result<Option<String>> {
333 let mut s = self.session.lock(); 366 let mut s = self.session.lock();
334 loop { 367 loop {
335 if let Some(line) = s.lines.next_line() { 368 match s.frames.next_frame() {
336 buf.push_str(&line); 369 Some(Ok(frame)) => return Ok(Some(frame)),
337 buf.push('\n'); 370 Some(Err(error)) => {
338 return Ok(line.len() + 1); 371 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
372 }
373 None => {}
339 } 374 }
340 375
341 let mut chunk = [0u8; 4096]; 376 let mut chunk = [0u8; 4096];
342 match s.tls.reader().read(&mut chunk) { 377 match s.tls.reader().read(&mut chunk) {
343 Ok(0) => { 378 Ok(0) => return Ok(None),
344 if let Some(rest) = s.lines.take_rest() { 379 Ok(n) => s.frames.push(&chunk[..n]),
345 let read = rest.len();
346 buf.push_str(&rest);
347 return Ok(read);
348 }
349 return Ok(0);
350 }
351 Ok(n) => s.lines.push(&chunk[..n]),
352 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, 380 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?,
353 Err(error) => return Err(error), 381 Err(error) => return Err(error),
354 } 382 }
355 } 383 }
356 } 384 }
357 385
358 pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { 386 /// Block until one `RES` or `EVT` arrives.
359 let mut s = self.session.lock(); 387 pub fn read_packet(&mut self) -> std::io::Result<Option<Packet>> {
360 s.tls.writer().write_all(message.as_bytes())?; 388 loop {
361 s.tls.writer().write_all(b"\n")?; 389 let Some(frame) = self.read_frame()? else {
362 s.drive() 390 return Ok(None);
391 };
392 match parse_packet(&frame) {
393 Ok(packet) => return Ok(Some(packet)),
394 Err(error) => {
395 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
396 }
397 }
398 }
363 } 399 }
364 400
365 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { 401 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> {
@@ -368,24 +404,36 @@ impl TlsStream {
368 s.sock.set_read_timeout(duration) 404 s.sock.set_read_timeout(duration)
369 } 405 }
370 406
371 /// Return one complete line if the socket already has one, without 407 /// Return one packet if the socket already has one, without blocking.
372 /// blocking. Used to pick up server pushes between requests. 408 /// Used to pick up server pushes between requests.
373 pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { 409 pub fn read_available_packet(&mut self) -> std::io::Result<Option<Packet>> {
374 let mut s = self.session.lock(); 410 let mut s = self.session.lock();
375 if let Some(line) = s.lines.next_line() {
376 return Ok(Some(line));
377 }
378 411
379 // `set_read_timeout(Some(0))` is rejected by std, so poll with the 412 // `set_read_timeout(Some(0))` is rejected by std, so poll with the
380 // shortest timeout the socket accepts instead of a nonblocking mode. 413 // shortest timeout the socket accepts instead of a nonblocking mode.
381 s.sock 414 s.sock
382 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; 415 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?;
383 let drained = (|| -> std::io::Result<()> { 416 let drained = (|| -> std::io::Result<Option<Packet>> {
384 loop { 417 loop {
418 match s.frames.next_frame() {
419 Some(Ok(frame)) => {
420 return match parse_packet(&frame) {
421 Ok(packet) => Ok(Some(packet)),
422 Err(error) => {
423 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
424 }
425 };
426 }
427 Some(Err(error)) => {
428 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
429 }
430 None => {}
431 }
432
385 let mut buf = [0u8; 4096]; 433 let mut buf = [0u8; 4096];
386 match s.tls.reader().read(&mut buf) { 434 match s.tls.reader().read(&mut buf) {
387 Ok(0) => return Ok(()), 435 Ok(0) => return Ok(None),
388 Ok(n) => s.lines.push(&buf[..n]), 436 Ok(n) => s.frames.push(&buf[..n]),
389 // The reader only drains plaintext rustls has already 437 // The reader only drains plaintext rustls has already
390 // decrypted; `drive` moves bytes off the socket. 438 // decrypted; `drive` moves bytes off the socket.
391 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 439 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
@@ -397,7 +445,7 @@ impl TlsStream {
397 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 445 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
398 ) => 446 ) =>
399 { 447 {
400 return Ok(()); 448 return Ok(None);
401 } 449 }
402 Err(error) => return Err(error), 450 Err(error) => return Err(error),
403 } 451 }
@@ -408,15 +456,14 @@ impl TlsStream {
408 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 456 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
409 ) => 457 ) =>
410 { 458 {
411 return Ok(()); 459 return Ok(None);
412 } 460 }
413 Err(error) => return Err(error), 461 Err(error) => return Err(error),
414 } 462 }
415 } 463 }
416 })(); 464 })();
417 s.sock.set_read_timeout(s.read_timeout)?; 465 s.sock.set_read_timeout(s.read_timeout)?;
418 drained?; 466 drained
419 Ok(s.lines.next_line())
420 } 467 }
421} 468}
422 469
diff --git a/src/client/view.rs b/src/client/view.rs
index 4615720..0886d64 100644
--- a/src/client/view.rs
+++ b/src/client/view.rs
@@ -7,15 +7,17 @@ use crate::blackjack::BlackjackState;
7use crate::client::games::roulette::{Cursor, Direction}; 7use crate::client::games::roulette::{Cursor, Direction};
8use crate::client::games::traits::{GamePhase, GameState}; 8use crate::client::games::traits::{GamePhase, GameState};
9use crate::roulette::{BetTarget, Bets}; 9use crate::roulette::{BetTarget, Bets};
10use serde::{Deserialize, Serialize};
10 11
11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
12pub enum RoulettePhase { 14pub enum RoulettePhase {
13 Placing, 15 Placing,
14 Spinning, 16 Spinning,
15 Result, 17 Result,
16} 18}
17 19
18#[derive(Debug, Clone, PartialEq, Eq)] 20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct RouletteView { 21pub struct RouletteView {
20 pub bet_amount: u64, 22 pub bet_amount: u64,
21 pub balance: Option<i64>, 23 pub balance: Option<i64>,
@@ -51,7 +53,10 @@ impl RouletteView {
51 Some("result") => RoulettePhase::Result, 53 Some("result") => RoulettePhase::Result,
52 _ => RoulettePhase::Placing, 54 _ => RoulettePhase::Placing,
53 }; 55 };
54 let number = state.data.get("number").and_then(|value| value.parse().ok()); 56 let number = state
57 .data
58 .get("number")
59 .and_then(|value| value.parse().ok());
55 let color = state.data.get("color").cloned(); 60 let color = state.data.get("color").cloned();
56 let winnings = state 61 let winnings = state
57 .data 62 .data
@@ -82,14 +87,15 @@ impl RouletteView {
82 } 87 }
83} 88}
84 89
85#[derive(Debug, Clone, Copy, PartialEq, Eq)] 90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
86pub enum SlotsPhase { 92pub enum SlotsPhase {
87 Ready, 93 Ready,
88 Spinning, 94 Spinning,
89 Result, 95 Result,
90} 96}
91 97
92#[derive(Debug, Clone, PartialEq, Eq)] 98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SlotsView { 99pub struct SlotsView {
94 pub bet: u64, 100 pub bet: u64,
95 pub balance: Option<i64>, 101 pub balance: Option<i64>,
@@ -149,7 +155,7 @@ impl SlotsView {
149 } 155 }
150} 156}
151 157
152#[derive(Debug, Clone, PartialEq, Eq)] 158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct BlackjackView { 159pub struct BlackjackView {
154 pub status: String, 160 pub status: String,
155 pub balance: Option<i64>, 161 pub balance: Option<i64>,
@@ -186,7 +192,7 @@ impl BlackjackView {
186 } 192 }
187} 193}
188 194
189#[derive(Debug, Clone, PartialEq)] 195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct CrashView { 196pub struct CrashView {
191 pub status: String, 197 pub status: String,
192 pub balance: Option<i64>, 198 pub balance: Option<i64>,
@@ -195,6 +201,11 @@ pub struct CrashView {
195 pub potential: u64, 201 pub potential: u64,
196 pub crash_point: Option<f64>, 202 pub crash_point: Option<f64>,
197 pub payout: u64, 203 pub payout: u64,
204 pub started_at: u64,
205 /// `sha256(seed)` for this round, before it ends.
206 pub commit: Option<String>,
207 /// The revealed seed, after the round ends.
208 pub seed: Option<String>,
198 pub error: Option<String>, 209 pub error: Option<String>,
199} 210}
200 211
@@ -232,6 +243,13 @@ impl CrashView {
232 .get("payout") 243 .get("payout")
233 .and_then(|value| value.parse::<u64>().ok()) 244 .and_then(|value| value.parse::<u64>().ok())
234 .unwrap_or(0), 245 .unwrap_or(0),
246 started_at: state
247 .data
248 .get("started_at")
249 .and_then(|value| value.parse::<u64>().ok())
250 .unwrap_or(0),
251 commit: state.data.get("commit").cloned(),
252 seed: state.data.get("seed").cloned(),
235 error: state 253 error: state
236 .data 254 .data
237 .get("error") 255 .get("error")
@@ -242,7 +260,7 @@ impl CrashView {
242} 260}
243 261
244/// The lobby: who is logged in, the balance, and what can be played. 262/// The lobby: who is logged in, the balance, and what can be played.
245#[derive(Debug, Clone, PartialEq, Eq)] 263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct LobbyView { 264pub struct LobbyView {
247 pub connected: bool, 265 pub connected: bool,
248 pub user: Option<String>, 266 pub user: Option<String>,
@@ -254,13 +272,14 @@ pub struct LobbyView {
254} 272}
255 273
256/// The play log, already parsed. 274/// The play log, already parsed.
257#[derive(Debug, Clone, PartialEq)] 275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct HistoryView { 276pub struct HistoryView {
259 pub records: Vec<crate::history::PlayRecord>, 277 pub records: Vec<crate::history::PlayRecord>,
260} 278}
261 279
262/// Everything a frontend can draw. 280/// Everything a frontend can draw.
263#[derive(Debug, Clone, PartialEq)] 281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282#[serde(tag = "screen", rename_all = "snake_case")]
264pub enum View { 283pub enum View {
265 Lobby(LobbyView), 284 Lobby(LobbyView),
266 History(HistoryView), 285 History(HistoryView),
@@ -271,17 +290,28 @@ pub enum View {
271} 290}
272 291
273/// Semantic input. Frontends turn keys and clicks into these. 292/// Semantic input. Frontends turn keys and clicks into these.
274#[derive(Debug, Clone, PartialEq, Eq)] 293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub enum Command { 294pub enum Command {
276 /// The credential is already hashed by the frontend. 295 /// The credential is already hashed by the frontend.
277 Login { user: String, credential: String }, 296 Login {
297 user: String,
298 credential: String,
299 },
278 Games, 300 Games,
279 Ping, 301 Ping,
280 Echo(String), 302 Echo(String),
281 Info, 303 Info,
282 Register { user: String, credential: String }, 304 Register {
283 Play { game: String, bet: Option<u64> }, 305 user: String,
284 History { limit: u32 }, 306 credential: String,
307 },
308 Play {
309 game: String,
310 bet: Option<u64>,
311 },
312 History {
313 limit: u32,
314 },
285 ShowLobby, 315 ShowLobby,
286 Logout, 316 Logout,
287 SetBet(u64), 317 SetBet(u64),