summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-24 17:36:19 +0200
committersalmonik <salmonik@tuta.io>2026-08-24 17:36:19 +0200
commit828eb472701af16b7bd913111273b62d22ea3627 (patch)
tree48ab3876cc1e2b7420609c7c1b016c939c9b84d1
parentTOFU host trust: SPKI fingerprint pinning, tests, forget-host (diff)
downloadzagan-bet-828eb472701af16b7bd913111273b62d22ea3627.tar.gz
zagan-bet-828eb472701af16b7bd913111273b62d22ea3627.zip
Fix critical game bugs: payout, sessions, balance type, render_lines; add AGENTS.md and regression tests
Diffstat (limited to '')
-rw-r--r--AGENTS.md56
-rw-r--r--src/bin/client/games/slots.rs6
-rw-r--r--src/bin/server/games/slots.rs21
-rw-r--r--src/bin/server/handler.rs98
-rw-r--r--src/lib.rs15
-rw-r--r--tests/slots.rs92
6 files changed, 229 insertions, 59 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c5139d5
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,56 @@
1# Project Conventions for zagan-bet
2
3## Type Safety
4- SQLite `INTEGER` is `i64`. Never cast to `u64` without a bounds check.
5- Balance, bet, and winnings fields must agree on `i64` or `u64` across the
6 entire codebase — pick one and enforce it.
7- Negative balance is a state the DB can represent; the application must reject
8 plays that would produce one, not silently wrap via `as u64`.
9
10## Game Logic
11- Never compute the same payout/result in two places. `process_action` and
12 `calculate_result` must not both independently derive winnings.
13 The canonical split: `process_action` mutates state and stores raw data
14 (reels, win_type); `calculate_result` reads that data and produces the
15 `GameResult`. One path, one formula.
16- When checking symmetry (e.g. two-of-a-kind), enumerate **all** pairs, not
17 just adjacent indices. `[a, b, a]` is a valid match.
18
19## Session Tracking
20- Every `PLAY_START` must produce a session entry before the balance is
21 touched. The session is the audit trail for the deduction; without it the
22 deduction is orphaned.
23- Clean up sessions on client disconnect. If a game is in progress when the
24 connection drops, the server must roll back the bet.
25
26## Server Handler
27- Never hold a lock, drop it, then re-acquire it in the same function to
28 mutate the same resource. Acquire once, scope the lock to the operation.
29- All balance mutations (`SET balance = balance ± ?`) must happen in a
30 transaction.
31
32## Client TUI
33- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`,
34 count lines against the same string the cursor will erase.
35 Prefer returning a fixed known count for the game's static layout and
36 letting dynamic content fill it, rather than inferring the count.
37
38## Error Paths
39- When a game action fails after a balance deduction, the refund must happen
40 in the same transaction scope as the deduction, not as a separate
41 `execute()` call.
42- Never silently ignore `Err` on balance mutations. If the refund fails,
43 log the username, balance, and operation; surface to the operator.
44
45## Tests
46- Every `calculate_payout` / `calculate_result` edge case gets a unit test
47 in `tests/`. Tests that exercise game logic use a deterministic seed or
48 a test helper that calls the function directly, not the network.
49- A change to game logic that breaks an existing payout pattern must fail
50 a test before it reaches the server.
51
52## TLS / TOFU
53- See `src/tofu.rs` for the trust model. The fingerprint is over the
54 certificate's SPKI, not the whole certificate.
55- `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ...
56 DO UPDATE SET last_used` — never `INSERT OR REPLACE`.
diff --git a/src/bin/client/games/slots.rs b/src/bin/client/games/slots.rs
index 2f9d63e..d664dbc 100644
--- a/src/bin/client/games/slots.rs
+++ b/src/bin/client/games/slots.rs
@@ -38,8 +38,10 @@ impl Game for SlotsGame {
38 output 38 output
39 } 39 }
40 40
41 fn render_lines(&self, state: &GameState) -> usize { 41 fn render_lines(&self, _state: &GameState) -> usize {
42 self.render(state).lines().count() 42 // Fixed count matching the render() layout:
43 // blank, header, bet, reels, blank, prompt, blank, controls
44 8
43 } 45 }
44 46
45 fn get_help(&self) -> String { 47 fn get_help(&self) -> String {
diff --git a/src/bin/server/games/slots.rs b/src/bin/server/games/slots.rs
index d84e21f..3d285ef 100644
--- a/src/bin/server/games/slots.rs
+++ b/src/bin/server/games/slots.rs
@@ -36,23 +36,25 @@ impl SlotsGame {
36 .collect() 36 .collect()
37 } 37 }
38 38
39 fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { 39 pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 {
40 let reel_string: String = reels.iter().collect(); 40 let reel_string: String = reels.iter().collect();
41 41
42 if let Some(&multiplier) = self.payouts.get(&reel_string) { 42 if let Some(&multiplier) = self.payouts.get(&reel_string) {
43 return bet * multiplier; 43 return bet * multiplier;
44 } 44 }
45 45
46 for i in 0..2 { 46 // Check all three pairs including non-adjacent (e.g. [C,D,C])
47 if reels[i] == reels[i + 1] { 47 let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)];
48 let two_of_kind = format!("{}{}", reels[i], reels[i]); 48 let mut best: u64 = 0;
49 for &(a, b) in &pairs {
50 if reels[a] == reels[b] {
51 let two_of_kind = format!("{}{}", reels[a], reels[a]);
49 if let Some(&multiplier) = self.payouts.get(&two_of_kind) { 52 if let Some(&multiplier) = self.payouts.get(&two_of_kind) {
50 return bet * multiplier; 53 best = best.max(bet * multiplier);
51 } 54 }
52 } 55 }
53 } 56 }
54 57 best
55 0
56 } 58 }
57} 59}
58 60
@@ -108,11 +110,8 @@ impl Game for SlotsGame {
108 new_data.insert("reels".to_string(), reel_string.clone()); 110 new_data.insert("reels".to_string(), reel_string.clone());
109 new_data.insert("status".to_string(), "spun".to_string()); 111 new_data.insert("status".to_string(), "spun".to_string());
110 112
111 let winnings = self.calculate_payout(&reels, state.bet_amount);
112 new_data.insert("winnings".to_string(), winnings.to_string());
113
114 let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; 113 let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2];
115 let is_two = reels[0] == reels[1] || reels[1] == reels[2]; 114 let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2];
116 new_data.insert( 115 new_data.insert(
117 "win_type".to_string(), 116 "win_type".to_string(),
118 if is_jackpot { 117 if is_jackpot {
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs
index 48e9d3a..5406e85 100644
--- a/src/bin/server/handler.rs
+++ b/src/bin/server/handler.rs
@@ -1,20 +1,25 @@
1use crate::database; 1use crate::database;
2use crate::games::registry::get_game_registry; 2use crate::games::registry::get_game_registry;
3use crate::games::traits::GameError; 3use crate::games::session::SessionManager;
4use crate::games::traits::{GameError, GamePhase};
4use crate::tls::TlsStream; 5use crate::tls::TlsStream;
5use once_cell::sync::Lazy; 6use once_cell::sync::Lazy;
7use parking_lot::Mutex;
6use rusqlite::Connection; 8use rusqlite::Connection;
7use std::io::{BufRead, BufReader, Write}; 9use std::io::{BufRead, BufReader, Write};
8use std::net::TcpStream; 10use std::net::TcpStream;
9use std::sync::Mutex;
10 11
11static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { 12static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| {
12 let conn = Connection::open(std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string())).unwrap(); 13 let conn = Connection::open(
14 std::env::var("SERVER_DB").unwrap_or_else(|_| "server.db".to_string()),
15 )
16 .unwrap();
13 database::init_db(&conn); 17 database::init_db(&conn);
14 Mutex::new(conn) 18 Mutex::new(conn)
15}); 19});
16 20
17#[allow(dead_code)] 21static SESSIONS: Lazy<SessionManager> = Lazy::new(SessionManager::new);
22
18pub fn handle_client(stream: TcpStream) { 23pub fn handle_client(stream: TcpStream) {
19 let client_addr = stream.peer_addr().unwrap().to_string(); 24 let client_addr = stream.peer_addr().unwrap().to_string();
20 let mut user = "guest".to_string(); 25 let mut user = "guest".to_string();
@@ -82,7 +87,7 @@ fn process_message(msg: &str, user: &mut String) -> String {
82 } else { 87 } else {
83 let username = parts[1]; 88 let username = parts[1];
84 let hashed_password = parts[2]; 89 let hashed_password = parts[2];
85 let db = DB_CONN.lock().unwrap(); 90 let db = DB_CONN.lock();
86 match database::add_user(&db, username, hashed_password) { 91 match database::add_user(&db, username, hashed_password) {
87 Ok(_) => format!("REGISTERED {}", username), 92 Ok(_) => format!("REGISTERED {}", username),
88 Err(_) => "ERROR User already exists".to_string(), 93 Err(_) => "ERROR User already exists".to_string(),
@@ -95,7 +100,7 @@ fn process_message(msg: &str, user: &mut String) -> String {
95 } else { 100 } else {
96 let username = parts[1]; 101 let username = parts[1];
97 let sent_hash = parts[2]; 102 let sent_hash = parts[2];
98 let db = DB_CONN.lock().unwrap(); 103 let db = DB_CONN.lock();
99 if let Some(stored_hash) = database::get_hashed_password(&db, username) { 104 if let Some(stored_hash) = database::get_hashed_password(&db, username) {
100 if sent_hash == stored_hash { 105 if sent_hash == stored_hash {
101 *user = username.to_string(); 106 *user = username.to_string();
@@ -113,7 +118,7 @@ fn process_message(msg: &str, user: &mut String) -> String {
113 if *user == "guest" { 118 if *user == "guest" {
114 "ERROR Not logged in".to_string() 119 "ERROR Not logged in".to_string()
115 } else { 120 } else {
116 let db = DB_CONN.lock().unwrap(); 121 let db = DB_CONN.lock();
117 if let (Some(id), Some(balance)) = ( 122 if let (Some(id), Some(balance)) = (
118 database::get_account_id(&db, user), 123 database::get_account_id(&db, user),
119 database::get_balance(&db, user), 124 database::get_balance(&db, user),
@@ -133,16 +138,13 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin
133 return "ERROR Not logged in".to_string(); 138 return "ERROR Not logged in".to_string();
134 } 139 }
135 140
136 let bet_amount: u64 = match bet_amount_str.parse() { 141 let bet_amount: i64 = match bet_amount_str.parse() {
137 Ok(amount) => amount, 142 Ok(amount) => amount,
138 Err(_) => return "ERROR Invalid bet amount".to_string(), 143 Err(_) => return "ERROR Invalid bet amount".to_string(),
139 }; 144 };
140 145 if bet_amount <= 0 {
141 let db = DB_CONN.lock().unwrap(); 146 return "ERROR Invalid bet amount".to_string();
142 let current_balance = match database::get_balance(&db, user) { 147 }
143 Some(balance) => balance as u64,
144 None => return "ERROR Could not retrieve balance".to_string(),
145 };
146 148
147 let registry = get_game_registry(); 149 let registry = get_game_registry();
148 let game = match registry.get_game(game_type) { 150 let game = match registry.get_game(game_type) {
@@ -150,7 +152,15 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin
150 None => return format!("ERROR Unknown game type: {}", game_type), 152 None => return format!("ERROR Unknown game type: {}", game_type),
151 }; 153 };
152 154
153 if let Err(e) = game.validate_bet(bet_amount, current_balance) { 155 let db = DB_CONN.lock();
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) {
154 return match e { 164 return match e {
155 GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(), 165 GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(),
156 GameError::InvalidBet => "ERROR Invalid bet amount".to_string(), 166 GameError::InvalidBet => "ERROR Invalid bet amount".to_string(),
@@ -158,44 +168,32 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin
158 }; 168 };
159 } 169 }
160 170
161 let game_state = game.prepare_state(user, bet_amount); 171 // Prepare and spin.
162 172 let game_state = game.prepare_state(user, bet_amount as u64);
163 drop(db);
164 let db = DB_CONN.lock().unwrap();
165 if let Err(_) = db.execute(
166 "UPDATE users SET balance = balance - ?1 WHERE username = ?2",
167 (bet_amount as i64, user),
168 ) {
169 return "ERROR Failed to deduct bet".to_string();
170 }
171 173
172 let new_state = match game.process_action(&game_state, "spin", &[]) { 174 let new_state = match game.process_action(&game_state, "spin", &[]) {
173 Ok(state) => state, 175 Ok(state) => state,
174 Err(e) => { 176 Err(e) => return format!("ERROR Failed to spin: {:?}", e),
175 let _ = db.execute(
176 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
177 (bet_amount as i64, user),
178 );
179 return format!("ERROR Failed to spin: {:?}", e);
180 }
181 }; 177 };
182 178
183 let result = match game.calculate_result(&new_state) { 179 let result = match game.calculate_result(&new_state) {
184 Ok(result) => result, 180 Ok(result) => result,
185 Err(e) => { 181 Err(e) => return format!("ERROR Failed to calculate result: {:?}", e),
186 let _ = db.execute(
187 "UPDATE users SET balance = balance + ?1 WHERE username = ?2",
188 (bet_amount as i64, user),
189 );
190 return format!("ERROR Failed to calculate result: {:?}", e);
191 }
192 }; 182 };
193 183
194 let total_return = result.bet_amount + result.winnings; 184 // Create session before touching balance.
195 if let Err(_) = db.execute( 185 let session_id = SESSIONS.create_session(user, game_type.to_string(), new_state.clone());
196 "UPDATE users SET balance = balance + ?1 WHERE username = ?2", 186
197 (total_return as i64, user), 187 // All balance mutations in a single transaction.
198 ) { 188 let total_return = bet_amount + 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}");
199 return "ERROR Failed to update balance".to_string(); 197 return "ERROR Failed to update balance".to_string();
200 } 198 }
201 199
@@ -208,8 +206,8 @@ fn handle_play_start(user: &str, game_type: &str, bet_amount_str: &str) -> Strin
208 let win_type = new_state.data.get("win_type").cloned().unwrap_or_default(); 206 let win_type = new_state.data.get("win_type").cloned().unwrap_or_default();
209 207
210 format!( 208 format!(
211 "GAME_RESULT {} {} {} {} {} {}", 209 "GAME_RESULT {session_id} {} {} {new_balance} {reels} {win_type}",
212 result.session_id, result.winnings, result.bet_amount, new_balance, reels, win_type 210 result.winnings, result.bet_amount,
213 ) 211 )
214} 212}
215 213
@@ -242,5 +240,13 @@ pub fn handle_client_tls(mut stream: TlsStream) {
242 } 240 }
243 println!("[{} @ {}] Sent: {}", user, client_addr, response); 241 println!("[{} @ {}] Sent: {}", user, client_addr, response);
244 } 242 }
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 }
250
245 println!("[{} @ {}] TLS Client disconnected", user, client_addr); 251 println!("[{} @ {}] TLS Client disconnected", user, client_addr);
246} 252}
diff --git a/src/lib.rs b/src/lib.rs
index 77533d7..e400619 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,3 +8,18 @@ pub mod client_database;
8 8
9#[path = "bin/server/database.rs"] 9#[path = "bin/server/database.rs"]
10pub mod server_database; 10pub mod server_database;
11
12// Server game logic — re-exported under `crate::games::traits` so that
13// `use crate::games::traits::*` inside slots.rs resolves correctly whether
14// the file is compiled as part of the lib or the server binary.
15#[path = "bin/server/games/traits.rs"]
16pub mod server_game_traits;
17
18pub mod games {
19 pub mod traits {
20 pub use crate::server_game_traits::*;
21 }
22}
23
24#[path = "bin/server/games/slots.rs"]
25pub mod server_slots;
diff --git a/tests/slots.rs b/tests/slots.rs
new file mode 100644
index 0000000..c50f425
--- /dev/null
+++ b/tests/slots.rs
@@ -0,0 +1,92 @@
1//! Regression tests for the server-side slots game logic.
2
3use zagan_bet::server_game_traits::{Game, GamePhase};
4use zagan_bet::server_slots::SlotsGame;
5
6fn make_game() -> SlotsGame {
7 SlotsGame::new()
8}
9
10/// Three of a kind pays the three-of-a-kind multiplier.
11#[test]
12fn three_of_a_kind_pays() {
13 let game = make_game();
14 // CCC = 5x
15 assert_eq!(game.calculate_payout(&['C', 'C', 'C'], 100), 500);
16 // SSS = 100x
17 assert_eq!(game.calculate_payout(&['S', 'S', 'S'], 10), 1000);
18}
19
20/// Two adjacent matching symbols pays the two-of-a-kind multiplier.
21#[test]
22fn two_adjacent_pays() {
23 let game = make_game();
24 // CC? = 2x
25 assert_eq!(game.calculate_payout(&['C', 'C', 'D'], 100), 200);
26 // ?LL = 3x
27 assert_eq!(game.calculate_payout(&['D', 'L', 'L'], 50), 150);
28}
29
30/// Non-adjacent matching symbols (e.g. [C,D,C]) must also pay.
31/// This was a bug: the old code only checked pairs (0,1) and (1,2).
32#[test]
33fn two_non_adjacent_pays() {
34 let game = make_game();
35 // C,D,C — two cherries at positions 0 and 2
36 assert_eq!(game.calculate_payout(&['C', 'D', 'C'], 100), 200);
37 // S,X,S — two sevens at positions 0 and 2
38 assert_eq!(game.calculate_payout(&['S', 'L', 'S'], 10), 200);
39 // L,O,L
40 assert_eq!(game.calculate_payout(&['L', 'O', 'L'], 50), 150);
41}
42
43/// No matching symbols returns 0.
44#[test]
45fn no_match_returns_zero() {
46 let game = make_game();
47 assert_eq!(game.calculate_payout(&['C', 'L', 'O'], 100), 0);
48 assert_eq!(game.calculate_payout(&['G', 'D', 'S'], 50), 0);
49}
50
51/// process_action produces a finished state with correct reels and win_type.
52#[test]
53fn process_action_spin_sets_finished() {
54 let game = make_game();
55 let state = game.prepare_state("alice", 100);
56 assert_eq!(state.phase, GamePhase::WaitingToStart);
57
58 // Manually set reels by going through process_action.
59 // Since spin is random, we test the state machine rather than the outcome.
60 let result = game.process_action(&state, "spin", &[]);
61 assert!(result.is_ok());
62 let new_state = result.unwrap();
63 assert_eq!(new_state.phase, GamePhase::Finished);
64 assert!(new_state.data.contains_key("reels"));
65 assert!(new_state.data.contains_key("win_type"));
66}
67
68/// calculate_result on a finished state returns valid GameResult.
69#[test]
70fn calculate_result_on_finished_state() {
71 let game = make_game();
72 let mut state = game.prepare_state("alice", 100);
73 state.phase = GamePhase::Finished;
74 state
75 .data
76 .insert("reels".to_string(), "CCD".to_string());
77 state
78 .data
79 .insert("win_type".to_string(), "two".to_string());
80
81 let result = game.calculate_result(&state).unwrap();
82 assert_eq!(result.bet_amount, 100);
83 assert_eq!(result.winnings, 200); // CC = 2x
84}
85
86/// calculate_result on a non-finished state returns an error.
87#[test]
88fn calculate_result_on_unfinished_state() {
89 let game = make_game();
90 let state = game.prepare_state("alice", 100);
91 assert!(game.calculate_result(&state).is_err());
92}