summaryrefslogtreecommitdiffstats
path: root/src/server
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
commitb5a52fc0312afdf5d5fbe53275dc88d6d881c2ff (patch)
treed12e3c7f6c23ae1698199d16ee763551edfd8c12 /src/server
parentv2.1.2: ignore ANSI when measuring board height (diff)
downloadzagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.tar.gz
zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.zip
v2.2.0: add blackjack with reserved bets and hit/stand/double
Diffstat (limited to '')
-rw-r--r--src/server/core.rs187
-rw-r--r--src/server/database.rs81
-rw-r--r--src/server/games/blackjack.rs274
-rw-r--r--src/server/games/mod.rs1
-rw-r--r--src/server/games/registry.rs2
-rw-r--r--src/server/games/session.rs1
6 files changed, 544 insertions, 2 deletions
diff --git a/src/server/core.rs b/src/server/core.rs
index 2ce8b2b..cf8c4b9 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;
3 4
5use crate::blackjack::BlackjackState;
4use crate::server::database; 6use crate::server::database;
7use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
5use crate::server::games::registry::GameRegistry; 8use crate::server::games::registry::GameRegistry;
6use crate::server::games::session::SessionManager; 9use crate::server::games::session::SessionManager;
7use crate::server::games::traits::GameError; 10use crate::server::games::traits::{GameError, GamePhase, GameState};
8 11
9#[derive(Debug, Default)] 12#[derive(Debug, Default)]
10pub struct ConnectionState { 13pub struct ConnectionState {
@@ -22,6 +25,7 @@ pub struct ServerCore {
22 sessions: SessionManager, 25 sessions: SessionManager,
23 registry: GameRegistry, 26 registry: GameRegistry,
24 bcrypt_cost: u32, 27 bcrypt_cost: u32,
28 blackjack: BlackjackGame,
25} 29}
26 30
27impl ServerCore { 31impl ServerCore {
@@ -39,9 +43,16 @@ impl ServerCore {
39 sessions: SessionManager::new(), 43 sessions: SessionManager::new(),
40 registry, 44 registry,
41 bcrypt_cost, 45 bcrypt_cost,
46 blackjack: BlackjackGame::new(),
42 } 47 }
43 } 48 }
44 49
50 /// Test/utility hook: use a fixed shoe for the next hand(s).
51 pub fn with_blackjack_deck(mut self, deck: Vec<String>) -> Self {
52 self.blackjack = BlackjackGame::with_deck(deck);
53 self
54 }
55
45 pub fn open(path: &str) -> rusqlite::Result<Self> { 56 pub fn open(path: &str) -> rusqlite::Result<Self> {
46 let conn = Connection::open(path)?; 57 let conn = Connection::open(path)?;
47 database::init_db(&conn); 58 database::init_db(&conn);
@@ -85,13 +96,23 @@ impl ServerCore {
85 } 96 }
86 } 97 }
87 "PLAY_START" => { 98 "PLAY_START" => {
88 if parts.len() != 3 && parts.len() != 4 { 99 if parts.len() == 3 && parts[1] == "blackjack" {
100 self.handle_blackjack_start(state, parts[2])
101 } else if parts.len() != 3 && parts.len() != 4 {
89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]" 102 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount> [payload]"
90 .to_string() 103 .to_string()
91 } else { 104 } else {
92 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied()) 105 self.handle_play_start(state, parts[1], parts[2], parts.get(3).copied())
93 } 106 }
94 } 107 }
108 "BLACKJACK_ACTION" => {
109 if parts.len() != 3 {
110 "ERROR Invalid BLACKJACK_ACTION format. Usage: BLACKJACK_ACTION <session> <action>"
111 .to_string()
112 } else {
113 self.handle_blackjack_action(state, parts[1], parts[2])
114 }
115 }
95 "REGISTER" => { 116 "REGISTER" => {
96 if parts.len() != 3 { 117 if parts.len() != 3 {
97 "ERROR Invalid REGISTER format".to_string() 118 "ERROR Invalid REGISTER format".to_string()
@@ -182,6 +203,158 @@ impl ServerCore {
182 } 203 }
183 } 204 }
184 205
206 fn handle_blackjack_start(&mut self, state: &ConnectionState, bet_str: &str) -> String {
207 let Some(user) = state.user.as_deref() else {
208 return "ERROR Not logged in".to_string();
209 };
210 if self
211 .sessions
212 .get_user_sessions(user)
213 .iter()
214 .any(|session| session.game_type == "blackjack")
215 {
216 return "ERROR Blackjack hand already in progress".to_string();
217 }
218 let bet: i64 = match bet_str.parse() {
219 Ok(bet) => bet,
220 Err(_) => return "ERROR Invalid bet amount".to_string(),
221 };
222 if bet <= 0 {
223 return "ERROR Invalid bet amount".to_string();
224 }
225 let Some(balance) = database::get_balance(&self.conn, user) else {
226 return "ERROR Could not retrieve balance".to_string();
227 };
228 if balance < bet {
229 return "ERROR Insufficient balance".to_string();
230 }
231 let balance = match database::debit(&mut self.conn, user, bet) {
232 Ok(balance) => balance,
233 Err(_) => return "ERROR Insufficient balance".to_string(),
234 };
235
236 let hand = self.blackjack.deal(bet as u64);
237 let session_id = self.create_blackjack_session(user, &hand);
238 self.blackjack_response(user, &session_id, hand, balance)
239 }
240
241 fn create_blackjack_session(&mut self, user: &str, hand: &BlackjackHand) -> String {
242 let mut data = HashMap::new();
243 data.insert("hand".to_string(), hand.to_json());
244 let game_state = GameState {
245 session_id: String::new(),
246 game_type: "blackjack".to_string(),
247 bet_amount: hand.bet,
248 phase: GamePhase::Playing,
249 data,
250 };
251 self.sessions
252 .create_session(user, "blackjack".to_string(), game_state)
253 }
254
255 fn handle_blackjack_action(
256 &mut self,
257 state: &ConnectionState,
258 session_id: &str,
259 action: &str,
260 ) -> String {
261 let Some(user) = state.user.as_deref() else {
262 return "ERROR Not logged in".to_string();
263 };
264 let Ok(session) = self.sessions.get_session(session_id) else {
265 return "ERROR Session not found".to_string();
266 };
267 if session.username != user || session.game_type != "blackjack" {
268 return "ERROR Invalid session".to_string();
269 }
270 let Some(raw) = session.state.data.get("hand") else {
271 return "ERROR Missing hand".to_string();
272 };
273 let Ok(mut hand) = BlackjackHand::from_json(raw) else {
274 return "ERROR Invalid hand".to_string();
275 };
276 if hand.is_done() {
277 return "ERROR Hand is over".to_string();
278 }
279
280 let balance = database::get_balance(&self.conn, user).unwrap_or(0);
281 match action {
282 "hit" => {
283 if let Err(error) = hand.hit() {
284 return format!("ERROR {error}");
285 }
286 }
287 "stand" => hand.stand(),
288 "double" => {
289 if hand.player.len() != 2 {
290 return "ERROR Double is only allowed on the first two cards".to_string();
291 }
292 if database::debit(&mut self.conn, user, hand.base_bet as i64).is_err() {
293 return "ERROR Insufficient balance".to_string();
294 }
295 if let Err(error) = hand.double() {
296 let _ = database::credit(&mut self.conn, user, hand.base_bet as i64);
297 return format!("ERROR {error}");
298 }
299 }
300 "cancel" => {
301 let refund = session.bet_amount as i64;
302 let balance = database::credit(&mut self.conn, user, refund).unwrap_or(balance);
303 let _ = self.sessions.remove_session(session_id);
304 hand.cancel();
305 let state = hand.state(session_id, balance);
306 return blackjack_wire(&state);
307 }
308 _ => return "ERROR Unknown action".to_string(),
309 }
310
311 let balance = database::get_balance(&self.conn, user).unwrap_or(balance);
312 self.blackjack_response(user, session_id, hand, balance)
313 }
314
315 fn blackjack_response(
316 &mut self,
317 user: &str,
318 session_id: &str,
319 hand: BlackjackHand,
320 balance: i64,
321 ) -> String {
322 if hand.is_done() {
323 let state = hand.state(session_id, balance);
324 let details = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string());
325 let balance = match database::settle_play(
326 &mut self.conn,
327 session_id,
328 user,
329 "blackjack",
330 hand.bet as i64,
331 hand.winnings as i64,
332 &details,
333 ) {
334 Ok(balance) => balance,
335 Err(error) => {
336 eprintln!("Blackjack settlement failed for {user}: {error}");
337 let _ = self.sessions.remove_session(session_id);
338 return "ERROR Failed to update balance".to_string();
339 }
340 };
341 let _ = self.sessions.remove_session(session_id);
342 let state = hand.state(session_id, balance);
343 blackjack_wire(&state)
344 } else {
345 if let Ok(mut session) = self.sessions.get_session(session_id) {
346 session.state.bet_amount = hand.bet;
347 session
348 .state
349 .data
350 .insert("hand".to_string(), hand.to_json());
351 let _ = self.sessions.update_session(session_id, session.state);
352 }
353 let state = hand.state(session_id, balance);
354 blackjack_wire(&state)
355 }
356 }
357
185 fn handle_play_start( 358 fn handle_play_start(
186 &mut self, 359 &mut self,
187 state: &ConnectionState, 360 state: &ConnectionState,
@@ -311,6 +484,9 @@ impl ServerCore {
311 pub fn disconnect(&mut self, state: &ConnectionState) { 484 pub fn disconnect(&mut self, state: &ConnectionState) {
312 if let Some(user) = state.user.as_deref() { 485 if let Some(user) = state.user.as_deref() {
313 for session in self.sessions.get_user_sessions(user) { 486 for session in self.sessions.get_user_sessions(user) {
487 if session.game_type == "blackjack" {
488 let _ = database::credit(&mut self.conn, user, session.bet_amount as i64);
489 }
314 let _ = self.sessions.remove_session(&session.session_id); 490 let _ = self.sessions.remove_session(&session.session_id);
315 } 491 }
316 } 492 }
@@ -339,3 +515,10 @@ fn game_error(error: GameError) -> String {
339 GameError::InternalError(message) => format!("ERROR {message}"), 515 GameError::InternalError(message) => format!("ERROR {message}"),
340 } 516 }
341} 517}
518
519fn blackjack_wire(state: &BlackjackState) -> String {
520 format!(
521 "BLACKJACK_STATE {}",
522 serde_json::to_string(state).unwrap_or_else(|_| "{}".to_string())
523 )
524}
diff --git a/src/server/database.rs b/src/server/database.rs
index 2a1fc40..99e0dde 100644
--- a/src/server/database.rs
+++ b/src/server/database.rs
@@ -177,3 +177,84 @@ pub fn count_plays(conn: &Connection, username: &str) -> i64 {
177 ) 177 )
178 .unwrap_or(0) 178 .unwrap_or(0)
179} 179}
180
181/// Reserve a bet before a multi-step hand starts.
182pub fn debit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> {
183 let tx = conn.transaction()?;
184 let updated = tx.execute(
185 "UPDATE users SET balance = balance - ?1
186 WHERE username = ?2 AND balance >= ?1",
187 params![amount, username],
188 )?;
189 if updated != 1 {
190 return Err(rusqlite::Error::QueryReturnedNoRows);
191 }
192 let balance = tx.query_row(
193 "SELECT balance FROM users WHERE username = ?1",
194 [username],
195 |row| row.get(0),
196 )?;
197 tx.commit()?;
198 Ok(balance)
199}
200
201/// Put a reserved bet back (disconnect or cancel).
202pub fn credit(conn: &mut Connection, username: &str, amount: i64) -> rusqlite::Result<i64> {
203 let tx = conn.transaction()?;
204 tx.execute(
205 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
206 params![amount, username],
207 )?;
208 let balance = tx.query_row(
209 "SELECT balance FROM users WHERE username = ?1",
210 [username],
211 |row| row.get(0),
212 )?;
213 tx.commit()?;
214 Ok(balance)
215}
216
217/// Settle a hand whose bet was already reserved: credit winnings, write the
218/// audit row, and return the new balance.
219#[allow(clippy::too_many_arguments)]
220pub fn settle_play(
221 conn: &mut Connection,
222 session_id: &str,
223 username: &str,
224 game_type: &str,
225 bet_amount: i64,
226 winnings: i64,
227 details: &str,
228) -> rusqlite::Result<i64> {
229 let tx = conn.transaction()?;
230
231 tx.execute(
232 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
233 params![winnings, username],
234 )?;
235
236 let balance_after: i64 = tx.query_row(
237 "SELECT balance FROM users WHERE username = ?1",
238 [username],
239 |row| row.get(0),
240 )?;
241
242 tx.execute(
243 "INSERT INTO plays
244 (session_id, username, game_type, bet_amount, winnings,
245 balance_after, details)
246 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
247 params![
248 session_id,
249 username,
250 game_type,
251 bet_amount,
252 winnings,
253 balance_after,
254 details
255 ],
256 )?;
257
258 tx.commit()?;
259 Ok(balance_after)
260}
diff --git a/src/server/games/blackjack.rs b/src/server/games/blackjack.rs
new file mode 100644
index 0000000..9505fed
--- /dev/null
+++ b/src/server/games/blackjack.rs
@@ -0,0 +1,274 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::blackjack::{
3 card_value, fresh_shoe, hand_total, is_blackjack, BlackjackState,
4};
5use rand::seq::SliceRandom;
6use rand::thread_rng;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10pub struct BlackjackGame {
11 fixed_deck: Option<Vec<String>>,
12}
13
14impl BlackjackGame {
15 pub fn new() -> Self {
16 Self { fixed_deck: None }
17 }
18
19 pub fn with_deck(deck: Vec<String>) -> Self {
20 Self {
21 fixed_deck: Some(deck),
22 }
23 }
24
25 fn shoe(&self) -> Vec<String> {
26 if let Some(deck) = &self.fixed_deck {
27 return deck.clone();
28 }
29 let mut shoe = fresh_shoe();
30 shoe.shuffle(&mut thread_rng());
31 shoe
32 }
33
34 pub fn deal(&self, bet: u64) -> BlackjackHand {
35 let mut hand = BlackjackHand::new(self.shoe(), bet);
36 if is_blackjack(&hand.player) || is_blackjack(&hand.dealer) {
37 hand.settle();
38 }
39 hand
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct BlackjackHand {
45 pub shoe: Vec<String>,
46 pub player: Vec<String>,
47 pub dealer: Vec<String>,
48 pub phase: String,
49 pub base_bet: u64,
50 pub bet: u64,
51 pub result: String,
52 pub winnings: u64,
53}
54
55impl BlackjackHand {
56 fn new(shoe: Vec<String>, bet: u64) -> Self {
57 let mut hand = Self {
58 shoe,
59 player: Vec::new(),
60 dealer: Vec::new(),
61 phase: "player".to_string(),
62 base_bet: bet,
63 bet,
64 result: "playing".to_string(),
65 winnings: 0,
66 };
67
68 let card = hand.draw();
69 hand.player.push(card);
70 let card = hand.draw();
71 hand.dealer.push(card);
72 let card = hand.draw();
73 hand.player.push(card);
74 let card = hand.draw();
75 hand.dealer.push(card);
76
77 hand
78 }
79
80 fn draw(&mut self) -> String {
81 self.shoe.remove(0)
82 }
83
84 pub fn player_total(&self) -> u8 {
85 hand_total(&self.player)
86 }
87
88 pub fn dealer_total(&self) -> u8 {
89 hand_total(&self.dealer)
90 }
91
92 pub fn is_done(&self) -> bool {
93 self.phase == "done"
94 }
95
96 pub fn hit(&mut self) -> Result<(), String> {
97 if self.is_done() {
98 return Err("Hand is over".to_string());
99 }
100 let card = self.draw();
101 self.player.push(card);
102 let total = self.player_total();
103 if total > 21 {
104 self.settle();
105 } else if total == 21 {
106 self.dealer_play();
107 self.settle();
108 }
109 Ok(())
110 }
111
112 pub fn stand(&mut self) {
113 if self.is_done() {
114 return;
115 }
116 self.dealer_play();
117 self.settle();
118 }
119
120 /// Caller must debit the extra `base_bet` before calling this.
121 pub fn double(&mut self) -> Result<(), String> {
122 if self.is_done() {
123 return Err("Hand is over".to_string());
124 }
125 if self.player.len() != 2 {
126 return Err("Double is only allowed on the first two cards".to_string());
127 }
128 self.bet = self.bet.saturating_add(self.base_bet);
129 let card = self.draw();
130 self.player.push(card);
131 self.dealer_play();
132 self.settle();
133 Ok(())
134 }
135
136 pub fn cancel(&mut self) {
137 self.phase = "done".to_string();
138 self.result = "cancelled".to_string();
139 self.winnings = 0;
140 }
141
142 fn dealer_play(&mut self) {
143 while self.dealer_total() < 17 {
144 let card = self.draw();
145 self.dealer.push(card);
146 }
147 }
148
149 fn settle(&mut self) {
150 if self.is_done() {
151 return;
152 }
153 self.phase = "done".to_string();
154
155 let player = self.player_total();
156 let dealer = self.dealer_total();
157 let player_blackjack = is_blackjack(&self.player);
158 let dealer_blackjack = is_blackjack(&self.dealer);
159
160 self.result = if player > 21 {
161 "bust".to_string()
162 } else if player_blackjack && !dealer_blackjack {
163 "blackjack".to_string()
164 } else if dealer_blackjack && !player_blackjack {
165 "lose".to_string()
166 } else if dealer > 21 {
167 "win".to_string()
168 } else if player == dealer {
169 "push".to_string()
170 } else if player > dealer {
171 "win".to_string()
172 } else {
173 "lose".to_string()
174 };
175
176 self.winnings = match self.result.as_str() {
177 "push" => self.bet,
178 "blackjack" => self.bet + (self.bet * 3) / 2,
179 "win" => self.bet * 2,
180 _ => 0,
181 };
182 }
183
184 pub fn state(&self, session_id: &str, balance: i64) -> BlackjackState {
185 let player_total = self.player_total();
186 let (dealer, dealer_total, actions) = if self.is_done() {
187 (self.dealer.clone(), self.dealer_total(), Vec::new())
188 } else {
189 let visible = self
190 .dealer
191 .first()
192 .map(|card| card_value(card))
193 .unwrap_or(0);
194 let mut actions = vec!["hit".to_string(), "stand".to_string()];
195 if self.player.len() == 2 {
196 actions.push("double".to_string());
197 }
198 (
199 vec![
200 self.dealer.first().cloned().unwrap_or_default(),
201 "?".to_string(),
202 ],
203 visible,
204 actions,
205 )
206 };
207
208 BlackjackState {
209 session_id: session_id.to_string(),
210 phase: self.phase.clone(),
211 player: self.player.clone(),
212 dealer,
213 player_total,
214 dealer_total,
215 bet: self.bet,
216 base_bet: self.base_bet,
217 balance,
218 actions,
219 result: self.result.clone(),
220 winnings: self.winnings,
221 }
222 }
223
224 pub fn to_json(&self) -> String {
225 serde_json::to_string(self).unwrap_or_default()
226 }
227
228 pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
229 serde_json::from_str(raw)
230 }
231}
232
233impl Game for BlackjackGame {
234 fn game_type(&self) -> &'static str {
235 "blackjack"
236 }
237
238 fn get_name(&self) -> &'static str {
239 "Blackjack"
240 }
241
242 fn get_description(&self) -> &'static str {
243 "Blackjack: hit, stand or double. Dealer stands on all 17s."
244 }
245
246 fn prepare_state(&self, _username: &str, bet_amount: u64) -> GameState {
247 let mut data = HashMap::new();
248 data.insert("status".to_string(), "ready".to_string());
249 GameState {
250 session_id: String::new(),
251 game_type: "blackjack".to_string(),
252 bet_amount,
253 phase: GamePhase::WaitingToStart,
254 data,
255 }
256 }
257
258 fn process_action(
259 &self,
260 _state: &GameState,
261 _action: &str,
262 _params: &[&str],
263 ) -> Result<GameState, GameError> {
264 Err(GameError::InvalidAction(
265 "Blackjack uses BLACKJACK_ACTION".to_string(),
266 ))
267 }
268
269 fn calculate_result(&self, _state: &GameState) -> Result<GameResult, GameError> {
270 Err(GameError::InternalError(
271 "Blackjack settles through BLACKJACK_ACTION".to_string(),
272 ))
273 }
274}
diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs
index 35728a1..07c5772 100644
--- a/src/server/games/mod.rs
+++ b/src/server/games/mod.rs
@@ -1,3 +1,4 @@
1pub mod blackjack;
1pub mod registry; 2pub mod registry;
2pub mod roulette; 3pub mod roulette;
3pub mod session; 4pub mod session;
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs
index 51d446b..7ff8fbf 100644
--- a/src/server/games/registry.rs
+++ b/src/server/games/registry.rs
@@ -1,5 +1,6 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use super::blackjack::BlackjackGame;
3use super::roulette::RouletteGame; 4use super::roulette::RouletteGame;
4use super::slots::SlotsGame; 5use super::slots::SlotsGame;
5use super::traits::Game; 6use super::traits::Game;
@@ -23,6 +24,7 @@ impl GameRegistry {
23 let registry = Self::new(); 24 let registry = Self::new();
24 registry.register_game(SlotsGame::new()); 25 registry.register_game(SlotsGame::new());
25 registry.register_game(RouletteGame::new()); 26 registry.register_game(RouletteGame::new());
27 registry.register_game(BlackjackGame::new());
26 registry 28 registry
27 } 29 }
28 30
diff --git a/src/server/games/session.rs b/src/server/games/session.rs
index a8410a9..916a45d 100644
--- a/src/server/games/session.rs
+++ b/src/server/games/session.rs
@@ -51,6 +51,7 @@ impl SessionManager {
51 pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> { 51 pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> {
52 let mut sessions = self.sessions.lock().unwrap(); 52 let mut sessions = self.sessions.lock().unwrap();
53 if let Some(session) = sessions.get_mut(session_id) { 53 if let Some(session) = sessions.get_mut(session_id) {
54 session.bet_amount = new_state.bet_amount;
54 session.state = new_state; 55 session.state = new_state;
55 Ok(()) 56 Ok(())
56 } else { 57 } else {