summaryrefslogtreecommitdiffstats
path: root/src/server/core.rs
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/core.rs
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 'src/server/core.rs')
-rw-r--r--src/server/core.rs240
1 files changed, 240 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}