From ea5e7d336719571e51ef14478ab603e71126e3d8 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:28:05 +0100 Subject: v0.2.2: split client/server logic into the library --- Cargo.toml | 2 +- src/bin/client/connection.rs | 2 +- src/bin/client/database.rs | 118 -------------------------- src/bin/client/games/manager.rs | 134 ----------------------------- src/bin/client/games/mod.rs | 3 - src/bin/client/games/slots.rs | 61 ------------- src/bin/client/games/traits.rs | 32 ------- src/bin/client/main.rs | 2 - src/bin/client/mod.rs | 6 -- src/bin/client/repl.rs | 6 +- src/bin/server/database.rs | 74 ---------------- src/bin/server/games/mod.rs | 4 - src/bin/server/games/registry.rs | 55 ------------ src/bin/server/games/session.rs | 77 ----------------- src/bin/server/games/slots.rs | 179 --------------------------------------- src/bin/server/games/traits.rs | 67 --------------- src/bin/server/handler.rs | 8 +- src/bin/server/main.rs | 2 - src/bin/server/mod.rs | 3 - src/client/database.rs | 118 ++++++++++++++++++++++++++ src/client/games/manager.rs | 131 ++++++++++++++++++++++++++++ src/client/games/mod.rs | 3 + src/client/games/slots.rs | 61 +++++++++++++ src/client/games/traits.rs | 32 +++++++ src/client/mod.rs | 2 + src/lib.rs | 33 ++------ src/server/database.rs | 74 ++++++++++++++++ src/server/games/mod.rs | 4 + src/server/games/registry.rs | 55 ++++++++++++ src/server/games/session.rs | 77 +++++++++++++++++ src/server/games/slots.rs | 179 +++++++++++++++++++++++++++++++++++++++ src/server/games/traits.rs | 67 +++++++++++++++ src/server/mod.rs | 2 + tests/slots.rs | 4 +- tests/storage.rs | 4 +- tests/tofu.rs | 2 +- 36 files changed, 828 insertions(+), 855 deletions(-) delete mode 100644 src/bin/client/database.rs delete mode 100644 src/bin/client/games/manager.rs delete mode 100644 src/bin/client/games/mod.rs delete mode 100644 src/bin/client/games/slots.rs delete mode 100644 src/bin/client/games/traits.rs delete mode 100644 src/bin/client/mod.rs delete mode 100644 src/bin/server/database.rs delete mode 100644 src/bin/server/games/mod.rs delete mode 100644 src/bin/server/games/registry.rs delete mode 100644 src/bin/server/games/session.rs delete mode 100644 src/bin/server/games/slots.rs delete mode 100644 src/bin/server/games/traits.rs delete mode 100644 src/bin/server/mod.rs create mode 100644 src/client/database.rs create mode 100644 src/client/games/manager.rs create mode 100644 src/client/games/mod.rs create mode 100644 src/client/games/slots.rs create mode 100644 src/client/games/traits.rs create mode 100644 src/client/mod.rs create mode 100644 src/server/database.rs create mode 100644 src/server/games/mod.rs create mode 100644 src/server/games/registry.rs create mode 100644 src/server/games/session.rs create mode 100644 src/server/games/slots.rs create mode 100644 src/server/games/traits.rs create mode 100644 src/server/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 9eca3a1..a5b6a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.2.1" +version = "0.2.2" edition = "2024" [dependencies] diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 6aff321..e54b646 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -1,4 +1,4 @@ -use crate::database; +use zagan_bet::client::database; use crate::tls::TlsConnector; use rusqlite::Connection; use std::io::{Read, Write}; diff --git a/src/bin/client/database.rs b/src/bin/client/database.rs deleted file mode 100644 index e1fa42d..0000000 --- a/src/bin/client/database.rs +++ /dev/null @@ -1,118 +0,0 @@ -use rusqlite::Connection; - -pub fn init_db(conn: &Connection) { - conn.execute( - "CREATE TABLE IF NOT EXISTS hosts ( - host TEXT PRIMARY KEY, - last_used DATETIME DEFAULT CURRENT_TIMESTAMP, - fingerprint TEXT - )", - [], - ) - .unwrap(); - // Migration for databases created before TOFU trust. - let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); - conn.execute( - "CREATE TABLE IF NOT EXISTS credentials ( - host TEXT PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL - )", - [], - ) - .unwrap(); -} - -pub fn get_last_host(conn: &Connection) -> Option { - let mut stmt = conn - .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") - .unwrap(); - let mut rows = stmt.query([]).unwrap(); - if let Ok(Some(row)) = rows.next() { - row.get(0).ok() - } else { - None - } -} - -pub fn save_host(conn: &Connection, host: &str) { - conn.execute( - "INSERT INTO hosts (host) VALUES (?1) - ON CONFLICT(host) DO UPDATE SET last_used = CURRENT_TIMESTAMP", - [host], - ) - .unwrap(); -} - -pub fn save_credentials( - conn: &Connection, - host: &str, - username: &str, - password: &str, -) -> Result<(), Box> { - conn.execute( - "INSERT OR REPLACE INTO credentials (host, username, password) VALUES (?1, ?2, ?3)", - (host, username, password), - )?; - Ok(()) -} - -pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String)> { - let mut stmt = conn - .prepare("SELECT username, password FROM credentials WHERE host = ?1") - .unwrap(); - let mut rows = stmt.query([host]).unwrap(); - if let Ok(Some(row)) = rows.next() { - let username: String = row.get(0).unwrap(); - let password: String = row.get(1).unwrap(); - Some((username, password)) - } else { - None - } -} - -/// SHA-256 SPKI fingerprint of a host's certificate, if trusted. -pub fn get_fingerprint(conn: &Connection, host: &str) -> Option { - let mut stmt = conn - .prepare("SELECT fingerprint FROM hosts WHERE host = ?1") - .ok()?; - let mut rows = stmt.query([host]).ok()?; - match rows.next() { - Ok(Some(row)) => row.get(0).ok(), - _ => None, - } -} - -pub fn save_fingerprint( - conn: &Connection, - host: &str, - fingerprint: &str, -) -> Result<(), rusqlite::Error> { - // Hosts may not exist yet (fingerprint can be saved before first - // successful connect completes save_host). - conn.execute("INSERT OR IGNORE INTO hosts (host) VALUES (?1)", [host])?; - conn.execute( - "UPDATE hosts SET fingerprint = ?2 WHERE host = ?1", - (host, fingerprint), - )?; - Ok(()) -} - -/// Remove all trust and credentials for a host ("forget this host"). -pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { - conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; - conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; - Ok(()) -} - -/// List all remembered hosts with their fingerprints. -pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option)> { - let mut stmt = conn - .prepare("SELECT host, fingerprint FROM hosts ORDER BY last_used DESC") - .unwrap(); - let rows = stmt.query_map([], |row| { - Ok((row.get(0)?, row.get(1)?)) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() -} diff --git a/src/bin/client/games/manager.rs b/src/bin/client/games/manager.rs deleted file mode 100644 index 0b5b4cb..0000000 --- a/src/bin/client/games/manager.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::games::slots::SlotsGame; -use crate::games::traits::{Game, GameError, GamePhase, GameState}; -use std::collections::HashMap; -use std::io::Write; - -pub struct GameManager { - current_session: Option, - pending_game_type: Option, - games: HashMap>, -} - -impl GameManager { - pub fn new() -> Self { - let mut games: HashMap> = HashMap::new(); - games.insert("slots".to_string(), Box::new(SlotsGame::new())); - - Self { - current_session: None, - pending_game_type: None, - games, - } - } - - pub fn get_available_games(&self) -> Vec<(&str, &str)> { - self.games - .iter() - .map(|(k, v)| (k.as_str(), v.get_name())) - .collect() - } - - pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { - if self.current_session.is_some() { - return Err(GameError::InvalidAction); - } - - if !self.games.contains_key(game_type) { - return Err(GameError::InvalidAction); - } - - self.pending_game_type = Some(game_type.to_string()); - - self.current_session = Some(GameState { - game_type: game_type.to_string(), - bet_amount: 10, - phase: GamePhase::WaitingToStart, - data: HashMap::new(), - }); - - Ok(()) - } - - pub fn start_game( - &mut self, - stream: &mut crate::connection::TlsStream, - ) -> Result<(), GameError> { - let game_type = self - .pending_game_type - .as_ref() - .ok_or(GameError::InvalidAction)?; - - let state = self - .current_session - .as_mut() - .ok_or(GameError::InvalidAction)?; - - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { - return Err(GameError::InvalidAction); - } - - let bet_amount = state.bet_amount; - - let writer = stream; - writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) - .map_err(|_| GameError::NetworkError)?; - - Ok(()) - } - - pub fn get_current_game(&self) -> Option<&dyn Game> { - self.current_session - .as_ref() - .and_then(|session| self.games.get(&session.game_type).map(|game| game.as_ref())) - } - - pub fn get_current_state(&self) -> Option<&GameState> { - self.current_session.as_ref() - } - - pub fn get_current_render_lines(&self) -> Option { - let session = self.current_session.as_ref()?; - let game = self.games.get(&session.game_type)?; - Some(game.render_lines(session)) - } - - pub fn is_in_game(&self) -> bool { - self.current_session.is_some() - } - - pub fn is_game_started(&self) -> bool { - self.current_session - .as_ref() - .map(|s| s.phase != GamePhase::WaitingToStart) - .unwrap_or(false) - } - - pub fn reset_phase_to_waiting(&mut self) { - if let Some(ref mut session) = self.current_session { - session.phase = GamePhase::WaitingToStart; - session.data.clear(); - } - } - - pub fn end_game(&mut self) { - self.current_session = None; - self.pending_game_type = None; - } - - pub fn adjust_bet(&mut self, delta: i64) -> Result { - if let Some(ref session) = self.current_session { - if session.phase != GamePhase::WaitingToStart { - return Err(GameError::InvalidAction); - } - } - - if let Some(ref mut session) = self.current_session { - let current_bet = session.bet_amount as i64; - let new_bet = (current_bet + delta).max(10) as u64; - session.bet_amount = new_bet; - Ok(new_bet) - } else { - Err(GameError::InvalidAction) - } - } -} diff --git a/src/bin/client/games/mod.rs b/src/bin/client/games/mod.rs deleted file mode 100644 index aa74574..0000000 --- a/src/bin/client/games/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod manager; -pub mod slots; -pub mod traits; diff --git a/src/bin/client/games/slots.rs b/src/bin/client/games/slots.rs deleted file mode 100644 index ff55274..0000000 --- a/src/bin/client/games/slots.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::games::traits::{Game, GameState}; - -pub struct SlotsGame; - -impl SlotsGame { - pub fn new() -> Self { - Self - } - - fn render_reels(&self, reels: &str) -> String { - if reels.is_empty() { - "[ ? ] [ ? ] [ ? ]".to_string() - } else { - let chars: Vec = reels.chars().collect(); - format!( - "[ {} ] [ {} ] [ {} ]\r", - chars.get(0).unwrap_or(&'?'), - chars.get(1).unwrap_or(&'?'), - chars.get(2).unwrap_or(&'?') - ) - } - } -} - -impl Game for SlotsGame { - fn get_name(&self) -> &'static str { - "Slots" - } - - fn render(&self, state: &GameState) -> String { - let reels = state.data.get("reels").cloned().unwrap_or_default(); - - format!( - "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", - state.bet_amount, - self.render_reels(&reels) - ) - } - - fn render_lines(&self, _state: &GameState) -> usize { - 6 - } - - fn get_help(&self) -> String { - "SLOTS GAME\n\ - =========\n\ - Match symbols to win!\n\ - \n\ - Payouts:\n\ - Three of a kind: 2x-28x bet\n\ - Two of a kind: 1x-3x bet\n\ - \n\ - Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ - \n\ - Controls:\n\ - SPACE - Spin the reels\n\ - +/- - Adjust bet amount\n\ - Q - Exit the game" - .to_string() - } -} diff --git a/src/bin/client/games/traits.rs b/src/bin/client/games/traits.rs deleted file mode 100644 index aed0c24..0000000 --- a/src/bin/client/games/traits.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::collections::HashMap; - -#[derive(Debug, Clone)] -pub enum GameError { - InvalidAction, - NetworkError, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum GamePhase { - WaitingToStart, - Playing, - Finished, -} - -#[derive(Debug, Clone)] -pub struct GameState { - pub game_type: String, - pub bet_amount: u64, - pub phase: GamePhase, - pub data: HashMap, -} - -pub trait Game: Send + Sync { - fn get_name(&self) -> &'static str; - - fn render(&self, state: &GameState) -> String; - - fn render_lines(&self, state: &GameState) -> usize; - - fn get_help(&self) -> String; -} diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs index 4e03381..1b701d8 100644 --- a/src/bin/client/main.rs +++ b/src/bin/client/main.rs @@ -1,8 +1,6 @@ #[macro_use] mod macros; mod connection; -mod database; -mod games; mod repl; mod tls; diff --git a/src/bin/client/mod.rs b/src/bin/client/mod.rs deleted file mode 100644 index 5921c41..0000000 --- a/src/bin/client/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod database; -mod connection; -mod games; -pub mod platform; -pub mod tls; -pub mod repl; diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index a6f0331..24605e2 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -4,9 +4,9 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use crate::connection::TlsStream; -use crate::database; -use crate::games::manager::GameManager; -use crate::games::traits::GamePhase; +use zagan_bet::client::database; +use zagan_bet::client::games::manager::GameManager; +use zagan_bet::client::games::traits::GamePhase; use crossterm::{ cursor::{Hide, MoveToColumn, Show}, diff --git a/src/bin/server/database.rs b/src/bin/server/database.rs deleted file mode 100644 index c73aec5..0000000 --- a/src/bin/server/database.rs +++ /dev/null @@ -1,74 +0,0 @@ -use rusqlite::Connection; - -pub fn init_db(conn: &Connection) { - conn.execute( - "CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - hashed_password TEXT NOT NULL, - account_id INTEGER UNIQUE, - balance INTEGER DEFAULT 1000 - )", - [], - ) - .unwrap(); - // Add column if not exists (for migration) - let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []); - let _ = conn.execute( - "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", - [], - ); -} - -pub fn add_user( - conn: &Connection, - username: &str, - hashed_password: &str, -) -> Result<(), Box> { - loop { - let account_id: u32 = rand::random::(); - match conn.execute( - "INSERT INTO users (username, hashed_password, account_id, balance) VALUES (?1, ?2, ?3, ?4)", - [username, hashed_password, &account_id.to_string(), "1000"], - ) { - Ok(_) => break, - Err(_) => continue, // Retry if account_id conflict - } - } - Ok(()) -} - -pub fn get_hashed_password(conn: &Connection, username: &str) -> Option { - let mut stmt = conn - .prepare("SELECT hashed_password FROM users WHERE username = ?1") - .unwrap(); - let mut rows = stmt.query([username]).unwrap(); - if let Ok(Some(row)) = rows.next() { - row.get(0).ok() - } else { - None - } -} - -pub fn get_account_id(conn: &Connection, username: &str) -> Option { - let mut stmt = conn - .prepare("SELECT account_id FROM users WHERE username = ?1") - .unwrap(); - let mut rows = stmt.query([username]).unwrap(); - if let Ok(Some(row)) = rows.next() { - Some(row.get::<_, u32>(0).ok()?) - } else { - None - } -} - -pub fn get_balance(conn: &Connection, username: &str) -> Option { - let mut stmt = conn - .prepare("SELECT balance FROM users WHERE username = ?1") - .unwrap(); - let mut rows = stmt.query([username]).unwrap(); - if let Ok(Some(row)) = rows.next() { - Some(row.get::<_, i64>(0).ok()?) - } else { - None - } -} diff --git a/src/bin/server/games/mod.rs b/src/bin/server/games/mod.rs deleted file mode 100644 index 4c62760..0000000 --- a/src/bin/server/games/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod registry; -pub mod session; -pub mod slots; -pub mod traits; diff --git a/src/bin/server/games/registry.rs b/src/bin/server/games/registry.rs deleted file mode 100644 index e77a138..0000000 --- a/src/bin/server/games/registry.rs +++ /dev/null @@ -1,55 +0,0 @@ -#![allow(dead_code)] - -use crate::games::traits::Game; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -pub type GameRef = Arc; - -pub struct GameRegistry { - games: Arc>>, -} - -impl GameRegistry { - pub fn new() -> Self { - Self { - games: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn register_game(&self, game: G) { - let game_type = game.game_type().to_string(); - let mut games = self.games.lock().unwrap(); - games.insert(game_type, Arc::new(game)); - } - - pub fn get_game(&self, game_type: &str) -> Option { - let games = self.games.lock().unwrap(); - games.get(game_type).cloned() - } - - pub fn list_games(&self) -> Vec { - let games = self.games.lock().unwrap(); - games.keys().cloned().collect() - } - - pub fn game_exists(&self, game_type: &str) -> bool { - let games = self.games.lock().unwrap(); - games.contains_key(game_type) - } -} - -// Global registry instance -use crate::games::slots::SlotsGame; -use once_cell::sync::Lazy; - -static GAME_REGISTRY: Lazy = Lazy::new(|| { - let registry = GameRegistry::new(); - // Register games here - registry.register_game(SlotsGame::new()); - registry -}); - -pub fn get_game_registry() -> &'static GameRegistry { - &GAME_REGISTRY -} diff --git a/src/bin/server/games/session.rs b/src/bin/server/games/session.rs deleted file mode 100644 index 595a745..0000000 --- a/src/bin/server/games/session.rs +++ /dev/null @@ -1,77 +0,0 @@ -#![allow(dead_code)] - -use crate::games::traits::{GameError, GameState}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; - -#[derive(Debug, Clone)] -pub struct GameSession { - pub session_id: String, - pub username: String, - pub game_type: String, - pub bet_amount: u64, - pub state: GameState, -} - -pub struct SessionManager { - sessions: Arc>>, -} - -impl SessionManager { - pub fn new() -> Self { - Self { - sessions: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn create_session(&self, username: &str, game_type: String, state: GameState) -> String { - let session_id = Uuid::new_v4().to_string(); - let session = GameSession { - session_id: session_id.clone(), - username: username.to_string(), - game_type, - bet_amount: state.bet_amount, - state, - }; - - let mut sessions = self.sessions.lock().unwrap(); - sessions.insert(session_id.clone(), session); - session_id - } - - pub fn get_session(&self, session_id: &str) -> Result { - let sessions = self.sessions.lock().unwrap(); - sessions - .get(session_id) - .cloned() - .ok_or(GameError::SessionNotFound) - } - - pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> { - let mut sessions = self.sessions.lock().unwrap(); - if let Some(session) = sessions.get_mut(session_id) { - session.state = new_state; - Ok(()) - } else { - Err(GameError::SessionNotFound) - } - } - - pub fn remove_session(&self, session_id: &str) -> Result<(), GameError> { - let mut sessions = self.sessions.lock().unwrap(); - sessions - .remove(session_id) - .ok_or(GameError::SessionNotFound) - .map(|_| ()) - } - - pub fn get_user_sessions(&self, username: &str) -> Vec { - let sessions = self.sessions.lock().unwrap(); - sessions - .values() - .filter(|s| s.username == username) - .cloned() - .collect() - } -} diff --git a/src/bin/server/games/slots.rs b/src/bin/server/games/slots.rs deleted file mode 100644 index 04ac175..0000000 --- a/src/bin/server/games/slots.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::games::traits::{Game, GameError, GamePhase, GameResult, GameState}; -use rand::Rng; -use std::collections::HashMap; - -pub struct SlotsGame { - symbols: Vec, - payouts: HashMap, -} - -impl SlotsGame { - pub fn new() -> Self { - let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S']; - let mut payouts = HashMap::new(); - - // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge) - payouts.insert("CCC".to_string(), 2); - payouts.insert("LLL".to_string(), 3); - payouts.insert("OOO".to_string(), 5); - payouts.insert("GGG".to_string(), 8); - payouts.insert("DDD".to_string(), 14); - payouts.insert("SSS".to_string(), 28); - - payouts.insert("CC".to_string(), 1); - payouts.insert("LL".to_string(), 1); - payouts.insert("OO".to_string(), 1); - payouts.insert("GG".to_string(), 2); - payouts.insert("DD".to_string(), 2); - payouts.insert("SS".to_string(), 3); - - Self { symbols, payouts } - } - - fn spin_reels(&self) -> Vec { - let mut rng = rand::thread_rng(); - (0..3) - .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) - .collect() - } - - pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { - let reel_string: String = reels.iter().collect(); - - if let Some(&multiplier) = self.payouts.get(&reel_string) { - return bet * multiplier; - } - - // Check all three pairs including non-adjacent (e.g. [C,D,C]) - let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; - let mut best: u64 = 0; - for &(a, b) in &pairs { - if reels[a] == reels[b] { - let two_of_kind = format!("{}{}", reels[a], reels[a]); - if let Some(&multiplier) = self.payouts.get(&two_of_kind) { - best = best.max(bet * multiplier); - } - } - } - best - } -} - -impl Game for SlotsGame { - fn game_type(&self) -> &'static str { - "slots" - } - - fn get_name(&self) -> &'static str { - "Slots" - } - - fn get_description(&self) -> &'static str { - "Spin the reels and match symbols to win!" - } - - fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { - let mut data = HashMap::new(); - data.insert("username".to_string(), username.to_string()); - data.insert("status".to_string(), "ready".to_string()); - - GameState { - session_id: String::new(), - game_type: "slots".to_string(), - bet_amount, - phase: GamePhase::WaitingToStart, - data, - } - } - - fn process_action( - &self, - state: &GameState, - action: &str, - _params: &[&str], - ) -> Result { - if state.phase == GamePhase::Finished { - return Err(GameError::GameOver); - } - - match action { - "spin" => { - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { - return Err(GameError::InvalidAction( - "Cannot spin in current phase".to_string(), - )); - } - - let reels = self.spin_reels(); - let reel_string: String = reels.iter().collect(); - - let mut new_data = state.data.clone(); - new_data.insert("reels".to_string(), reel_string.clone()); - new_data.insert("status".to_string(), "spun".to_string()); - - let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; - let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; - new_data.insert( - "win_type".to_string(), - if is_jackpot { - "jackpot".to_string() - } else if is_two { - "two".to_string() - } else { - "none".to_string() - }, - ); - - Ok(GameState { - session_id: state.session_id.clone(), - game_type: state.game_type.clone(), - bet_amount: state.bet_amount, - phase: GamePhase::Finished, - data: new_data, - }) - } - _ => Err(GameError::InvalidAction(format!( - "Unknown action: {}", - action - ))), - } - } - - fn calculate_result(&self, state: &GameState) -> Result { - if state.phase != GamePhase::Finished { - return Err(GameError::InternalError("Game not finished".to_string())); - } - - let reel_string = state - .data - .get("reels") - .ok_or(GameError::InternalError("No reels found".to_string()))?; - - let reels: Vec = reel_string.chars().collect(); - let winnings = self.calculate_payout(&reels, state.bet_amount); - - let mut result_data = HashMap::new(); - result_data.insert("reels".to_string(), reel_string.clone()); - result_data.insert( - "payout_multiplier".to_string(), - if winnings > 0 && state.bet_amount > 0 { - (winnings / state.bet_amount).to_string() - } else { - "0".to_string() - }, - ); - result_data.insert( - "win_type".to_string(), - state.data.get("win_type").cloned().unwrap_or_default(), - ); - - Ok(GameResult { - session_id: state.session_id.clone(), - game_type: state.game_type.clone(), - bet_amount: state.bet_amount, - winnings, - final_balance: 0, - result_data, - }) - } -} diff --git a/src/bin/server/games/traits.rs b/src/bin/server/games/traits.rs deleted file mode 100644 index d56b334..0000000 --- a/src/bin/server/games/traits.rs +++ /dev/null @@ -1,67 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum GameError { - InvalidAction(String), - InsufficientBalance, - InvalidBet, - GameOver, - SessionNotFound, - InternalError(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum GamePhase { - WaitingToStart, - Playing, - Finished, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GameState { - pub session_id: String, - pub game_type: String, - pub bet_amount: u64, - pub phase: GamePhase, - pub data: HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GameResult { - pub session_id: String, - pub game_type: String, - pub bet_amount: u64, - pub winnings: u64, - pub final_balance: u64, - pub result_data: HashMap, -} - -pub trait Game: Send + Sync { - fn game_type(&self) -> &'static str; - - fn get_name(&self) -> &'static str; - - fn get_description(&self) -> &'static str; - - fn validate_bet(&self, bet_amount: u64, user_balance: u64) -> Result<(), GameError> { - if bet_amount == 0 { - return Err(GameError::InvalidBet); - } - if bet_amount > user_balance { - return Err(GameError::InsufficientBalance); - } - Ok(()) - } - - fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState; - - fn process_action( - &self, - state: &GameState, - action: &str, - params: &[&str], - ) -> Result; - - fn calculate_result(&self, state: &GameState) -> Result; -} diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index 0a4934f..06ed5cb 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs @@ -1,7 +1,7 @@ -use crate::database; -use crate::games::registry::get_game_registry; -use crate::games::session::SessionManager; -use crate::games::traits::{GameError, GamePhase}; +use zagan_bet::server::database; +use zagan_bet::server::games::registry::get_game_registry; +use zagan_bet::server::games::session::SessionManager; +use zagan_bet::server::games::traits::GameError; use crate::tls::TlsStream; use once_cell::sync::Lazy; use parking_lot::Mutex; diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs index 4328559..8dba415 100644 --- a/src/bin/server/main.rs +++ b/src/bin/server/main.rs @@ -1,6 +1,4 @@ mod args; -mod database; -mod games; mod handler; mod tls; diff --git a/src/bin/server/mod.rs b/src/bin/server/mod.rs deleted file mode 100644 index b2d622e..0000000 --- a/src/bin/server/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod args; -pub mod handler; -pub mod database; diff --git a/src/client/database.rs b/src/client/database.rs new file mode 100644 index 0000000..e1fa42d --- /dev/null +++ b/src/client/database.rs @@ -0,0 +1,118 @@ +use rusqlite::Connection; + +pub fn init_db(conn: &Connection) { + conn.execute( + "CREATE TABLE IF NOT EXISTS hosts ( + host TEXT PRIMARY KEY, + last_used DATETIME DEFAULT CURRENT_TIMESTAMP, + fingerprint TEXT + )", + [], + ) + .unwrap(); + // Migration for databases created before TOFU trust. + let _ = conn.execute("ALTER TABLE hosts ADD COLUMN fingerprint TEXT", []); + conn.execute( + "CREATE TABLE IF NOT EXISTS credentials ( + host TEXT PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL + )", + [], + ) + .unwrap(); +} + +pub fn get_last_host(conn: &Connection) -> Option { + let mut stmt = conn + .prepare("SELECT host FROM hosts ORDER BY last_used DESC LIMIT 1") + .unwrap(); + let mut rows = stmt.query([]).unwrap(); + if let Ok(Some(row)) = rows.next() { + row.get(0).ok() + } else { + None + } +} + +pub fn save_host(conn: &Connection, host: &str) { + conn.execute( + "INSERT INTO hosts (host) VALUES (?1) + ON CONFLICT(host) DO UPDATE SET last_used = CURRENT_TIMESTAMP", + [host], + ) + .unwrap(); +} + +pub fn save_credentials( + conn: &Connection, + host: &str, + username: &str, + password: &str, +) -> Result<(), Box> { + conn.execute( + "INSERT OR REPLACE INTO credentials (host, username, password) VALUES (?1, ?2, ?3)", + (host, username, password), + )?; + Ok(()) +} + +pub fn get_credentials(conn: &Connection, host: &str) -> Option<(String, String)> { + let mut stmt = conn + .prepare("SELECT username, password FROM credentials WHERE host = ?1") + .unwrap(); + let mut rows = stmt.query([host]).unwrap(); + if let Ok(Some(row)) = rows.next() { + let username: String = row.get(0).unwrap(); + let password: String = row.get(1).unwrap(); + Some((username, password)) + } else { + None + } +} + +/// SHA-256 SPKI fingerprint of a host's certificate, if trusted. +pub fn get_fingerprint(conn: &Connection, host: &str) -> Option { + let mut stmt = conn + .prepare("SELECT fingerprint FROM hosts WHERE host = ?1") + .ok()?; + let mut rows = stmt.query([host]).ok()?; + match rows.next() { + Ok(Some(row)) => row.get(0).ok(), + _ => None, + } +} + +pub fn save_fingerprint( + conn: &Connection, + host: &str, + fingerprint: &str, +) -> Result<(), rusqlite::Error> { + // Hosts may not exist yet (fingerprint can be saved before first + // successful connect completes save_host). + conn.execute("INSERT OR IGNORE INTO hosts (host) VALUES (?1)", [host])?; + conn.execute( + "UPDATE hosts SET fingerprint = ?2 WHERE host = ?1", + (host, fingerprint), + )?; + Ok(()) +} + +/// Remove all trust and credentials for a host ("forget this host"). +pub fn forget_host(conn: &Connection, host: &str) -> Result<(), rusqlite::Error> { + conn.execute("DELETE FROM hosts WHERE host = ?1", [host])?; + conn.execute("DELETE FROM credentials WHERE host = ?1", [host])?; + Ok(()) +} + +/// List all remembered hosts with their fingerprints. +pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option)> { + let mut stmt = conn + .prepare("SELECT host, fingerprint FROM hosts ORDER BY last_used DESC") + .unwrap(); + let rows = stmt.query_map([], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() +} diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs new file mode 100644 index 0000000..be96b1a --- /dev/null +++ b/src/client/games/manager.rs @@ -0,0 +1,131 @@ +use super::slots::SlotsGame; +use super::traits::{Game, GameError, GamePhase, GameState}; +use std::collections::HashMap; +use std::io::Write; + +pub struct GameManager { + current_session: Option, + pending_game_type: Option, + games: HashMap>, +} + +impl GameManager { + pub fn new() -> Self { + let mut games: HashMap> = HashMap::new(); + games.insert("slots".to_string(), Box::new(SlotsGame::new())); + + Self { + current_session: None, + pending_game_type: None, + games, + } + } + + pub fn get_available_games(&self) -> Vec<(&str, &str)> { + self.games + .iter() + .map(|(k, v)| (k.as_str(), v.get_name())) + .collect() + } + + pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { + if self.current_session.is_some() { + return Err(GameError::InvalidAction); + } + + if !self.games.contains_key(game_type) { + return Err(GameError::InvalidAction); + } + + self.pending_game_type = Some(game_type.to_string()); + + self.current_session = Some(GameState { + game_type: game_type.to_string(), + bet_amount: 10, + phase: GamePhase::WaitingToStart, + data: HashMap::new(), + }); + + Ok(()) + } + + pub fn start_game(&mut self, stream: &mut W) -> Result<(), GameError> { + let game_type = self + .pending_game_type + .as_ref() + .ok_or(GameError::InvalidAction)?; + + let state = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + + if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); + } + + let bet_amount = state.bet_amount; + + let writer = stream; + writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) + .map_err(|_| GameError::NetworkError)?; + + Ok(()) + } + + pub fn get_current_game(&self) -> Option<&dyn Game> { + self.current_session + .as_ref() + .and_then(|session| self.games.get(&session.game_type).map(|game| game.as_ref())) + } + + pub fn get_current_state(&self) -> Option<&GameState> { + self.current_session.as_ref() + } + + pub fn get_current_render_lines(&self) -> Option { + let session = self.current_session.as_ref()?; + let game = self.games.get(&session.game_type)?; + Some(game.render_lines(session)) + } + + pub fn is_in_game(&self) -> bool { + self.current_session.is_some() + } + + pub fn is_game_started(&self) -> bool { + self.current_session + .as_ref() + .map(|s| s.phase != GamePhase::WaitingToStart) + .unwrap_or(false) + } + + pub fn reset_phase_to_waiting(&mut self) { + if let Some(ref mut session) = self.current_session { + session.phase = GamePhase::WaitingToStart; + session.data.clear(); + } + } + + pub fn end_game(&mut self) { + self.current_session = None; + self.pending_game_type = None; + } + + pub fn adjust_bet(&mut self, delta: i64) -> Result { + if let Some(ref session) = self.current_session { + if session.phase != GamePhase::WaitingToStart { + return Err(GameError::InvalidAction); + } + } + + if let Some(ref mut session) = self.current_session { + let current_bet = session.bet_amount as i64; + let new_bet = (current_bet + delta).max(10) as u64; + session.bet_amount = new_bet; + Ok(new_bet) + } else { + Err(GameError::InvalidAction) + } + } +} diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs new file mode 100644 index 0000000..aa74574 --- /dev/null +++ b/src/client/games/mod.rs @@ -0,0 +1,3 @@ +pub mod manager; +pub mod slots; +pub mod traits; diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs new file mode 100644 index 0000000..6e2ebbe --- /dev/null +++ b/src/client/games/slots.rs @@ -0,0 +1,61 @@ +use super::traits::{Game, GameState}; + +pub struct SlotsGame; + +impl SlotsGame { + pub fn new() -> Self { + Self + } + + fn render_reels(&self, reels: &str) -> String { + if reels.is_empty() { + "[ ? ] [ ? ] [ ? ]".to_string() + } else { + let chars: Vec = reels.chars().collect(); + format!( + "[ {} ] [ {} ] [ {} ]\r", + chars.get(0).unwrap_or(&'?'), + chars.get(1).unwrap_or(&'?'), + chars.get(2).unwrap_or(&'?') + ) + } + } +} + +impl Game for SlotsGame { + fn get_name(&self) -> &'static str { + "Slots" + } + + fn render(&self, state: &GameState) -> String { + let reels = state.data.get("reels").cloned().unwrap_or_default(); + + format!( + "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", + state.bet_amount, + self.render_reels(&reels) + ) + } + + fn render_lines(&self, _state: &GameState) -> usize { + 6 + } + + fn get_help(&self) -> String { + "SLOTS GAME\n\ + =========\n\ + Match symbols to win!\n\ + \n\ + Payouts:\n\ + Three of a kind: 2x-28x bet\n\ + Two of a kind: 1x-3x bet\n\ + \n\ + Symbols: C=Cherry, L=Lemon, O=Orange, G=Grapes, D=Diamond, S=Seven\n\ + \n\ + Controls:\n\ + SPACE - Spin the reels\n\ + +/- - Adjust bet amount\n\ + Q - Exit the game" + .to_string() + } +} diff --git a/src/client/games/traits.rs b/src/client/games/traits.rs new file mode 100644 index 0000000..aed0c24 --- /dev/null +++ b/src/client/games/traits.rs @@ -0,0 +1,32 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub enum GameError { + InvalidAction, + NetworkError, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GamePhase { + WaitingToStart, + Playing, + Finished, +} + +#[derive(Debug, Clone)] +pub struct GameState { + pub game_type: String, + pub bet_amount: u64, + pub phase: GamePhase, + pub data: HashMap, +} + +pub trait Game: Send + Sync { + fn get_name(&self) -> &'static str; + + fn render(&self, state: &GameState) -> String; + + fn render_lines(&self, state: &GameState) -> usize; + + fn get_help(&self) -> String; +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..5b38158 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,2 @@ +pub mod database; +pub mod games; diff --git a/src/lib.rs b/src/lib.rs index e400619..a4fb801 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,10 @@ -//! Shared library for zagan-bet: exposes client/server storage layers and the -//! TOFU trust module to integration tests in `tests/`. - +//! Shared library for zagan-bet. +//! +//! The client and server binaries are thin adapters around these modules: +//! terminal/TLS/SQLite wiring stays in `src/bin/`, while game rules, protocol +//! handling, storage, and view logic live here so they can be tested from +//! `tests/` without a network or a terminal. + +pub mod client; +pub mod server; pub mod tofu; - -#[path = "bin/client/database.rs"] -pub mod client_database; - -#[path = "bin/server/database.rs"] -pub mod server_database; - -// Server game logic — re-exported under `crate::games::traits` so that -// `use crate::games::traits::*` inside slots.rs resolves correctly whether -// the file is compiled as part of the lib or the server binary. -#[path = "bin/server/games/traits.rs"] -pub mod server_game_traits; - -pub mod games { - pub mod traits { - pub use crate::server_game_traits::*; - } -} - -#[path = "bin/server/games/slots.rs"] -pub mod server_slots; diff --git a/src/server/database.rs b/src/server/database.rs new file mode 100644 index 0000000..c73aec5 --- /dev/null +++ b/src/server/database.rs @@ -0,0 +1,74 @@ +use rusqlite::Connection; + +pub fn init_db(conn: &Connection) { + conn.execute( + "CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + hashed_password TEXT NOT NULL, + account_id INTEGER UNIQUE, + balance INTEGER DEFAULT 1000 + )", + [], + ) + .unwrap(); + // Add column if not exists (for migration) + let _ = conn.execute("ALTER TABLE users ADD COLUMN account_id INTEGER UNIQUE", []); + let _ = conn.execute( + "ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 1000", + [], + ); +} + +pub fn add_user( + conn: &Connection, + username: &str, + hashed_password: &str, +) -> Result<(), Box> { + loop { + let account_id: u32 = rand::random::(); + match conn.execute( + "INSERT INTO users (username, hashed_password, account_id, balance) VALUES (?1, ?2, ?3, ?4)", + [username, hashed_password, &account_id.to_string(), "1000"], + ) { + Ok(_) => break, + Err(_) => continue, // Retry if account_id conflict + } + } + Ok(()) +} + +pub fn get_hashed_password(conn: &Connection, username: &str) -> Option { + let mut stmt = conn + .prepare("SELECT hashed_password FROM users WHERE username = ?1") + .unwrap(); + let mut rows = stmt.query([username]).unwrap(); + if let Ok(Some(row)) = rows.next() { + row.get(0).ok() + } else { + None + } +} + +pub fn get_account_id(conn: &Connection, username: &str) -> Option { + let mut stmt = conn + .prepare("SELECT account_id FROM users WHERE username = ?1") + .unwrap(); + let mut rows = stmt.query([username]).unwrap(); + if let Ok(Some(row)) = rows.next() { + Some(row.get::<_, u32>(0).ok()?) + } else { + None + } +} + +pub fn get_balance(conn: &Connection, username: &str) -> Option { + let mut stmt = conn + .prepare("SELECT balance FROM users WHERE username = ?1") + .unwrap(); + let mut rows = stmt.query([username]).unwrap(); + if let Ok(Some(row)) = rows.next() { + Some(row.get::<_, i64>(0).ok()?) + } else { + None + } +} diff --git a/src/server/games/mod.rs b/src/server/games/mod.rs new file mode 100644 index 0000000..4c62760 --- /dev/null +++ b/src/server/games/mod.rs @@ -0,0 +1,4 @@ +pub mod registry; +pub mod session; +pub mod slots; +pub mod traits; diff --git a/src/server/games/registry.rs b/src/server/games/registry.rs new file mode 100644 index 0000000..3b8c391 --- /dev/null +++ b/src/server/games/registry.rs @@ -0,0 +1,55 @@ +#![allow(dead_code)] + +use super::traits::Game; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +pub type GameRef = Arc; + +pub struct GameRegistry { + games: Arc>>, +} + +impl GameRegistry { + pub fn new() -> Self { + Self { + games: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn register_game(&self, game: G) { + let game_type = game.game_type().to_string(); + let mut games = self.games.lock().unwrap(); + games.insert(game_type, Arc::new(game)); + } + + pub fn get_game(&self, game_type: &str) -> Option { + let games = self.games.lock().unwrap(); + games.get(game_type).cloned() + } + + pub fn list_games(&self) -> Vec { + let games = self.games.lock().unwrap(); + games.keys().cloned().collect() + } + + pub fn game_exists(&self, game_type: &str) -> bool { + let games = self.games.lock().unwrap(); + games.contains_key(game_type) + } +} + +// Global registry instance +use super::slots::SlotsGame; +use once_cell::sync::Lazy; + +static GAME_REGISTRY: Lazy = Lazy::new(|| { + let registry = GameRegistry::new(); + // Register games here + registry.register_game(SlotsGame::new()); + registry +}); + +pub fn get_game_registry() -> &'static GameRegistry { + &GAME_REGISTRY +} diff --git a/src/server/games/session.rs b/src/server/games/session.rs new file mode 100644 index 0000000..97ceaf3 --- /dev/null +++ b/src/server/games/session.rs @@ -0,0 +1,77 @@ +#![allow(dead_code)] + +use super::traits::{GameError, GameState}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct GameSession { + pub session_id: String, + pub username: String, + pub game_type: String, + pub bet_amount: u64, + pub state: GameState, +} + +pub struct SessionManager { + sessions: Arc>>, +} + +impl SessionManager { + pub fn new() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn create_session(&self, username: &str, game_type: String, state: GameState) -> String { + let session_id = Uuid::new_v4().to_string(); + let session = GameSession { + session_id: session_id.clone(), + username: username.to_string(), + game_type, + bet_amount: state.bet_amount, + state, + }; + + let mut sessions = self.sessions.lock().unwrap(); + sessions.insert(session_id.clone(), session); + session_id + } + + pub fn get_session(&self, session_id: &str) -> Result { + let sessions = self.sessions.lock().unwrap(); + sessions + .get(session_id) + .cloned() + .ok_or(GameError::SessionNotFound) + } + + pub fn update_session(&self, session_id: &str, new_state: GameState) -> Result<(), GameError> { + let mut sessions = self.sessions.lock().unwrap(); + if let Some(session) = sessions.get_mut(session_id) { + session.state = new_state; + Ok(()) + } else { + Err(GameError::SessionNotFound) + } + } + + pub fn remove_session(&self, session_id: &str) -> Result<(), GameError> { + let mut sessions = self.sessions.lock().unwrap(); + sessions + .remove(session_id) + .ok_or(GameError::SessionNotFound) + .map(|_| ()) + } + + pub fn get_user_sessions(&self, username: &str) -> Vec { + let sessions = self.sessions.lock().unwrap(); + sessions + .values() + .filter(|s| s.username == username) + .cloned() + .collect() + } +} diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs new file mode 100644 index 0000000..0a9797d --- /dev/null +++ b/src/server/games/slots.rs @@ -0,0 +1,179 @@ +use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; +use rand::Rng; +use std::collections::HashMap; + +pub struct SlotsGame { + symbols: Vec, + payouts: HashMap, +} + +impl SlotsGame { + pub fn new() -> Self { + let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S']; + let mut payouts = HashMap::new(); + + // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge) + payouts.insert("CCC".to_string(), 2); + payouts.insert("LLL".to_string(), 3); + payouts.insert("OOO".to_string(), 5); + payouts.insert("GGG".to_string(), 8); + payouts.insert("DDD".to_string(), 14); + payouts.insert("SSS".to_string(), 28); + + payouts.insert("CC".to_string(), 1); + payouts.insert("LL".to_string(), 1); + payouts.insert("OO".to_string(), 1); + payouts.insert("GG".to_string(), 2); + payouts.insert("DD".to_string(), 2); + payouts.insert("SS".to_string(), 3); + + Self { symbols, payouts } + } + + fn spin_reels(&self) -> Vec { + let mut rng = rand::thread_rng(); + (0..3) + .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())]) + .collect() + } + + pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 { + let reel_string: String = reels.iter().collect(); + + if let Some(&multiplier) = self.payouts.get(&reel_string) { + return bet * multiplier; + } + + // Check all three pairs including non-adjacent (e.g. [C,D,C]) + let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)]; + let mut best: u64 = 0; + for &(a, b) in &pairs { + if reels[a] == reels[b] { + let two_of_kind = format!("{}{}", reels[a], reels[a]); + if let Some(&multiplier) = self.payouts.get(&two_of_kind) { + best = best.max(bet * multiplier); + } + } + } + best + } +} + +impl Game for SlotsGame { + fn game_type(&self) -> &'static str { + "slots" + } + + fn get_name(&self) -> &'static str { + "Slots" + } + + fn get_description(&self) -> &'static str { + "Spin the reels and match symbols to win!" + } + + fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState { + let mut data = HashMap::new(); + data.insert("username".to_string(), username.to_string()); + data.insert("status".to_string(), "ready".to_string()); + + GameState { + session_id: String::new(), + game_type: "slots".to_string(), + bet_amount, + phase: GamePhase::WaitingToStart, + data, + } + } + + fn process_action( + &self, + state: &GameState, + action: &str, + _params: &[&str], + ) -> Result { + if state.phase == GamePhase::Finished { + return Err(GameError::GameOver); + } + + match action { + "spin" => { + if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing { + return Err(GameError::InvalidAction( + "Cannot spin in current phase".to_string(), + )); + } + + let reels = self.spin_reels(); + let reel_string: String = reels.iter().collect(); + + let mut new_data = state.data.clone(); + new_data.insert("reels".to_string(), reel_string.clone()); + new_data.insert("status".to_string(), "spun".to_string()); + + let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2]; + let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2]; + new_data.insert( + "win_type".to_string(), + if is_jackpot { + "jackpot".to_string() + } else if is_two { + "two".to_string() + } else { + "none".to_string() + }, + ); + + Ok(GameState { + session_id: state.session_id.clone(), + game_type: state.game_type.clone(), + bet_amount: state.bet_amount, + phase: GamePhase::Finished, + data: new_data, + }) + } + _ => Err(GameError::InvalidAction(format!( + "Unknown action: {}", + action + ))), + } + } + + fn calculate_result(&self, state: &GameState) -> Result { + if state.phase != GamePhase::Finished { + return Err(GameError::InternalError("Game not finished".to_string())); + } + + let reel_string = state + .data + .get("reels") + .ok_or(GameError::InternalError("No reels found".to_string()))?; + + let reels: Vec = reel_string.chars().collect(); + let winnings = self.calculate_payout(&reels, state.bet_amount); + + let mut result_data = HashMap::new(); + result_data.insert("reels".to_string(), reel_string.clone()); + result_data.insert( + "payout_multiplier".to_string(), + if winnings > 0 && state.bet_amount > 0 { + (winnings / state.bet_amount).to_string() + } else { + "0".to_string() + }, + ); + result_data.insert( + "win_type".to_string(), + state.data.get("win_type").cloned().unwrap_or_default(), + ); + + Ok(GameResult { + session_id: state.session_id.clone(), + game_type: state.game_type.clone(), + bet_amount: state.bet_amount, + winnings, + final_balance: 0, + result_data, + }) + } +} diff --git a/src/server/games/traits.rs b/src/server/games/traits.rs new file mode 100644 index 0000000..d56b334 --- /dev/null +++ b/src/server/games/traits.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GameError { + InvalidAction(String), + InsufficientBalance, + InvalidBet, + GameOver, + SessionNotFound, + InternalError(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum GamePhase { + WaitingToStart, + Playing, + Finished, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameState { + pub session_id: String, + pub game_type: String, + pub bet_amount: u64, + pub phase: GamePhase, + pub data: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameResult { + pub session_id: String, + pub game_type: String, + pub bet_amount: u64, + pub winnings: u64, + pub final_balance: u64, + pub result_data: HashMap, +} + +pub trait Game: Send + Sync { + fn game_type(&self) -> &'static str; + + fn get_name(&self) -> &'static str; + + fn get_description(&self) -> &'static str; + + fn validate_bet(&self, bet_amount: u64, user_balance: u64) -> Result<(), GameError> { + if bet_amount == 0 { + return Err(GameError::InvalidBet); + } + if bet_amount > user_balance { + return Err(GameError::InsufficientBalance); + } + Ok(()) + } + + fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState; + + fn process_action( + &self, + state: &GameState, + action: &str, + params: &[&str], + ) -> Result; + + fn calculate_result(&self, state: &GameState) -> Result; +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..5b38158 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,2 @@ +pub mod database; +pub mod games; diff --git a/tests/slots.rs b/tests/slots.rs index 6babd98..12348c9 100644 --- a/tests/slots.rs +++ b/tests/slots.rs @@ -1,7 +1,7 @@ //! Regression tests for the server-side slots game logic. -use zagan_bet::server_game_traits::{Game, GamePhase}; -use zagan_bet::server_slots::SlotsGame; +use zagan_bet::server::games::slots::SlotsGame; +use zagan_bet::server::games::traits::{Game, GamePhase}; fn make_game() -> SlotsGame { SlotsGame::new() diff --git a/tests/storage.rs b/tests/storage.rs index 275d47d..b6e1369 100644 --- a/tests/storage.rs +++ b/tests/storage.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; -use zagan_bet::client_database; -use zagan_bet::server_database; +use zagan_bet::client::database as client_database; +use zagan_bet::server::database as server_database; fn tmp_db(name: &str) -> (String, rusqlite::Connection) { let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) diff --git a/tests/tofu.rs b/tests/tofu.rs index 260e1b6..359c192 100644 --- a/tests/tofu.rs +++ b/tests/tofu.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::process::Command; -use zagan_bet::client_database; +use zagan_bet::client::database as client_database; use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; /// Generate a self-signed certificate under target/ and return its DER bytes. -- cgit v1.2.3