summaryrefslogtreecommitdiffstats
path: root/src/client/core.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /src/client/core.rs
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to '')
-rw-r--r--src/client/core.rs182
1 files changed, 135 insertions, 47 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> {