summaryrefslogtreecommitdiffstats
path: root/src/server/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/server/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 'src/server/core.rs')
-rw-r--r--src/server/core.rs81
1 files changed, 71 insertions, 10 deletions
diff --git a/src/server/core.rs b/src/server/core.rs
index 3cd6469..0b2efb4 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -1,22 +1,26 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{DEFAULT_COST, hash, verify};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap; 3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc; 4use std::sync::Arc;
5 5
6use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState; 7use crate::crash::CrashState;
8use crate::protocol::{REPLAY_CACHE_LEN, Request};
8use crate::server::database; 9use crate::server::database;
9use crate::version::{download_url, hello_line, Version, VERSION};
10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; 11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
12use crate::server::games::registry::GameRegistry; 12use crate::server::games::registry::GameRegistry;
13use crate::server::games::session::SessionManager; 13use crate::server::games::session::SessionManager;
14use crate::server::games::traits::{GameError, GamePhase, GameState}; 14use crate::server::games::traits::{GameError, GamePhase, GameState};
15use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms};
15 16
16#[derive(Debug, Default)] 17#[derive(Debug, Default)]
17pub struct ConnectionState { 18pub struct ConnectionState {
18 user: Option<String>, 19 user: Option<String>,
19 greeted: bool, 20 greeted: bool,
21 /// Last replies on this connection, oldest first. Request ids are unique
22 /// while cached, so a retried request replays its first result.
23 replays: VecDeque<(u64, String, String)>,
20} 24}
21 25
22/// Settle reservations left behind by a previous process. 26/// Settle reservations left behind by a previous process.
@@ -39,6 +43,27 @@ impl ConnectionState {
39 pub fn greeted(&self) -> bool { 43 pub fn greeted(&self) -> bool {
40 self.greeted 44 self.greeted
41 } 45 }
46
47 fn replay(&self, id: u64, payload: &str) -> Option<Result<String, String>> {
48 self.replays
49 .iter()
50 .find(|(cached_id, _, _)| *cached_id == id)
51 .map(|(_, cached_payload, reply)| {
52 if cached_payload == payload {
53 Ok(reply.clone())
54 } else {
55 Err("ERROR Request id reused with a different command".to_string())
56 }
57 })
58 }
59
60 fn remember(&mut self, id: u64, payload: &str, reply: &str) {
61 if self.replays.len() >= REPLAY_CACHE_LEN {
62 self.replays.pop_front();
63 }
64 self.replays
65 .push_back((id, payload.to_string(), reply.to_string()));
66 }
42} 67}
43 68
44pub struct ServerCore { 69pub struct ServerCore {
@@ -49,6 +74,8 @@ pub struct ServerCore {
49 blackjack: BlackjackGame, 74 blackjack: BlackjackGame,
50 crash: CrashGame, 75 crash: CrashGame,
51 clock: Arc<dyn Clock>, 76 clock: Arc<dyn Clock>,
77 /// Last time a crash tick was pushed, in seconds on the game clock.
78 last_tick: HashMap<String, f64>,
52} 79}
53 80
54impl ServerCore { 81impl ServerCore {
@@ -65,6 +92,7 @@ impl ServerCore {
65 blackjack: BlackjackGame::new(), 92 blackjack: BlackjackGame::new(),
66 crash: CrashGame::new(), 93 crash: CrashGame::new(),
67 clock: Arc::new(RealClock::new()), 94 clock: Arc::new(RealClock::new()),
95 last_tick: HashMap::new(),
68 } 96 }
69 } 97 }
70 98
@@ -96,6 +124,21 @@ impl ServerCore {
96 Ok(Self::new(conn)) 124 Ok(Self::new(conn))
97 } 125 }
98 126
127 /// Handle one enveloped request, replaying a cached reply for a repeated
128 /// request id. The payload grammar is unchanged; the envelope only adds
129 /// correlation and idempotency.
130 pub fn handle_request(&mut self, state: &mut ConnectionState, request: &Request) -> String {
131 if let Some(reply) = state.replay(request.id, &request.payload) {
132 return match reply {
133 Ok(reply) => reply,
134 Err(message) => message,
135 };
136 }
137 let reply = self.handle_message(state, &request.payload);
138 state.remember(request.id, &request.payload, &reply);
139 reply
140 }
141
99 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { 142 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
100 let parts: Vec<&str> = msg.splitn(5, ' ').collect(); 143 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
101 144
@@ -201,7 +244,7 @@ impl ServerCore {
201 } 244 }
202 } 245 }
203 } 246 }
204 "PING" => "PONG".to_string(), 247 "PING" => pong_line(unix_time_ms()),
205 "INFO" => self.handle_info(state), 248 "INFO" => self.handle_info(state),
206 _ => msg.to_string(), 249 _ => msg.to_string(),
207 } 250 }
@@ -212,7 +255,8 @@ impl ServerCore {
212 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string(); 255 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string();
213 } 256 }
214 257
215 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { 258 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION))
259 else {
216 return "ERROR Invalid version".to_string(); 260 return "ERROR Invalid version".to_string();
217 }; 261 };
218 262
@@ -225,7 +269,7 @@ impl ServerCore {
225 } 269 }
226 270
227 state.greeted = true; 271 state.greeted = true;
228 hello_line(VERSION, VERSION, &download_url()) 272 hello_line(VERSION, VERSION, &download_url(), unix_time_ms())
229 } 273 }
230 274
231 fn handle_register(&mut self, username: &str, credential: &str) -> String { 275 fn handle_register(&mut self, username: &str, credential: &str) -> String {
@@ -529,8 +573,8 @@ impl ServerCore {
529 573
530 // The session is the audit guard for the deduction, so it exists before 574 // The session is the audit guard for the deduction, so it exists before
531 // the balance is touched. 575 // the balance is touched.
532 let crash_point = self.crash.next_crash_point(); 576 let seed = self.crash.next_seed();
533 let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); 577 let round = CrashRound::new(bet as u64, seed, self.clock.now(), unix_time_ms());
534 let mut data = HashMap::new(); 578 let mut data = HashMap::new();
535 data.insert("round".to_string(), round.to_json()); 579 data.insert("round".to_string(), round.to_json());
536 data.insert("balance".to_string(), balance.to_string()); 580 data.insert("balance".to_string(), balance.to_string());
@@ -643,11 +687,27 @@ impl ServerCore {
643 if !round.is_running() { 687 if !round.is_running() {
644 continue; 688 continue;
645 } 689 }
646 let elapsed = (self.clock.now() - round.started_at).max(0.0); 690 let now = self.clock.now();
647 if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { 691 let elapsed = (now - round.started_at).max(0.0);
692 let multiplier = crate::crash::multiplier_at_secs(elapsed);
693 if multiplier < round.crash_point {
694 let last = self
695 .last_tick
696 .get(&session.session_id)
697 .copied()
698 .unwrap_or(f64::NEG_INFINITY);
699 if now - last >= 0.1 {
700 self.last_tick.insert(session.session_id.clone(), now);
701 out.push(format!(
702 "crash.tick session={} t={} m={multiplier:.4}",
703 session.session_id,
704 unix_time_ms()
705 ));
706 }
648 continue; 707 continue;
649 } 708 }
650 round.crash(); 709 round.crash();
710 self.last_tick.remove(&session.session_id);
651 match self.settle_crash(user, &session.session_id, &round, 0) { 711 match self.settle_crash(user, &session.session_id, &round, 0) {
652 Ok(balance) => { 712 Ok(balance) => {
653 let state = round.state(&session.session_id, balance); 713 let state = round.state(&session.session_id, balance);
@@ -678,6 +738,7 @@ impl ServerCore {
678 payout as i64, 738 payout as i64,
679 &details, 739 &details,
680 ); 740 );
741 self.last_tick.remove(session_id);
681 let _ = self.sessions.remove_session(session_id); 742 let _ = self.sessions.remove_session(session_id);
682 result.map_err(|error| error.to_string()) 743 result.map_err(|error| error.to_string())
683 } 744 }