diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-24 17:36:19 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-24 17:36:19 +0200 |
| commit | 828eb472701af16b7bd913111273b62d22ea3627 (patch) | |
| tree | 48ab3876cc1e2b7420609c7c1b016c939c9b84d1 | |
| parent | TOFU host trust: SPKI fingerprint pinning, tests, forget-host (diff) | |
| download | zagan-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.md | 56 | ||||
| -rw-r--r-- | src/bin/client/games/slots.rs | 6 | ||||
| -rw-r--r-- | src/bin/server/games/slots.rs | 21 | ||||
| -rw-r--r-- | src/bin/server/handler.rs | 98 | ||||
| -rw-r--r-- | src/lib.rs | 15 | ||||
| -rw-r--r-- | tests/slots.rs | 92 |
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 @@ | |||
| 1 | use crate::database; | 1 | use crate::database; |
| 2 | use crate::games::registry::get_game_registry; | 2 | use crate::games::registry::get_game_registry; |
| 3 | use crate::games::traits::GameError; | 3 | use crate::games::session::SessionManager; |
| 4 | use crate::games::traits::{GameError, GamePhase}; | ||
| 4 | use crate::tls::TlsStream; | 5 | use crate::tls::TlsStream; |
| 5 | use once_cell::sync::Lazy; | 6 | use once_cell::sync::Lazy; |
| 7 | use parking_lot::Mutex; | ||
| 6 | use rusqlite::Connection; | 8 | use rusqlite::Connection; |
| 7 | use std::io::{BufRead, BufReader, Write}; | 9 | use std::io::{BufRead, BufReader, Write}; |
| 8 | use std::net::TcpStream; | 10 | use std::net::TcpStream; |
| 9 | use std::sync::Mutex; | ||
| 10 | 11 | ||
| 11 | static DB_CONN: Lazy<Mutex<Connection>> = Lazy::new(|| { | 12 | static 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)] | 21 | static SESSIONS: Lazy<SessionManager> = Lazy::new(SessionManager::new); |
| 22 | |||
| 18 | pub fn handle_client(stream: TcpStream) { | 23 | pub 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 | } |
| @@ -8,3 +8,18 @@ pub mod client_database; | |||
| 8 | 8 | ||
| 9 | #[path = "bin/server/database.rs"] | 9 | #[path = "bin/server/database.rs"] |
| 10 | pub mod server_database; | 10 | pub 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"] | ||
| 16 | pub mod server_game_traits; | ||
| 17 | |||
| 18 | pub mod games { | ||
| 19 | pub mod traits { | ||
| 20 | pub use crate::server_game_traits::*; | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | #[path = "bin/server/games/slots.rs"] | ||
| 25 | pub 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 | |||
| 3 | use zagan_bet::server_game_traits::{Game, GamePhase}; | ||
| 4 | use zagan_bet::server_slots::SlotsGame; | ||
| 5 | |||
| 6 | fn make_game() -> SlotsGame { | ||
| 7 | SlotsGame::new() | ||
| 8 | } | ||
| 9 | |||
| 10 | /// Three of a kind pays the three-of-a-kind multiplier. | ||
| 11 | #[test] | ||
| 12 | fn 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] | ||
| 22 | fn 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] | ||
| 33 | fn 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] | ||
| 45 | fn 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] | ||
| 53 | fn 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] | ||
| 70 | fn 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] | ||
| 88 | fn 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 | } | ||
