summaryrefslogtreecommitdiffstats
path: root/src/server
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
commit6e2787d792de64c003eff03b62a2df798ef851c0 (patch)
tree0e9bf97b2f71df5fb9eb00d5195d32b8326886ff /src/server
parentv2.2.1: forfeit blackjack bets when leaving or disconnecting (diff)
downloadzagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.tar.gz
zagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.zip
v2.3.0: add crash with local multiplier and pushed crash event
Diffstat (limited to '')
-rw-r--r--src/server/core.rs240
-rw-r--r--src/server/games/crash.rs216
-rw-r--r--src/server/games/mod.rs1
-rw-r--r--src/server/games/registry.rs2
4 files changed, 459 insertions, 0 deletions
diff --git a/src/server/core.rs b/src/server/core.rs
index 259f936..d4c95f6 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -1,10 +1,13 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{hash, verify, DEFAULT_COST};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap; 3use std::collections::HashMap;
4use std::sync::Arc;
4 5
5use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState;
6use crate::server::database; 8use crate::server::database;
7use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 9use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
10use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
8use crate::server::games::registry::GameRegistry; 11use crate::server::games::registry::GameRegistry;
9use crate::server::games::session::SessionManager; 12use crate::server::games::session::SessionManager;
10use crate::server::games::traits::{GameError, GamePhase, GameState}; 13use crate::server::games::traits::{GameError, GamePhase, GameState};
@@ -26,6 +29,8 @@ pub struct ServerCore {
26 registry: GameRegistry, 29 registry: GameRegistry,
27 bcrypt_cost: u32, 30 bcrypt_cost: u32,
28 blackjack: BlackjackGame, 31 blackjack: BlackjackGame,
32 crash: CrashGame,
33 clock: Arc<dyn Clock>,
29} 34}
30 35
31impl ServerCore { 36impl ServerCore {
@@ -44,6 +49,8 @@ impl ServerCore {
44 registry, 49 registry,
45 bcrypt_cost, 50 bcrypt_cost,
46 blackjack: BlackjackGame::new(), 51 blackjack: BlackjackGame::new(),
52 crash: CrashGame::new(),
53 clock: Arc::new(RealClock::new()),
47 } 54 }
48 } 55 }
49 56
@@ -53,6 +60,22 @@ impl ServerCore {
53 self 60 self
54 } 61 }
55 62
63 /// Test/utility hook: queue the next crash-point draws.
64 pub fn with_crash_uniforms(mut self, uniforms: impl IntoIterator<Item = f64>) -> Self {
65 self.crash = CrashGame::with_uniforms(uniforms);
66 self
67 }
68
69 pub fn with_crash_uniform(self, uniform: f64) -> Self {
70 self.with_crash_uniforms([uniform])
71 }
72
73 /// Test/utility hook: replace the monotonic clock driving crash rounds.
74 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
75 self.clock = clock;
76 self
77 }
78
56 pub fn open(path: &str) -> rusqlite::Result<Self> { 79 pub fn open(path: &str) -> rusqlite::Result<Self> {
57 let conn = Connection::open(path)?; 80 let conn = Connection::open(path)?;
58 database::init_db(&conn); 81 database::init_db(&conn);
@@ -98,6 +121,8 @@ impl ServerCore {
98 "PLAY_START" => { 121 "PLAY_START" => {
99 if parts.len() == 3 && parts[1] == "blackjack" { 122 if parts.len() == 3 && parts[1] == "blackjack" {
100 self.handle_blackjack_start(state, parts[2]) 123 self.handle_blackjack_start(state, parts[2])
124 } else if parts.len() == 3 && parts[1] == "crash" {
125 self.handle_crash_start(state, parts[2])
101 } else if parts.len() != 3 && parts.len() != 4 { 126 } else if parts.len() != 3 && parts.len() != 4 {
102 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" 127 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]"
103 .to_string() 128 .to_string()
@@ -105,6 +130,22 @@ impl ServerCore {
105 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) 130 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied())
106 } 131 }
107 } 132 }
133 "CRASH_CASHOUT" => {
134 if parts.len() != 2 {
135 "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>"
136 .to_string()
137 } else {
138 self.handle_crash_end(state, parts[1], false)
139 }
140 }
141 "CRASH_FORFEIT" => {
142 if parts.len() != 2 {
143 "ERROR Invalid CRASH_FORFEIT format. Usage: CRASH_FORFEIT <session>"
144 .to_string()
145 } else {
146 self.handle_crash_end(state, parts[1], true)
147 }
148 }
108 "BLACKJACK_ACTION" => { 149 "BLACKJACK_ACTION" => {
109 if parts.len() != 3 { 150 if parts.len() != 3 {
110 "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>" 151 "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>"
@@ -371,6 +412,175 @@ impl ServerCore {
371 } 412 }
372 } 413 }
373 414
415 fn handle_crash_start(&mut self, state: &ConnectionState, bet_str: &str) -> String {
416 let Some(user) = state.user.as_deref() else {
417 return "ERROR Not logged in".to_string();
418 };
419 if self
420 .sessions
421 .get_user_sessions(user)
422 .iter()
423 .any(|session| session.game_type == "crash")
424 {
425 return "ERROR Crash round already in progress".to_string();
426 }
427 let bet: i64 = match bet_str.parse() {
428 Ok(bet) => bet,
429 Err(_) => return "ERROR Invalid bet amount".to_string(),
430 };
431 if bet <= 0 {
432 return "ERROR Invalid bet amount".to_string();
433 }
434 let Some(balance) = database::get_balance(&self.conn, user) else {
435 return "ERROR Could not retrieve balance".to_string();
436 };
437 if balance < bet {
438 return "ERROR Insufficient balance".to_string();
439 }
440
441 // The session is the audit guard for the deduction, so it exists before
442 // the balance is touched.
443 let crash_point = self.crash.next_crash_point();
444 let round = CrashRound::new(bet as u64, crash_point, self.clock.now());
445 let mut data = HashMap::new();
446 data.insert("round".to_string(), round.to_json());
447 data.insert("balance".to_string(), balance.to_string());
448 let game_state = GameState {
449 session_id: String::new(),
450 game_type: "crash".to_string(),
451 bet_amount: round.bet,
452 phase: GamePhase::Playing,
453 data,
454 };
455 let session_id = self
456 .sessions
457 .create_session(user, "crash".to_string(), game_state);
458
459 let balance = match database::debit(&mut self.conn, user, bet) {
460 Ok(balance) => balance,
461 Err(_) => {
462 let _ = self.sessions.remove_session(&session_id);
463 return "ERROR Insufficient balance".to_string();
464 }
465 };
466 if let Ok(mut session) = self.sessions.get_session(&session_id) {
467 session
468 .state
469 .data
470 .insert("balance".to_string(), balance.to_string());
471 let _ = self.sessions.update_session(&session_id, session.state);
472 }
473
474 let state = round.state(&session_id, balance);
475 crash_wire("CRASH_START", &state)
476 }
477
478 /// Cash out or forfeit. The multiplier comes from the server clock at the
479 /// moment the request is handled; the client never supplies one.
480 fn handle_crash_end(&mut self, state: &ConnectionState, session_id: &str, forfeit: bool) -> String {
481 let Some(user) = state.user.as_deref() else {
482 return "ERROR Not logged in".to_string();
483 };
484 let Ok(session) = self.sessions.get_session(session_id) else {
485 return "ERROR Session not found".to_string();
486 };
487 if session.username != user || session.game_type != "crash" {
488 return "ERROR Invalid session".to_string();
489 }
490 let Some(raw) = session.state.data.get("round") else {
491 return "ERROR Missing round".to_string();
492 };
493 let Ok(mut round) = CrashRound::from_json(raw) else {
494 return "ERROR Invalid round".to_string();
495 };
496 if !round.is_running() {
497 return "ERROR Round already ended".to_string();
498 }
499
500 let elapsed = (self.clock.now() - round.started_at).max(0.0);
501 let multiplier = crate::crash::multiplier_at_secs(elapsed);
502 let payout = if forfeit {
503 round.multiplier = multiplier;
504 round.forfeit();
505 0
506 } else if multiplier < round.crash_point {
507 round.cash_out(multiplier)
508 } else {
509 round.crash();
510 0
511 };
512
513 match self.settle_crash(user, session_id, &round, payout) {
514 Ok(balance) => {
515 let state = round.state(session_id, balance);
516 crash_wire("CRASH_STATE", &state)
517 }
518 Err(message) => {
519 eprintln!("Crash settlement failed for {user}: {message}");
520 "ERROR Failed to update balance".to_string()
521 }
522 }
523 }
524
525 /// Server-driven end of a round. `handler` calls this on every socket
526 /// timeout and forwards whatever lines come back.
527 pub fn poll(&mut self, state: &ConnectionState) -> Vec<String> {
528 let Some(user) = state.user.as_deref() else {
529 return Vec::new();
530 };
531 let mut out = Vec::new();
532 for session in self.sessions.get_user_sessions(user) {
533 if session.game_type != "crash" {
534 continue;
535 }
536 let Some(raw) = session.state.data.get("round") else {
537 continue;
538 };
539 let Ok(mut round) = CrashRound::from_json(raw) else {
540 continue;
541 };
542 if !round.is_running() {
543 continue;
544 }
545 let elapsed = (self.clock.now() - round.started_at).max(0.0);
546 if crate::crash::multiplier_at_secs(elapsed) < round.crash_point {
547 continue;
548 }
549 round.crash();
550 match self.settle_crash(user, &session.session_id, &round, 0) {
551 Ok(balance) => {
552 let state = round.state(&session.session_id, balance);
553 out.push(crash_wire("CRASH_BUSTED", &state));
554 }
555 Err(message) => {
556 eprintln!("Crash push settlement failed for {user}: {message}");
557 }
558 }
559 }
560 out
561 }
562
563 fn settle_crash(
564 &mut self,
565 user: &str,
566 session_id: &str,
567 round: &CrashRound,
568 payout: u64,
569 ) -> Result<i64, String> {
570 let details = round.to_json();
571 let result = database::settle_play(
572 &mut self.conn,
573 session_id,
574 user,
575 "crash",
576 round.bet as i64,
577 payout as i64,
578 &details,
579 );
580 let _ = self.sessions.remove_session(session_id);
581 result.map_err(|error| error.to_string())
582 }
583
374 fn handle_play_start( 584 fn handle_play_start(
375 &mut self, 585 &mut self,
376 state: &ConnectionState, 586 state: &ConnectionState,
@@ -527,6 +737,29 @@ impl ServerCore {
527 } 737 }
528 } 738 }
529 } 739 }
740 if session.game_type == "crash" {
741 if let Some(raw) = session.state.data.get("round") {
742 if let Ok(mut round) = CrashRound::from_json(raw) {
743 if round.is_running() {
744 round.forfeit();
745 let details = round.to_json();
746 if let Err(error) = database::settle_play(
747 &mut self.conn,
748 &session.session_id,
749 user,
750 "crash",
751 round.bet as i64,
752 0,
753 &details,
754 ) {
755 eprintln!(
756 "Crash disconnect forfeit failed for {user}: {error}"
757 );
758 }
759 }
760 }
761 }
762 }
530 let _ = self.sessions.remove_session(&session.session_id); 763 let _ = self.sessions.remove_session(&session.session_id);
531 } 764 }
532 } 765 }
@@ -562,3 +795,10 @@ fn blackjack_wire(state: &BlackjackState) -> String {
562 serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string()) 795 serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string())
563 ) 796 )
564} 797}
798
799fn crash_wire(prefix: &str, state: &CrashState) -> String {
800 format!(
801 "{prefix} {}",
802 serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string())
803 )
804}
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs
new file mode 100644
index 0000000..ba73c7f
--- /dev/null
+++ b/src/server/games/crash.rs
@@ -0,0 +1,216 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::crash::{crash_point_from_uniform, payout, CrashState};
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, VecDeque};
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8/// Monotonic seconds. Injected so tests can drive a round without sleeping.
9pub trait Clock: Send + Sync {
10 fn now(&self) -> f64;
11}
12
13pub struct RealClock {
14 origin: Instant,
15}
16
17impl RealClock {
18 pub fn new() -> Self {
19 Self {
20 origin: Instant::now(),
21 }
22 }
23}
24
25impl Clock for RealClock {
26 fn now(&self) -> f64 {
27 self.origin.elapsed().as_secs_f64()
28 }
29}
30
31/// A clock a test can move by hand. Clones share the same instant.
32#[derive(Clone, Default)]
33pub struct ManualClock {
34 seconds: Arc<Mutex<f64>>,
35}
36
37impl ManualClock {
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn set(&self, seconds: f64) {
43 *self.seconds.lock().unwrap() = seconds;
44 }
45
46 pub fn advance(&self, seconds: f64) {
47 *self.seconds.lock().unwrap() += seconds;
48 }
49
50 pub fn seconds(&self) -> f64 {
51 *self.seconds.lock().unwrap()
52 }
53}
54
55impl Clock for ManualClock {
56 fn now(&self) -> f64 {
57 self.seconds()
58 }
59}
60
61/// One crash round. Lives in the game session's `data` while it runs.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct CrashRound {
64 pub bet: u64,
65 /// Drawn at start, revealed only when the round ends.
66 pub crash_point: f64,
67 pub started_at: f64,
68 /// `running`, `cashed`, `crashed` or `forfeit`.
69 pub phase: String,
70 pub result: String,
71 /// Live while running; frozen at the final value afterwards.
72 pub multiplier: f64,
73 pub payout: u64,
74}
75
76impl CrashRound {
77 pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self {
78 Self {
79 bet,
80 crash_point,
81 started_at,
82 phase: "running".to_string(),
83 result: "playing".to_string(),
84 multiplier: 1.0,
85 payout: 0,
86 }
87 }
88
89 pub fn is_running(&self) -> bool {
90 self.phase == "running"
91 }
92
93 pub fn cash_out(&mut self, multiplier: f64) -> u64 {
94 self.multiplier = multiplier;
95 self.payout = payout(self.bet, multiplier);
96 self.phase = "cashed".to_string();
97 self.result = "cashed".to_string();
98 self.payout
99 }
100
101 /// The round ended before the player could cash out.
102 pub fn crash(&mut self) {
103 self.multiplier = self.crash_point;
104 self.payout = 0;
105 self.phase = "crashed".to_string();
106 self.result = "crashed".to_string();
107 }
108
109 pub fn forfeit(&mut self) {
110 self.payout = 0;
111 self.phase = "forfeit".to_string();
112 self.result = "forfeit".to_string();
113 }
114
115 pub fn state(&self, session_id: &str, balance: i64) -> CrashState {
116 CrashState {
117 session_id: session_id.to_string(),
118 phase: self.phase.clone(),
119 bet: self.bet,
120 balance,
121 multiplier: self.multiplier,
122 potential: payout(self.bet, self.multiplier),
123 crash_point: if self.is_running() {
124 None
125 } else {
126 Some(self.crash_point)
127 },
128 payout: self.payout,
129 result: self.result.clone(),
130 }
131 }
132
133 pub fn to_json(&self) -> String {
134 serde_json::to_string(self).unwrap_or_default()
135 }
136
137 pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
138 serde_json::from_str(raw)
139 }
140}
141
142/// Crash-point generator. Tests queue fixed uniforms; production draws random
143/// ones. The visible crash point stays hidden until `poll` or cashout ends the
144/// round.
145pub struct CrashGame {
146 uniforms: VecDeque<f64>,
147}
148
149impl CrashGame {
150 pub fn new() -> Self {
151 Self {
152 uniforms: VecDeque::new(),
153 }
154 }
155
156 pub fn with_uniform(uniform: f64) -> Self {
157 Self::with_uniforms([uniform])
158 }
159
160 pub fn with_uniforms(uniforms: impl IntoIterator<Item = f64>) -> Self {
161 Self {
162 uniforms: uniforms.into_iter().collect(),
163 }
164 }
165
166 pub fn next_crash_point(&mut self) -> f64 {
167 let uniform = self
168 .uniforms
169 .pop_front()
170 .unwrap_or_else(rand::random::<f64>);
171 crash_point_from_uniform(uniform)
172 }
173}
174
175impl Game for CrashGame {
176 fn game_type(&self) -> &'static str {
177 "crash"
178 }
179
180 fn get_name(&self) -> &'static str {
181 "Crash"
182 }
183
184 fn get_description(&self) -> &'static str {
185 "Crash: cash out before the multiplier crashes."
186 }
187
188 fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState {
189 let mut data = HashMap::new();
190 data.insert("status".to_string(), "ready".to_string());
191 GameState {
192 session_id: String::new(),
193 game_type: "crash".to_string(),
194 bet_amount,
195 phase: GamePhase::WaitingToStart,
196 data,
197 }
198 }
199
200 fn process_action(
201 &self,
202 _state: &GameState,
203 _action: &str,
204 _params: &[&str],
205 ) -> Result<GameState, GameError> {
206 Err(GameError::InvalidAction(
207 "Crash uses CRASH_CASHOUT and CRASH_FORFEIT".to_string(),
208 ))
209 }
210
211 fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> {
212 Err(GameError::InternalError(
213 "Crash settles through CRASH_CASHOUT".to_string(),
214 ))
215 }
216}
diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs
index 07c5772..5de9eef 100644
--- a/src/server/games/mod.rs
+++ b/src/server/games/mod.rs
@@ -1,4 +1,5 @@
1pub mod blackjack; 1pub mod blackjack;
2pub mod crash;
2pub mod registry; 3pub mod registry;
3pub mod roulette; 4pub mod roulette;
4pub mod session; 5pub mod session;
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs
index 7ff8fbf..7a8b605 100644
--- a/src/server/games/registry.rs
+++ b/src/server/games/registry.rs
@@ -1,6 +1,7 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use super::blackjack::BlackjackGame; 3use super::blackjack::BlackjackGame;
4use super::crash::CrashGame;
4use super::roulette::RouletteGame; 5use super::roulette::RouletteGame;
5use super::slots::SlotsGame; 6use super::slots::SlotsGame;
6use super::traits::Game; 7use super::traits::Game;
@@ -25,6 +26,7 @@ impl GameRegistry {
25 registry.register_game(SlotsGame::new()); 26 registry.register_game(SlotsGame::new());
26 registry.register_game(RouletteGame::new()); 27 registry.register_game(RouletteGame::new());
27 registry.register_game(BlackjackGame::new()); 28 registry.register_game(BlackjackGame::new());
29 registry.register_game(CrashGame::new());
28 registry 30 registry
29 } 31 }
30 32