From 86d5f832c8452aad2313489b82eb8fd514215b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:28:20 +0100 Subject: v0.2.3: add ClientCore and fix the game board rendering --- src/client/games/manager.rs | 140 +++++++++++++++++++++++++++---------------- src/client/games/mod.rs | 1 + src/client/games/response.rs | 60 +++++++++++++++++++ src/client/games/slots.rs | 58 +++++++++++++----- 4 files changed, 192 insertions(+), 67 deletions(-) create mode 100644 src/client/games/response.rs (limited to 'src/client/games') diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index be96b1a..71cd28c 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs @@ -1,11 +1,10 @@ +use super::response::SpinResult; 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>, } @@ -16,16 +15,18 @@ impl GameManager { Self { current_session: None, - pending_game_type: None, games, } } pub fn get_available_games(&self) -> Vec<(&str, &str)> { - self.games + let mut games: Vec<(&str, &str)> = self + .games .iter() - .map(|(k, v)| (k.as_str(), v.get_name())) - .collect() + .map(|(key, game)| (key.as_str(), game.get_name())) + .collect(); + games.sort_unstable_by_key(|(key, _)| *key); + games } pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { @@ -37,8 +38,6 @@ impl GameManager { 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, @@ -49,28 +48,75 @@ impl GameManager { 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)?; + /// The wire request for a spin, if the current phase allows one. + pub fn spin_request(&self) -> Option { + let session = self.current_session.as_ref()?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return None; + } + Some(format!( + "PLAY_START {} {}", + session.game_type, session.bet_amount + )) + } - let state = self + /// Move the board into its spinning state and drop the previous result + /// so stale reels/winnings cannot leak into the new spin. + pub fn begin_spin(&mut self) -> Result<(), GameError> { + let session = self .current_session .as_mut() .ok_or(GameError::InvalidAction)?; - - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { return Err(GameError::InvalidAction); } - let bet_amount = state.bet_amount; + session.phase = GamePhase::Playing; + session.data.remove("error"); + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + Ok(()) + } - let writer = stream; - writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) - .map_err(|_| GameError::NetworkError)?; + /// Store the settled spin so `render()` can draw it in place. + pub fn apply_result(&mut self, result: SpinResult) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::Finished; + session.bet_amount = result.bet_amount; + session.data.remove("error"); + session.data.insert("reels".to_string(), result.reels); + session + .data + .insert("win_type".to_string(), result.win_type); + session + .data + .insert("winnings".to_string(), result.winnings.to_string()); + session + .data + .insert("balance".to_string(), result.balance.to_string()); + } - Ok(()) + /// Show a failed spin without leaving the player stuck in `Playing`. + pub fn set_error(&mut self, message: impl Into) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::WaitingToStart; + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + session.data.insert("error".to_string(), message.into()); + } + + pub fn set_balance(&mut self, balance: i64) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("balance".to_string(), balance.to_string()); + } } pub fn get_current_game(&self) -> Option<&dyn Game> { @@ -83,49 +129,37 @@ impl GameManager { 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 end_game(&mut self) { + self.current_session = None; } - 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 set_bet(&mut self, bet: u64) -> Result { + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); } - } - - pub fn end_game(&mut self) { - self.current_session = None; - self.pending_game_type = None; + session.bet_amount = bet.max(10); + Ok(session.bet_amount) } 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); - } + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + 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) - } + let current_bet = session.bet_amount as i64; + session.bet_amount = (current_bet + delta).max(10) as u64; + Ok(session.bet_amount) } } diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index aa74574..63cc579 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs @@ -1,3 +1,4 @@ pub mod manager; +pub mod response; pub mod slots; pub mod traits; diff --git a/src/client/games/response.rs b/src/client/games/response.rs new file mode 100644 index 0000000..6948805 --- /dev/null +++ b/src/client/games/response.rs @@ -0,0 +1,60 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpinResult { + pub session_id: String, + pub winnings: u64, + pub bet_amount: u64, + pub balance: i64, + pub reels: String, + pub win_type: String, +} + +/// Parse one server reply to `PLAY_START`. +/// +/// `Ok` carries the settled spin; `Err` carries a message that is safe to +/// show to the player (server errors are reported without the `ERROR ` prefix). +pub fn parse_spin_response(response: &str) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let parts: Vec<&str> = response.split_whitespace().collect(); + if parts.first() != Some(&"GAME_RESULT") { + return Err(format!("Unexpected server response: {response}")); + } + if parts.len() < 7 { + return Err("Malformed GAME_RESULT from server".to_string()); + } + + let winnings = parts[2] + .parse::() + .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?; + let bet_amount = parts[3] + .parse::() + .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?; + let balance = parts[4] + .parse::() + .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; + + if parts[5].chars().count() != 3 { + return Err(format!("Invalid reels from server: {}", parts[5])); + } + + Ok(SpinResult { + session_id: parts[1].to_string(), + winnings, + bet_amount, + balance, + reels: parts[5].to_string(), + win_type: parts[6].to_string(), + }) +} diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 6e2ebbe..5eed34d 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,4 +1,8 @@ -use super::traits::{Game, GameState}; +use super::traits::{Game, GamePhase, GameState}; + +/// Height of the game board. It must never depend on the spin result, or an +/// in-place redraw would move to the wrong line. +pub const SLOT_BOARD_LINES: usize = 7; pub struct SlotsGame; @@ -8,17 +12,36 @@ impl SlotsGame { } 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(&'?') - ) + let mut chars = reels.chars(); + format!( + "[ {} ] [ {} ] [ {} ]", + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?') + ) + } + + /// The fifth board line: current status, result, or error. + fn render_status(&self, state: &GameState) -> String { + if let Some(error) = state.data.get("error") { + return format!("ERROR: {error}"); + } + if state.phase == GamePhase::Playing { + return "Spinning...".to_string(); + } + if state.phase == GamePhase::Finished { + let winnings = state + .data + .get("winnings") + .map(String::as_str) + .unwrap_or("0"); + return match state.data.get("win_type").map(String::as_str) { + Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), + Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), + _ => "No win.".to_string(), + }; } + "Press [SPACE] to spin!".to_string() } } @@ -29,16 +52,23 @@ impl Game for SlotsGame { fn render(&self, state: &GameState) -> String { let reels = state.data.get("reels").cloned().unwrap_or_default(); + let balance = state + .data + .get("balance") + .map(String::as_str) + .unwrap_or("?"); format!( - "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", + "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", state.bet_amount, - self.render_reels(&reels) + balance, + self.render_reels(&reels), + self.render_status(state) ) } fn render_lines(&self, _state: &GameState) -> usize { - 6 + SLOT_BOARD_LINES } fn get_help(&self) -> String { -- cgit v1.2.3