summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--Cargo.toml2
-rw-r--r--src/bin/server/handler.rs252
-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
-rw-r--r--tests/server_core.rs246
-rw-r--r--tests/slots.rs7
10 files changed, 734 insertions, 253 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 359d16f..6fd0b3e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.2.6" 3version = "0.3.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs
index 06ed5cb..86b09cb 100644
--- a/src/bin/server/handler.rs
+++ b/src/bin/server/handler.rs
@@ -1,228 +1,34 @@
1use zagan_bet::server::database;
2use zagan_bet::server::games::registry::get_game_registry;
3use zagan_bet::server::games::session::SessionManager;
4use zagan_bet::server::games::traits::GameError;
5use crate::tls::TlsStream; 1use crate::tls::TlsStream;
6use once_cell::sync::Lazy; 2use zagan_bet::server::core::{ConnectionState, ServerCore};
7use parking_lot::Mutex;
8use rusqlite::Connection;
9use std::io::{BufRead, BufReader, Write};
10use std::net::TcpStream;
11 3
12static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { 4pub fn handle_client_tls(mut stream: TlsStream) {
13 let conn = Connection::open( 5 let client_addr = match stream.peer_addr() {
14 std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()), 6 Ok(addr) => addr.to_string(),
15 ) 7 Err(error) => {
16 .unwrap(); 8 eprintln!("Could not get peer address: {error}");
17 database::init_db(&conn); 9 return;
18 Mutex::new(conn)
19});
20
21static SESSIONS: Lazy<SessionManager> = Lazy::new(SessionManager::new);
22
23pub fn handle_client(stream: TcpStream) {
24 let client_addr = stream.peer_addr().unwrap().to_string();
25 let mut user = "guest".to_string();
26 println!("[{} @ {}] Client connected", user, client_addr);
27 let reader_stream = stream.try_clone().unwrap();
28 let reader = BufReader::new(reader_stream);
29 let mut writer = stream;
30 for line in reader.lines() {
31 let line = line.unwrap();
32 println!("[{} @ {}] Received: {}", user, client_addr, line);
33 let response = process_message(&line, &mut user);
34 writeln!(writer, "{}", response).unwrap();
35 println!("[{} @ {}] Sent: {}", user, client_addr, response);
36 }
37 println!("[{} @ {}] Client disconnected", user, client_addr);
38}
39
40fn process_message(msg: &str, user: &mut String) -> String {
41 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
42 match parts[0] {
43 "GAME_LIST" => {
44 let registry = get_game_registry();
45 let games: Vec<String> = registry.list_games();
46 if games.is_empty() {
47 "ERROR No games available".to_string()
48 } else {
49 let game_info: Vec<String> = games
50 .iter()
51 .filter_map(|gt| {
52 registry
53 .get_game(gt)
54 .map(|g| format!("{}:{}", gt, g.get_name()))
55 })
56 .collect();
57 format!("GAME_LIST {}", game_info.join(","))
58 }
59 }
60 "GAME_INFO" => {
61 if parts.len() != 2 {
62 "ERROR Invalid GAME_INFO format. Usage: GAME_INFO <game_type>".to_string()
63 } else {
64 let registry = get_game_registry();
65 match registry.get_game(parts[1]) {
66 Some(game) => format!(
67 "GAME_INFO {} {} {}",
68 parts[1],
69 game.get_name(),
70 game.get_description()
71 ),
72 None => format!("ERROR Unknown game type: {}", parts[1]),
73 }
74 }
75 }
76 "PLAY_START" => {
77 if parts.len() != 3 {
78 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>"
79 .to_string()
80 } else {
81 handle_play_start(user, parts[1], parts[2])
82 }
83 }
84 "REGISTER" => {
85 if parts.len() != 3 {
86 "ERROR Invalid REGISTER format".to_string()
87 } else {
88 let username = parts[1];
89 let hashed_password = parts[2];
90 let db = DB_CONN.lock();
91 match database::add_user(&db, username, hashed_password) {
92 Ok(_) => format!("REGISTERED {}", username),
93 Err(_) => "ERROR User already exists".to_string(),
94 }
95 }
96 }
97 "AUTH" => {
98 if parts.len() != 3 {
99 "ERROR Invalid AUTH format".to_string()
100 } else {
101 let username = parts[1];
102 let sent_hash = parts[2];
103 let db = DB_CONN.lock();
104 if let Some(stored_hash) = database::get_hashed_password(&db, username) {
105 if sent_hash == stored_hash {
106 *user = username.to_string();
107 format!("OK {}", username)
108 } else {
109 "ERROR Invalid credentials".to_string()
110 }
111 } else {
112 "ERROR User not found".to_string()
113 }
114 }
115 }
116 "PING" => "PONG".to_string(),
117 "INFO" => {
118 if *user == "guest" {
119 "ERROR Not logged in".to_string()
120 } else {
121 let db = DB_CONN.lock();
122 if let (Some(id), Some(balance)) = (
123 database::get_account_id(&db, user),
124 database::get_balance(&db, user),
125 ) {
126 format!("INFO {} {} {}", user, id, balance)
127 } else {
128 "ERROR Error retrieving user info".to_string()
129 }
130 }
131 } 10 }
132 _ => msg.to_string(),
133 }
134}
135
136fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> String {
137 if user == "guest" {
138 return "ERROR Not logged in".to_string();
139 }
140
141 let bet_amount: i64 = match bet_amount_str.parse() {
142 Ok(amount) => amount,
143 Err(_) => return "ERROR Invalid bet amount".to_string(),
144 }; 11 };
145 if bet_amount <= 0 {
146 return "ERROR Invalid bet amount".to_string();
147 }
148 12
149 let registry = get_game_registry(); 13 let db_path = std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string());
150 let game = match registry.get_game(game_type) { 14 let mut core = match ServerCore::open(&db_path) {
151 Some(game) => game, 15 Ok(core) => core,
152 None => return format!("ERROR Unknown game type: {}", game_type), 16 Err(error) => {
153 }; 17 eprintln!("Failed to open database {db_path}: {error}");
154 18 return;
155 let db = DB_CONN.lock(); 19 }
156
157 // Validate balance inside a read.
158 let current_balance = match database::get_balance(&db, user) {
159 Some(balance) => balance,
160 None => return "ERROR Could not retrieve balance".to_string(),
161 };
162
163 if let Err(e) = game.validate_bet(bet_amount as u64, current_balance as u64) {
164 return match e {
165 GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(),
166 GameError::InvalidBet => "ERROR Invalid bet amount".to_string(),
167 _ => "ERROR Bet validation failed".to_string(),
168 };
169 }
170
171 // Prepare and spin.
172 let game_state = game.prepare_state(user, bet_amount as u64);
173
174 let new_state = match game.process_action(&game_state, "spin", &[]) {
175 Ok(state) => state,
176 Err(e) => return format!("ERROR Failed to spin: {:?}", e),
177 };
178
179 let result = match game.calculate_result(&new_state) {
180 Ok(result) => result,
181 Err(e) => return format!("ERROR Failed to calculate result: {:?}", e),
182 };
183
184 // Create session before touching balance.
185 let session_id = SESSIONS.create_session(user, game_type.to_string(), new_state.clone());
186
187 // All balance mutations in a single transaction.
188 let total_return = result.winnings as i64;
189 let tx_result = db.execute_batch(&format!(
190 "UPDATE users SET balance = balance - {bet_amount} WHERE username = '{user}';
191 UPDATE users SET balance = balance + {total_return} WHERE username = '{user}';"
192 ));
193
194 if let Err(e) = tx_result {
195 let _ = SESSIONS.remove_session(&session_id);
196 eprintln!("Balance transaction failed for {user}: {e}");
197 return "ERROR Failed to update balance".to_string();
198 }
199
200 let new_balance = match database::get_balance(&db, user) {
201 Some(balance) => balance,
202 None => return "ERROR Could not retrieve new balance".to_string(),
203 }; 20 };
204 21
205 let reels = new_state.data.get("reels").cloned().unwrap_or_default(); 22 let mut state = ConnectionState::default();
206 let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); 23 println!("[TLS Client connected] {client_addr}");
207
208 format!(
209 "GAME_RESULT {session_id} {} {} {new_balance} {reels} {win_type}",
210 result.winnings, result.bet_amount,
211 )
212}
213
214pub fn handle_client_tls(mut stream: TlsStream) {
215 let client_addr = stream.peer_addr().unwrap().to_string();
216 let mut user = "guest".to_string();
217 println!("[{} @ {}] TLS Client connected", user, client_addr);
218 24
219 loop { 25 loop {
220 let mut line = String::new(); 26 let mut line = String::new();
221 match stream.read_line(&mut line) { 27 match stream.read_line(&mut line) {
222 Ok(0) => break, 28 Ok(0) => break,
223 Ok(_) => {} 29 Ok(_) => {}
224 Err(e) => { 30 Err(error) => {
225 eprintln!("Read error: {}", e); 31 eprintln!("Read error: {error}");
226 break; 32 break;
227 } 33 }
228 } 34 }
@@ -232,21 +38,17 @@ pub fn handle_client_tls(mut stream: TlsStream) {
232 continue; 38 continue;
233 } 39 }
234 40
235 println!("[{} @ {}] Received: {}", user, client_addr, line); 41 let user = state.user().unwrap_or("guest").to_string();
236 let response = process_message(line, &mut user); 42 println!("[{user} @ {client_addr}] Received: {line}");
237 if let Err(e) = stream.write_line(&response) { 43 let response = core.handle_message(&mut state, line);
238 eprintln!("Write error: {}", e); 44 if let Err(error) = stream.write_line(&response) {
45 eprintln!("Write error: {error}");
239 break; 46 break;
240 } 47 }
241 println!("[{} @ {}] Sent: {}", user, client_addr, response); 48 let user = state.user().unwrap_or("guest");
242 } 49 println!("[{user} @ {client_addr}] Sent: {response}");
243
244 // Clean up any active game sessions for this user on disconnect.
245 if user != "guest" {
246 for session in SESSIONS.get_user_sessions(&user) {
247 let _ = SESSIONS.remove_session(&session.session_id);
248 }
249 } 50 }
250 51
251 println!("[{} @ {}] TLS Client disconnected", user, client_addr); 52 core.disconnect(&state);
53 println!("[TLS Client disconnected] {client_addr}");
252} 54}
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;
diff --git a/tests/server_core.rs b/tests/server_core.rs
new file mode 100644
index 0000000..fe2b3d3
--- /dev/null
+++ b/tests/server_core.rs
@@ -0,0 +1,246 @@
1use std::path::PathBuf;
2
3use rusqlite::Connection;
4use zagan_bet::server::core::{ConnectionState, ServerCore};
5use zagan_bet::server::database;
6use zagan_bet::server::games::registry::GameRegistry;
7use zagan_bet::server::games::slots::SlotsGame;
8
9fn setup(name: &str, reels: &str) -> (ServerCore, String) {
10 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
11 .join(format!("server-core-{name}.db"))
12 .to_string_lossy()
13 .into_owned();
14 let _ = std::fs::remove_file(&path);
15
16 let conn = Connection::open(&path).unwrap();
17 database::init_db(&conn);
18
19 let registry = GameRegistry::new();
20 registry.register_game(SlotsGame::with_reels(reels));
21
22 // Low bcrypt cost keeps the tests fast.
23 (ServerCore::with_registry(conn, registry, 4), path)
24}
25
26fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
27 assert_eq!(
28 core.handle_message(state, &format!("REGISTER {user} credential")),
29 format!("REGISTERED {user}")
30 );
31}
32
33fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
34 assert_eq!(
35 core.handle_message(state, &format!("AUTH {user} credential")),
36 format!("OK {user}")
37 );
38}
39
40#[test]
41fn register_stores_a_bcrypt_hash_and_auth_accepts_it() {
42 let (mut core, path) = setup("register", "CLO");
43 let mut state = ConnectionState::default();
44
45 register(&mut core, &mut state, "alice");
46
47 let conn = Connection::open(&path).unwrap();
48 let stored = database::get_hashed_password(&conn, "alice").unwrap();
49 assert!(stored.starts_with("$2"), "not a bcrypt hash: {stored}");
50 assert_ne!(stored, "credential");
51 assert!(bcrypt::verify("credential", &stored).unwrap());
52
53 assert_eq!(
54 core.handle_message(&mut state, "AUTH alice credential"),
55 "OK alice"
56 );
57 assert_eq!(state.user(), Some("alice"));
58}
59
60#[test]
61fn the_same_credential_gets_a_different_salt() {
62 let (mut core, path) = setup("salt", "CLO");
63 let mut state = ConnectionState::default();
64
65 register(&mut core, &mut state, "alice");
66 register(&mut core, &mut state, "bob");
67
68 let conn = Connection::open(&path).unwrap();
69 let alice = database::get_hashed_password(&conn, "alice").unwrap();
70 let bob = database::get_hashed_password(&conn, "bob").unwrap();
71 assert_ne!(alice, bob);
72}
73
74#[test]
75fn legacy_sha256_rows_are_upgraded_on_login() {
76 let (mut core, path) = setup("legacy", "CLO");
77 let conn = Connection::open(&path).unwrap();
78 database::add_user(&conn, "legacy", "deadbeef").unwrap();
79
80 let mut state = ConnectionState::default();
81 assert_eq!(
82 core.handle_message(&mut state, "AUTH legacy deadbeef"),
83 "OK legacy"
84 );
85
86 let stored = database::get_hashed_password(&conn, "legacy").unwrap();
87 assert!(stored.starts_with("$2"), "legacy hash was not upgraded");
88 assert!(bcrypt::verify("deadbeef", &stored).unwrap());
89
90 let mut other = ConnectionState::default();
91 assert_eq!(
92 core.handle_message(&mut other, "AUTH legacy wrong"),
93 "ERROR Invalid credentials"
94 );
95}
96
97#[test]
98fn duplicate_registration_returns_an_error() {
99 let (mut core, _path) = setup("duplicate", "CLO");
100 let mut state = ConnectionState::default();
101
102 register(&mut core, &mut state, "alice");
103 assert_eq!(
104 core.handle_message(&mut state, "REGISTER alice credential"),
105 "ERROR User already exists"
106 );
107}
108
109#[test]
110fn a_malicious_username_cannot_change_another_balance() {
111 let (mut core, _path) = setup("injection", "CLO");
112 let mut admin = ConnectionState::default();
113 register(&mut core, &mut admin, "victim");
114
115 // No spaces, so it survives the three-part REGISTER/AUTH parser.
116 let evil = "x';UPDATE/**/users/**/SET/**/balance=999999;--";
117 register(&mut core, &mut admin, evil);
118
119 let mut evil_state = ConnectionState::default();
120 login(&mut core, &mut evil_state, evil);
121
122 let response = core.handle_message(&mut evil_state, "PLAY_START slots 10");
123 assert!(
124 response.starts_with("GAME_RESULT"),
125 "spin failed: {response}"
126 );
127
128 assert_eq!(core.balance("victim"), Some(1000));
129 assert_eq!(core.balance(evil), Some(990));
130 assert_eq!(core.play_count(evil), 1);
131}
132
133#[test]
134fn a_failed_audit_insert_rolls_back_the_balance_change() {
135 let (mut core, path) = setup("rollback", "CLO");
136 let mut state = ConnectionState::default();
137 register(&mut core, &mut state, "alice");
138 login(&mut core, &mut state, "alice");
139
140 let conn = Connection::open(&path).unwrap();
141 conn.execute_batch(
142 "CREATE TRIGGER fail_plays BEFORE INSERT ON plays
143 BEGIN SELECT RAISE(ABORT, 'boom'); END;",
144 )
145 .unwrap();
146
147 assert_eq!(
148 core.handle_message(&mut state, "PLAY_START slots 10"),
149 "ERROR Failed to update balance"
150 );
151 assert_eq!(core.balance("alice"), Some(1000));
152 assert_eq!(core.play_count("alice"), 0);
153 assert_eq!(core.session_count(), 0);
154}
155
156#[test]
157fn spins_write_audit_rows_and_leave_no_sessions() {
158 let (mut core, _path) = setup("audit", "CLO");
159 let mut state = ConnectionState::default();
160 register(&mut core, &mut state, "alice");
161 login(&mut core, &mut state, "alice");
162
163 for _ in 0..3 {
164 let response = core.handle_message(&mut state, "PLAY_START slots 10");
165 assert!(
166 response.starts_with("GAME_RESULT"),
167 "spin failed: {response}"
168 );
169 assert_eq!(core.session_count(), 0);
170 }
171
172 assert_eq!(core.balance("alice"), Some(970));
173 assert_eq!(core.play_count("alice"), 3);
174}
175
176#[test]
177fn a_winning_spin_updates_the_balance_and_the_audit_row() {
178 let (mut core, _path) = setup("win", "CCC");
179 let mut state = ConnectionState::default();
180 register(&mut core, &mut state, "alice");
181 login(&mut core, &mut state, "alice");
182
183 let response = core.handle_message(&mut state, "PLAY_START slots 10");
184 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
185 assert!(response.contains(" 20 10 1010 "), "response: {response}");
186
187 assert_eq!(core.balance("alice"), Some(1010));
188 assert_eq!(core.play_count("alice"), 1);
189 assert_eq!(core.session_count(), 0);
190}
191
192#[test]
193fn insufficient_balance_does_not_write_an_audit_row() {
194 let (mut core, path) = setup("insufficient", "CLO");
195 let mut state = ConnectionState::default();
196 register(&mut core, &mut state, "alice");
197 login(&mut core, &mut state, "alice");
198
199 let conn = Connection::open(&path).unwrap();
200 conn.execute("UPDATE users SET balance = 5 WHERE username = 'alice'", [])
201 .unwrap();
202
203 assert_eq!(
204 core.handle_message(&mut state, "PLAY_START slots 10"),
205 "ERROR Insufficient balance"
206 );
207 assert_eq!(core.balance("alice"), Some(5));
208 assert_eq!(core.play_count("alice"), 0);
209 assert_eq!(core.session_count(), 0);
210}
211
212#[test]
213fn a_negative_balance_is_rejected_without_wrapping() {
214 let (mut core, path) = setup("negative", "CLO");
215 let mut state = ConnectionState::default();
216 register(&mut core, &mut state, "alice");
217 login(&mut core, &mut state, "alice");
218
219 let conn = Connection::open(&path).unwrap();
220 conn.execute(
221 "UPDATE users SET balance = -5 WHERE username = 'alice'",
222 [],
223 )
224 .unwrap();
225
226 assert_eq!(
227 core.handle_message(&mut state, "PLAY_START slots 10"),
228 "ERROR Invalid balance"
229 );
230 assert_eq!(core.balance("alice"), Some(-5));
231 assert_eq!(core.play_count("alice"), 0);
232}
233
234#[test]
235fn unknown_games_are_rejected_before_a_session_is_created() {
236 let (mut core, _path) = setup("unknown", "CLO");
237 let mut state = ConnectionState::default();
238 register(&mut core, &mut state, "alice");
239 login(&mut core, &mut state, "alice");
240
241 assert_eq!(
242 core.handle_message(&mut state, "PLAY_START nope 10"),
243 "ERROR Unknown game type: nope"
244 );
245 assert_eq!(core.session_count(), 0);
246}
diff --git a/tests/slots.rs b/tests/slots.rs
index 12348c9..df4af28 100644
--- a/tests/slots.rs
+++ b/tests/slots.rs
@@ -48,6 +48,13 @@ fn no_match_returns_zero() {
48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0); 48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0);
49} 49}
50 50
51#[test]
52fn an_enormous_bet_saturates_instead_of_overflowing() {
53 let game = make_game();
54 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], u64::MAX), u64::MAX);
55 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], u64::MAX), u64::MAX);
56}
57
51/// process_action produces a finished state with correct reels and win_type. 58/// process_action produces a finished state with correct reels and win_type.
52#[test] 59#[test]
53fn process_action_spin_sets_finished() { 60fn process_action_spin_sets_finished() {