summaryrefslogtreecommitdiffstats
path: root/src/server
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:44:41 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:44:41 +0100
commit63c2e2fc3ea299aee31b66d2bdd6ecd871df223a (patch)
tree9c85ab7a4de17125055257dc055fa3e53dc1e8cf /src/server
parentv0.2.6: preserve buffered lines and add request read timeouts (diff)
downloadzagan-bet-63c2e2fc3ea299aee31b66d2bdd6ecd871df223a.tar.gz
zagan-bet-63c2e2fc3ea299aee31b66d2bdd6ecd871df223a.zip
v0.3.0: add server core, bcrypt migration and plays audit
Diffstat (limited to 'src/server')
-rw-r--r--src/server/core.rs303
-rw-r--r--src/server/database.rs123
-rw-r--r--src/server/games/registry.rs22
-rw-r--r--src/server/games/session.rs8
-rw-r--r--src/server/games/slots.rs23
-rw-r--r--src/server/mod.rs1
6 files changed, 453 insertions, 27 deletions
diff --git a/src/server/core.rs b/src/server/core.rs
new file mode 100644
index 0000000..84ad4d2
--- /dev/null
+++ b/src/server/core.rs
@@ -0,0 +1,303 @@
1use bcrypt::{hash, verify, DEFAULT_COST};
2use rusqlite::Connection;
3
4use crate::server::database;
5use crate::server::games::registry::GameRegistry;
6use crate::server::games::session::SessionManager;
7use crate::server::games::traits::GameError;
8
9#[derive(Debug, Default)]
10pub struct ConnectionState {
11 user: Option<String>,
12}
13
14impl ConnectionState {
15 pub fn user(&self) -> Option<&str> {
16 self.user.as_deref()
17 }
18}
19
20pub struct ServerCore {
21 conn: Connection,
22 sessions: SessionManager,
23 registry: GameRegistry,
24 bcrypt_cost: u32,
25}
26
27impl ServerCore {
28 pub fn new(conn: Connection) -> Self {
29 Self::with_registry(conn, GameRegistry::with_defaults(), DEFAULT_COST)
30 }
31
32 pub fn with_registry(
33 conn: Connection,
34 registry: GameRegistry,
35 bcrypt_cost: u32,
36 ) -> Self {
37 Self {
38 conn,
39 sessions: SessionManager::new(),
40 registry,
41 bcrypt_cost,
42 }
43 }
44
45 pub fn open(path: &str) -> rusqlite::Result<Self> {
46 let conn = Connection::open(path)?;
47 database::init_db(&conn);
48 Ok(Self::new(conn))
49 }
50
51 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
52 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
53 match parts[0] {
54 "GAME_LIST" => {
55 let mut games = self.registry.list_games();
56 games.sort();
57 let info: Vec<String> = games
58 .iter()
59 .filter_map(|game_type| {
60 self.registry
61 .get_game(game_type)
62 .map(|game| format!("{}:{}", game_type, game.get_name()))
63 })
64 .collect();
65 if info.is_empty() {
66 "ERROR No games available".to_string()
67 } else {
68 format!("GAME_LIST {}", info.join(","))
69 }
70 }
71 "GAME_INFO" => {
72 if parts.len() != 2 {
73 "ERROR Invalid GAME_INFO format. Usage: GAME_INFO <game_type>"
74 .to_string()
75 } else {
76 match self.registry.get_game(parts[1]) {
77 Some(game) => format!(
78 "GAME_INFO {} {} {}",
79 parts[1],
80 game.get_name(),
81 game.get_description()
82 ),
83 None => format!("ERROR Unknown game type: {}", parts[1]),
84 }
85 }
86 }
87 "PLAY_START" => {
88 if parts.len() != 3 {
89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>"
90 .to_string()
91 } else {
92 self.handle_play_start(state, parts[1], parts[2])
93 }
94 }
95 "REGISTER" => {
96 if parts.len() != 3 {
97 "ERROR Invalid REGISTER format".to_string()
98 } else {
99 self.handle_register(parts[1], parts[2])
100 }
101 }
102 "AUTH" => {
103 if parts.len() != 3 {
104 "ERROR Invalid AUTH format".to_string()
105 } else {
106 self.handle_auth(state, parts[1], parts[2])
107 }
108 }
109 "PING" => "PONG".to_string(),
110 "INFO" => self.handle_info(state),
111 _ => msg.to_string(),
112 }
113 }
114
115 fn handle_register(&mut self, username: &str, credential: &str) -> String {
116 if database::get_hashed_password(&self.conn, username).is_some() {
117 return "ERROR User already exists".to_string();
118 }
119
120 let hashed = match hash(credential, self.bcrypt_cost) {
121 Ok(hashed) => hashed,
122 Err(error) => {
123 eprintln!("bcrypt failed for {username}: {error}");
124 return "ERROR Could not hash password".to_string();
125 }
126 };
127
128 match database::add_user(&self.conn, username, &hashed) {
129 Ok(()) => format!("REGISTERED {}", username),
130 Err(_) => "ERROR User already exists".to_string(),
131 }
132 }
133
134 fn handle_auth(
135 &mut self,
136 state: &mut ConnectionState,
137 username: &str,
138 credential: &str,
139 ) -> String {
140 let Some(stored) = database::get_hashed_password(&self.conn, username) else {
141 return "ERROR User not found".to_string();
142 };
143
144 let valid = if stored.starts_with("$2") {
145 verify(credential, &stored).unwrap_or(false)
146 } else if stored == credential {
147 // Legacy unsalted SHA-256 row: accept once, then upgrade in place.
148 match hash(credential, self.bcrypt_cost) {
149 Ok(upgraded) => {
150 if let Err(error) =
151 database::set_hashed_password(&self.conn, username, &upgraded)
152 {
153 eprintln!("failed to upgrade password for {username}: {error}");
154 }
155 }
156 Err(error) => eprintln!("bcrypt failed for {username}: {error}"),
157 }
158 true
159 } else {
160 false
161 };
162
163 if valid {
164 state.user = Some(username.to_string());
165 format!("OK {}", username)
166 } else {
167 "ERROR Invalid credentials".to_string()
168 }
169 }
170
171 fn handle_info(&self, state: &ConnectionState) -> String {
172 let Some(user) = state.user.as_deref() else {
173 return "ERROR Not logged in".to_string();
174 };
175
176 match (
177 database::get_account_id(&self.conn, user),
178 database::get_balance(&self.conn, user),
179 ) {
180 (Some(id), Some(balance)) => format!("INFO {} {} {}", user, id, balance),
181 _ => "ERROR Error retrieving user info".to_string(),
182 }
183 }
184
185 fn handle_play_start(
186 &mut self,
187 state: &ConnectionState,
188 game_type: &str,
189 bet_amount_str: &str,
190 ) -> String {
191 let Some(user) = state.user.as_deref() else {
192 return "ERROR Not logged in".to_string();
193 };
194
195 let bet_amount: i64 = match bet_amount_str.parse() {
196 Ok(amount) => amount,
197 Err(_) => return "ERROR Invalid bet amount".to_string(),
198 };
199 if bet_amount <= 0 {
200 return "ERROR Invalid bet amount".to_string();
201 }
202
203 let Some(game) = self.registry.get_game(game_type) else {
204 return format!("ERROR Unknown game type: {}", game_type);
205 };
206
207 let Some(balance) = database::get_balance(&self.conn, user) else {
208 return "ERROR Could not retrieve balance".to_string();
209 };
210 if balance < 0 {
211 return "ERROR Invalid balance".to_string();
212 }
213
214 if let Err(error) = game.validate_bet(bet_amount as u64, balance as u64) {
215 return game_error(error);
216 }
217
218 let game_state = game.prepare_state(user, bet_amount as u64);
219 let new_state = match game.process_action(&game_state, "spin", &[]) {
220 Ok(state) => state,
221 Err(error) => return game_error(error),
222 };
223 let result = match game.calculate_result(&new_state) {
224 Ok(result) => result,
225 Err(error) => return game_error(error),
226 };
227
228 // The session is the audit guard for the deduction and is created
229 // before the balance changes. The durable audit row is `plays`.
230 let session_id =
231 self.sessions
232 .create_session(user, game_type.to_string(), new_state.clone());
233
234 let winnings = match i64::try_from(result.winnings) {
235 Ok(winnings) => winnings,
236 Err(_) => {
237 let _ = self.sessions.remove_session(&session_id);
238 return "ERROR Winnings out of range".to_string();
239 }
240 };
241
242 let reels = new_state.data.get("reels").cloned().unwrap_or_default();
243 let win_type = new_state
244 .data
245 .get("win_type")
246 .cloned()
247 .unwrap_or_default();
248
249 match database::apply_spin(
250 &mut self.conn,
251 &session_id,
252 user,
253 game_type,
254 bet_amount,
255 winnings,
256 &reels,
257 &win_type,
258 ) {
259 Ok(new_balance) => {
260 let _ = self.sessions.remove_session(&session_id);
261 format!(
262 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type}"
263 )
264 }
265 Err(error) => {
266 let _ = self.sessions.remove_session(&session_id);
267 eprintln!("Balance transaction failed for {user}: {error}");
268 "ERROR Failed to update balance".to_string()
269 }
270 }
271 }
272
273 pub fn disconnect(&mut self, state: &ConnectionState) {
274 if let Some(user) = state.user.as_deref() {
275 for session in self.sessions.get_user_sessions(user) {
276 let _ = self.sessions.remove_session(&session.session_id);
277 }
278 }
279 }
280
281 pub fn balance(&self, username: &str) -> Option<i64> {
282 database::get_balance(&self.conn, username)
283 }
284
285 pub fn play_count(&self, username: &str) -> i64 {
286 database::count_plays(&self.conn, username)
287 }
288
289 pub fn session_count(&self) -> usize {
290 self.sessions.len()
291 }
292}
293
294fn game_error(error: GameError) -> String {
295 match error {
296 GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(),
297 GameError::InvalidBet => "ERROR Invalid bet amount".to_string(),
298 GameError::InvalidAction(message) => format!("ERROR {message}"),
299 GameError::GameOver => "ERROR Game over".to_string(),
300 GameError::SessionNotFound => "ERROR Session not found".to_string(),
301 GameError::InternalError(message) => format!("ERROR {message}"),
302 }
303}
diff --git a/src/server/database.rs b/src/server/database.rs
index c73aec5..f6c8872 100644
--- a/src/server/database.rs
+++ b/src/server/database.rs
@@ -1,4 +1,4 @@
1use rusqlite::Connection; 1use rusqlite::{params, Connection};
2 2
3pub fn init_db(conn: &Connection) { 3pub fn init_db(conn: &Connection) {
4 conn.execute( 4 conn.execute(
@@ -17,6 +17,21 @@ pub fn init_db(conn: &Connection) {
17 "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", 17 "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000",
18 [], 18 [],
19 ); 19 );
20 conn.execute(
21 "CREATE TABLE IF NOT EXISTS plays (
22 session_id TEXT PRIMARY KEY,
23 username TEXT NOT NULL,
24 game_type TEXT NOT NULL,
25 bet_amount INTEGER NOT NULL,
26 winnings INTEGER NOT NULL,
27 reels TEXT NOT NULL DEFAULT '',
28 win_type TEXT NOT NULL DEFAULT '',
29 balance_after INTEGER NOT NULL,
30 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
31 )",
32 [],
33 )
34 .unwrap();
20} 35}
21 36
22pub fn add_user( 37pub fn add_user(
@@ -24,17 +39,28 @@ pub fn add_user(
24 username: &str, 39 username: &str,
25 hashed_password: &str, 40 hashed_password: &str,
26) -> Result<(), Box<dyn std::error::Error>> { 41) -> Result<(), Box<dyn std::error::Error>> {
27 loop { 42 if get_hashed_password(conn, username).is_some() {
43 return Err("user already exists".into());
44 }
45
46 for _ in 0..10 {
28 let account_id: u32 = rand::random::<u32>(); 47 let account_id: u32 = rand::random::<u32>();
29 match conn.execute( 48 match conn.execute(
30 "INSERT INTO users (username, hashed_password, account_id, balance) VALUES (?1, ?2, ?3, ?4)", 49 "INSERT INTO users (username, hashed_password, account_id, balance)
31 [username, hashed_password, &account_id.to_string(), "1000"], 50 VALUES (?1, ?2, ?3, ?4)",
51 params![username, hashed_password, account_id as i64, 1000i64],
32 ) { 52 ) {
33 Ok(_) => break, 53 Ok(_) => return Ok(()),
34 Err(_) => continue, // Retry if account_id conflict 54 Err(rusqlite::Error::SqliteFailure(error, _))
55 if error.code == rusqlite::ErrorCode::ConstraintViolation =>
56 {
57 continue;
58 }
59 Err(error) => return Err(Box::new(error)),
35 } 60 }
36 } 61 }
37 Ok(()) 62
63 Err("could not allocate a unique account id".into())
38} 64}
39 65
40pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String> { 66pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String> {
@@ -49,13 +75,25 @@ pub fn get_hashed_password(conn: &Connection, username: &str) -> Option<String>
49 } 75 }
50} 76}
51 77
52pub fn get_account_id(conn: &Connection, username: &str) -> Option<u32> { 78pub fn set_hashed_password(
79 conn: &Connection,
80 username: &str,
81 hashed_password: &str,
82) -> rusqlite::Result<()> {
83 conn.execute(
84 "UPDATE users SET hashed_password = ?1 WHERE username = ?2",
85 params![hashed_password, username],
86 )?;
87 Ok(())
88}
89
90pub fn get_account_id(conn: &Connection, username: &str) -> Option<i64> {
53 let mut stmt = conn 91 let mut stmt = conn
54 .prepare("SELECT account_id FROM users WHERE username = ?1") 92 .prepare("SELECT account_id FROM users WHERE username = ?1")
55 .unwrap(); 93 .unwrap();
56 let mut rows = stmt.query([username]).unwrap(); 94 let mut rows = stmt.query([username]).unwrap();
57 if let Ok(Some(row)) = rows.next() { 95 if let Ok(Some(row)) = rows.next() {
58 Some(row.get::<_, u32>(0).ok()?) 96 Some(row.get::<_, i64>(0).ok()?)
59 } else { 97 } else {
60 None 98 None
61 } 99 }
@@ -72,3 +110,70 @@ pub fn get_balance(conn: &Connection, username: &str) -> Option<i64> {
72 None 110 None
73 } 111 }
74} 112}
113
114/// Settle one spin and record its audit row in a single transaction.
115///
116/// Returns the balance after the spin. All SQL is parameterized: usernames
117/// are data, never part of the statement text.
118#[allow(clippy::too_many_arguments)]
119pub fn apply_spin(
120 conn: &mut Connection,
121 session_id: &str,
122 username: &str,
123 game_type: &str,
124 bet_amount: i64,
125 winnings: i64,
126 reels: &str,
127 win_type: &str,
128) -> rusqlite::Result<i64> {
129 let tx = conn.transaction()?;
130
131 let deducted = tx.execute(
132 "UPDATE users SET balance = balance - ?1
133 WHERE username = ?2 AND balance >= ?1",
134 params![bet_amount, username],
135 )?;
136 if deducted != 1 {
137 return Err(rusqlite::Error::QueryReturnedNoRows);
138 }
139
140 tx.execute(
141 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
142 params![winnings, username],
143 )?;
144
145 let balance_after: i64 = tx.query_row(
146 "SELECT balance FROM users WHERE username = ?1",
147 [username],
148 |row| row.get(0),
149 )?;
150
151 tx.execute(
152 "INSERT INTO plays
153 (session_id, username, game_type, bet_amount, winnings, reels,
154 win_type, balance_after)
155 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
156 params![
157 session_id,
158 username,
159 game_type,
160 bet_amount,
161 winnings,
162 reels,
163 win_type,
164 balance_after
165 ],
166 )?;
167
168 tx.commit()?;
169 Ok(balance_after)
170}
171
172pub fn count_plays(conn: &Connection, username: &str) -> i64 {
173 conn.query_row(
174 "SELECT COUNT(*) FROM plays WHERE username = ?1",
175 [username],
176 |row| row.get(0),
177 )
178 .unwrap_or(0)
179}
diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs
index 3b8c391..b7ed9d3 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::slots::SlotsGame;
3use super::traits::Game; 4use super::traits::Game;
4use std::collections::HashMap; 5use std::collections::HashMap;
5use std::sync::{Arc, Mutex}; 6use std::sync::{Arc, Mutex};
@@ -17,6 +18,12 @@ impl GameRegistry {
17 } 18 }
18 } 19 }
19 20
21 pub fn with_defaults() -> Self {
22 let registry = Self::new();
23 registry.register_game(SlotsGame::new());
24 registry
25 }
26
20 pub fn register_game<G: Game + Send + Sync + 'static>(&self, game: G) { 27 pub fn register_game<G: Game + Send + Sync + 'static>(&self, game: G) {
21 let game_type = game.game_type().to_string(); 28 let game_type = game.game_type().to_string();
22 let mut games = self.games.lock().unwrap(); 29 let mut games = self.games.lock().unwrap();
@@ -38,18 +45,3 @@ impl GameRegistry {
38 games.contains_key(game_type) 45 games.contains_key(game_type)
39 } 46 }
40} 47}
41
42// Global registry instance
43use super::slots::SlotsGame;
44use once_cell::sync::Lazy;
45
46static GAME_REGISTRY: Lazy<GameRegistry> = Lazy::new(|| {
47 let registry = GameRegistry::new();
48 // Register games here
49 registry.register_game(SlotsGame::new());
50 registry
51});
52
53pub fn get_game_registry() -> &'static GameRegistry {
54 &GAME_REGISTRY
55}
diff --git a/src/server/games/session.rs b/src/server/games/session.rs
index 97ceaf3..a8410a9 100644
--- a/src/server/games/session.rs
+++ b/src/server/games/session.rs
@@ -74,4 +74,12 @@ impl SessionManager {
74 .cloned() 74 .cloned()
75 .collect() 75 .collect()
76 } 76 }
77
78 pub fn len(&self) -> usize {
79 self.sessions.lock().unwrap().len()
80 }
81
82 pub fn is_empty(&self) -> bool {
83 self.len() == 0
84 }
77} 85}
diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs
index 0a9797d..02595b1 100644
--- a/src/server/games/slots.rs
+++ b/src/server/games/slots.rs
@@ -5,6 +5,8 @@ use std::collections::HashMap;
5pub struct SlotsGame { 5pub struct SlotsGame {
6 symbols: Vec<char>, 6 symbols: Vec<char>,
7 payouts: HashMap<String, u64>, 7 payouts: HashMap<String, u64>,
8 /// Deterministic reels for tests and demos.
9 fixed_reels: Option<Vec<char>>,
8} 10}
9 11
10impl SlotsGame { 12impl SlotsGame {
@@ -27,10 +29,25 @@ impl SlotsGame {
27 payouts.insert("DD".to_string(), 2); 29 payouts.insert("DD".to_string(), 2);
28 payouts.insert("SS".to_string(), 3); 30 payouts.insert("SS".to_string(), 3);
29 31
30 Self { symbols, payouts } 32 Self {
33 symbols,
34 payouts,
35 fixed_reels: None,
36 }
37 }
38
39 /// Build a slots game that always spins the given three symbols.
40 pub fn with_reels(reels: &str) -> Self {
41 let mut game = Self::new();
42 game.fixed_reels = Some(reels.chars().take(3).collect());
43 game
31 } 44 }
32 45
33 fn spin_reels(&self) -> Vec<char> { 46 fn spin_reels(&self) -> Vec<char> {
47 if let Some(reels) = &self.fixed_reels {
48 return reels.clone();
49 }
50
34 let mut rng = rand::thread_rng(); 51 let mut rng = rand::thread_rng();
35 (0..3) 52 (0..3)
36 .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) 53 .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())])
@@ -41,7 +58,7 @@ impl SlotsGame {
41 let reel_string: String = reels.iter().collect(); 58 let reel_string: String = reels.iter().collect();
42 59
43 if let Some(&multiplier) = self.payouts.get(&reel_string) { 60 if let Some(&multiplier) = self.payouts.get(&reel_string) {
44 return bet * multiplier; 61 return bet.saturating_mul(multiplier);
45 } 62 }
46 63
47 // Check all three pairs including non-adjacent (e.g. [C,D,C]) 64 // Check all three pairs including non-adjacent (e.g. [C,D,C])
@@ -51,7 +68,7 @@ impl SlotsGame {
51 if reels[a] == reels[b] { 68 if reels[a] == reels[b] {
52 let two_of_kind = format!("{}{}", reels[a], reels[a]); 69 let two_of_kind = format!("{}{}", reels[a], reels[a]);
53 if let Some(&multiplier) = self.payouts.get(&two_of_kind) { 70 if let Some(&multiplier) = self.payouts.get(&two_of_kind) {
54 best = best.max(bet * multiplier); 71 best = best.max(bet.saturating_mul(multiplier));
55 } 72 }
56 } 73 }
57 } 74 }
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 5b38158..6e80f78 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,2 +1,3 @@
1pub mod core;
1pub mod database; 2pub mod database;
2pub mod games; 3pub mod games;