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 --- 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 ++++++++++++++++ 5 files changed, 382 insertions(+) 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 (limited to 'src/server/games') 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; +} -- cgit v1.2.3