From 37dedc342c087506e502c78a952b45b1c91602fb Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 18:03:24 +0100 Subject: v4.0.2: port slots, blackjack and crash to structured views --- src/client/core.rs | 232 ++++++++++++++++++++++++------------------ src/client/games/blackjack.rs | 59 +++++------ src/client/games/crash.rs | 67 ++++++------ src/client/games/slots.rs | 125 ++++++++++------------- src/client/tui.rs | 17 ++-- src/client/view.rs | 182 +++++++++++++++++++++++++++++++-- 6 files changed, 436 insertions(+), 246 deletions(-) (limited to 'src') diff --git a/src/client/core.rs b/src/client/core.rs index 7e43fb7..3e3126a 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -4,7 +4,7 @@ use crate::client::games::response::{ parse_roulette_response, parse_spin_response, }; use crate::client::games::roulette::Direction; -use crate::client::view::{Command, RouletteView, View}; +use crate::client::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View}; use crate::client::ui::{ command_names, completion_lines, display_width, help_lines, history_lines, input_cursor_column, parse_bet_arg, record_history, @@ -38,7 +38,7 @@ pub enum InteractiveCommand { /// High-level actions produced by the client core. The terminal adapter is /// responsible for turning these into crossterm calls and network writes. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum Effect { /// Append lines to the scrolling transcript. Print(Vec), @@ -213,7 +213,9 @@ impl ClientCore { self.balance = Some(result.balance); let winning = result.number; self.game.apply_roulette_result(result); - let view = self.view(); + let Some(view) = self.view() else { + return Vec::new(); + }; vec![Effect::AnimateRoulette { winning, view }] } Err(message) => { @@ -340,22 +342,10 @@ impl ClientCore { match key { Key::CtrlC => vec![Effect::Quit], - Key::Esc | Key::Char('q') => { - self.game.end_game(); - vec![ - Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), - ] - } - Key::Char(' ') => self.spin(), - Key::Char('+') | Key::Char('=') => { - let _ = self.game.adjust_bet(10); - self.board_effects() - } - Key::Char('-') => { - let _ = self.game.adjust_bet(-10); - self.board_effects() - } + Key::Esc | Key::Char('q') => self.command(Command::Leave), + Key::Char(' ') => self.command(Command::Spin), + Key::Char('+') | Key::Char('=') => self.command(Command::AdjustBet(10)), + Key::Char('-') => self.command(Command::AdjustBet(-10)), _ => Vec::new(), } } @@ -393,7 +383,46 @@ impl ClientCore { /// Apply a semantic command. Both frontends drive the core through this. pub fn command(&mut self, command: Command) -> Vec { + let game = self.current_game_type().map(str::to_string); + match command { + Command::SetBet(bet) => { + if game.as_deref() == Some("roulette") { + return self.command(Command::SetChip(bet)); + } + if self.game.set_bet(bet).is_ok() { + self.board_effects() + } else { + Vec::new() + } + } + Command::AdjustBet(delta) => { + if game.as_deref() == Some("roulette") { + return self.command(Command::AdjustChip(delta)); + } + if self.game.adjust_bet(delta).is_ok() { + self.board_effects() + } else { + Vec::new() + } + } + Command::Leave => self.leave_current_game(), + Command::Spin => match game.as_deref() { + Some("slots") => self.spin(), + Some("roulette") => self.spin_roulette(), + _ => Vec::new(), + }, + Command::Deal => self.start_blackjack(), + Command::Hit => self.blackjack_action("hit"), + Command::Stand => self.blackjack_action("stand"), + Command::Double => self.blackjack_action("double"), + Command::Start => self.start_crash(), + Command::CashOut => self.cash_out_crash(), + Command::Forfeit => match game.as_deref() { + Some("blackjack") => self.forfeit_blackjack(), + Some("crash") => self.forfeit_crash(), + _ => Vec::new(), + }, Command::MoveCursor(direction) => { let cursor = self.game.roulette_cursor().moved(direction); self.game.set_roulette_cursor(&cursor); @@ -418,16 +447,67 @@ impl ClientCore { self.game.set_roulette_chip(chip); self.board_effects() } - Command::Spin => self.spin_roulette(), - Command::Leave => { - self.crash_started = None; - self.game.end_game(); - vec![ - Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), - ] + } + } + + fn current_game_type(&self) -> Option<&str> { + self.game + .get_current_state() + .map(|state| state.game_type.as_str()) + } + + fn leave_current_game(&mut self) -> Vec { + match self.current_game_type().map(str::to_string).as_deref() { + Some("blackjack") => { + if self + .game + .blackjack_state() + .is_some_and(|hand| hand.phase == "player") + { + return self.forfeit_blackjack(); + } } + Some("crash") => { + if self.game.crash_status() == "running" { + return self.forfeit_crash(); + } + } + _ => {} } + self.exit_game() + } + + fn exit_game(&mut self) -> Vec { + self.crash_started = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + + fn forfeit_blackjack(&mut self) -> Vec { + let Some(hand) = self + .game + .blackjack_state() + .filter(|hand| hand.phase == "player") + else { + return self.exit_game(); + }; + self.game.set_blackjack_status("forfeiting"); + self.pending = Some(Pending::BlackjackForfeit); + vec![Effect::SendLine(format!( + "BLACKJACK_ACTION {} forfeit", + hand.session_id + ))] + } + + fn forfeit_crash(&mut self) -> Vec { + let Some(session) = self.game.crash_session_id() else { + return self.exit_game(); + }; + self.pending = Some(Pending::CrashForfeit); + vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] } fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec { @@ -482,46 +562,23 @@ impl ClientCore { .cloned() .unwrap_or_else(|| "ready".to_string()); let hand = self.game.blackjack_state(); + let player = hand.as_ref().is_some_and(|hand| hand.phase == "player"); + let can_double = hand.as_ref().is_some_and(|hand| { + hand.phase == "player" && hand.actions.iter().any(|action| action == "double") + }); match key { Key::CtrlC => vec![Effect::Quit], - Key::Char('q') => { - if let Some(hand) = hand.filter(|hand| hand.phase == "player") { - self.game.set_blackjack_status("forfeiting"); - self.pending = Some(Pending::BlackjackForfeit); - vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} forfeit", - hand.session_id - ))] - } else { - self.game.end_game(); - vec![ - Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), - ] - } - } - Key::Char(' ') => self.start_blackjack(), - Key::Char('h') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => { - self.blackjack_action("hit") - } - Key::Char('s') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => { - self.blackjack_action("stand") - } - Key::Char('d') - if hand.as_ref().is_some_and(|hand| { - hand.phase == "player" && hand.actions.iter().any(|action| action == "double") - }) => - { - self.blackjack_action("double") - } + Key::Char('q') => self.command(Command::Leave), + Key::Char(' ') => self.command(Command::Deal), + Key::Char('h') if player => self.command(Command::Hit), + Key::Char('s') if player => self.command(Command::Stand), + Key::Char('d') if can_double => self.command(Command::Double), Key::Char('+') | Key::Char('=') if matches!(status.as_str(), "ready" | "done") => { - let _ = self.game.adjust_bet(10); - self.board_effects() + self.command(Command::AdjustBet(10)) } Key::Char('-') if matches!(status.as_str(), "ready" | "done") => { - let _ = self.game.adjust_bet(-10); - self.board_effects() + self.command(Command::AdjustBet(-10)) } _ => Vec::new(), } @@ -643,32 +700,15 @@ impl ClientCore { match key { Key::CtrlC => vec![Effect::Quit], - Key::Char('q') => { - if running { - let Some(session) = self.game.crash_session_id() else { - return Vec::new(); - }; - self.pending = Some(Pending::CrashForfeit); - vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] - } else { - self.crash_started = None; - self.game.end_game(); - vec![ - Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), - ] - } + Key::Char('q') => self.command(Command::Leave), + Key::Char(' ') if !running && self.pending.is_none() => self.command(Command::Start), + Key::Char('c') if running && self.pending.is_none() => { + self.command(Command::CashOut) } - Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(), - Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(), Key::Char('+') | Key::Char('=') if !running => { - let _ = self.game.adjust_bet(10); - self.board_effects() - } - Key::Char('-') if !running => { - let _ = self.game.adjust_bet(-10); - self.board_effects() + self.command(Command::AdjustBet(10)) } + Key::Char('-') if !running => self.command(Command::AdjustBet(-10)), _ => Vec::new(), } } @@ -948,24 +988,20 @@ impl ClientCore { effects } - fn board_lines(&self) -> Option> { - let game = self.game.get_current_game()?; - let state = self.game.get_current_state()?; - Some(game.render(state).lines().map(str::to_string).collect()) - } - /// Snapshot of the current screen for a frontend to draw. - pub fn view(&self) -> View { - if let Some(state) = self.game.get_current_state() { - if state.game_type == "roulette" { - return View::Roulette(RouletteView::from_state(state)); - } - } - View::Text(self.board_lines().unwrap_or_default()) + pub fn view(&self) -> Option { + let state = self.game.get_current_state()?; + Some(match state.game_type.as_str() { + "slots" => View::Slots(SlotsView::from_state(state)), + "roulette" => View::Roulette(RouletteView::from_state(state)), + "blackjack" => View::Blackjack(BlackjackView::from_state(state)), + "crash" => View::Crash(CrashView::from_state(state)), + _ => return None, + }) } fn board_effects(&self) -> Vec { - vec![Effect::Render(self.view())] + self.view().map(Effect::Render).into_iter().collect() } fn prompt_effects(&self) -> Vec { diff --git a/src/client/games/blackjack.rs b/src/client/games/blackjack.rs index 8fa5693..e8f251f 100644 --- a/src/client/games/blackjack.rs +++ b/src/client/games/blackjack.rs @@ -1,5 +1,6 @@ use super::traits::{Game, GameState}; -use crate::blackjack::{BlackjackState, card_label}; +use crate::blackjack::card_label; +use crate::client::view::BlackjackView; pub const BLACKJACK_BOARD_LINES: usize = 6; @@ -10,13 +11,6 @@ impl BlackjackGame { Self } - fn state(state: &GameState) -> Option { - state - .data - .get("blackjack") - .and_then(|raw| serde_json::from_str(raw).ok()) - } - fn cards(cards: &[String]) -> String { cards .iter() @@ -43,40 +37,29 @@ impl BlackjackGame { _ => "Hand complete.".to_string(), } } -} -impl Game for BlackjackGame { - fn get_name(&self) -> &'static str { - "Blackjack" - } - - fn render(&self, state: &GameState) -> String { - let status = state - .data - .get("status") - .map(String::as_str) - .unwrap_or("ready"); - let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); - let hand = Self::state(state); + /// Render a blackjack view to terminal lines. The GUI paints the same view. + pub fn render_view(view: &BlackjackView) -> String { + let balance = view + .balance + .map(|balance| balance.to_string()) + .unwrap_or_else(|| "?".to_string()); - if let Some(error) = state.data.get("error") { - let bet = hand - .as_ref() - .map(|hand| hand.bet) - .unwrap_or(state.bet_amount); + if let Some(error) = &view.error { + let bet = view.hand.as_ref().map(|hand| hand.bet).unwrap_or(view.bet); return format!( "=== BLACKJACK ===\r\nBalance: {balance} Kr Bet: {bet} Kr\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit" ); } let (dealer, dealer_total, player, player_total, bet, text, controls) = - if let Some(hand) = &hand { - let bet = if status == "player" { + if let Some(hand) = &view.hand { + let bet = if view.status == "player" { hand.bet } else { - state.bet_amount + view.bet }; - let text = match status { + let text = match view.status.as_str() { "player" => { if hand.actions.iter().any(|action| action == "double") { "Hit, stand or double.".to_string() @@ -89,7 +72,7 @@ impl Game for BlackjackGame { "done" => Self::result_text(&hand.result, hand.winnings), _ => "Press [SPACE] to deal.".to_string(), }; - let controls = if status == "player" { + let controls = if view.status == "player" { if hand.actions.iter().any(|action| action == "double") { "[H]it [S]tand [D]ouble [Q] Forfeit".to_string() } else { @@ -113,7 +96,7 @@ impl Game for BlackjackGame { 0, "—".to_string(), 0, - state.bet_amount, + view.bet, "Press [SPACE] to deal.".to_string(), "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(), ) @@ -124,6 +107,16 @@ impl Game for BlackjackGame { balance, bet, dealer, dealer_total, player, player_total, text, controls ) } +} + +impl Game for BlackjackGame { + fn get_name(&self) -> &'static str { + "Blackjack" + } + + fn render(&self, state: &GameState) -> String { + Self::render_view(&BlackjackView::from_state(state)) + } fn render_lines(&self, _state: &GameState) -> usize { BLACKJACK_BOARD_LINES diff --git a/src/client/games/crash.rs b/src/client/games/crash.rs index 383e807..affb642 100644 --- a/src/client/games/crash.rs +++ b/src/client/games/crash.rs @@ -1,4 +1,5 @@ use super::traits::{Game, GameState}; +use crate::client::view::CrashView; pub const CRASH_BOARD_LINES: usize = 6; @@ -9,67 +10,67 @@ impl CrashGame { Self } - fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> { - state.data.get(key).map(String::as_str) - } - - fn status_text(state: &GameState) -> String { - let status = Self::value(state, "status").unwrap_or("ready"); - match status { + fn status_text(view: &CrashView) -> String { + match view.status.as_str() { "starting" => "Starting...".to_string(), "running" => "Rising... cash out before it crashes.".to_string(), "cashed" => format!( - "Cashed out at {}x Won {} Kr", - Self::value(state, "multiplier").unwrap_or("1.00"), - Self::value(state, "payout").unwrap_or("0") + "Cashed out at {:.2}x Won {} Kr", + view.multiplier, view.payout ), "crashed" => format!( "CRASHED at {}x Bet lost", - Self::value(state, "crash_point").unwrap_or("?") + view.crash_point + .map(|point| format!("{point:.2}")) + .unwrap_or_else(|| "?".to_string()) ), "forfeit" => "Forfeited. Bet lost.".to_string(), _ => "Press [SPACE] to start.".to_string(), } } - fn controls(state: &GameState) -> &'static str { - if Self::value(state, "status") == Some("running") { + fn controls(view: &CrashView) -> &'static str { + if view.status == "running" { "[C] Cash out [Q] Forfeit" } else { "[SPACE] Play [+/=/-] Bet [Q] Quit" } } -} -impl Game for CrashGame { - fn get_name(&self) -> &'static str { - "Crash" - } - - fn render(&self, state: &GameState) -> String { - let balance = Self::value(state, "balance").unwrap_or("?"); - let multiplier = Self::value(state, "multiplier").unwrap_or("1.00"); - let potential = Self::value(state, "potential") - .map(str::to_string) - .unwrap_or_else(|| state.bet_amount.to_string()); + /// Render a crash view to terminal lines. The GUI paints the same view. + pub fn render_view(view: &CrashView) -> String { + let balance = view + .balance + .map(|balance| balance.to_string()) + .unwrap_or_else(|| "?".to_string()); - if let Some(error) = Self::value(state, "error") { + if let Some(error) = &view.error { return format!( "=== CRASH ===\r\nBalance: {balance} Kr Bet: {} Kr\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit", - state.bet_amount + view.bet ); } format!( - "=== CRASH ===\r\nBalance: {} Kr Bet: {} Kr\r\nMultiplier: {}x\r\nPotential: {} Kr\r\n{}\r\n{}", + "=== CRASH ===\r\nBalance: {} Kr Bet: {} Kr\r\nMultiplier: {:.2}x\r\nPotential: {} Kr\r\n{}\r\n{}", balance, - state.bet_amount, - multiplier, - potential, - Self::status_text(state), - Self::controls(state) + view.bet, + view.multiplier, + view.potential, + Self::status_text(view), + Self::controls(view) ) } +} + +impl Game for CrashGame { + fn get_name(&self) -> &'static str { + "Crash" + } + + fn render(&self, state: &GameState) -> String { + Self::render_view(&CrashView::from_state(state)) + } fn render_lines(&self, _state: &GameState) -> usize { CRASH_BOARD_LINES diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 5bffc4c..3aad319 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs @@ -1,4 +1,5 @@ -use super::traits::{Game, GamePhase, GameState}; +use super::traits::{Game, GameState}; +use crate::client::view::{SlotsPhase, SlotsView}; use crate::slots::{LINE_NAMES, REELS, ROWS, paytable_lines}; /// Height of the game board. It must never depend on the spin result. @@ -28,105 +29,91 @@ impl SlotsGame { } } - fn highlighted_cells(state: &GameState) -> Vec { - state - .data - .get("win_cells") - .map(|raw| { - raw.split(',') - .filter_map(|value| value.trim().parse::().ok()) - .collect() - }) - .unwrap_or_default() - } - - fn render_grid(&self, reels: &str, winning: &[usize]) -> [String; ROWS] { - let chars: Vec = reels.chars().collect(); + fn render_grid(reels: &[char], winning: &[usize]) -> [String; ROWS] { std::array::from_fn(|row| { (0..REELS) - .map(|col| Self::cell(&chars, row, col, winning)) + .map(|col| Self::cell(reels, row, col, winning)) .collect::>() .join(" ") }) } - fn render_status(&self, state: &GameState) -> String { - if let Some(error) = state.data.get("error") { + fn render_status(view: &SlotsView) -> String { + if let Some(error) = &view.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"); - if winnings != "0" { - return match state.data.get("win_type").map(String::as_str) { - Some("mega") => format!("MEGA WIN! {winnings} Kr"), - Some("big") => format!("BIG WIN! {winnings} Kr"), + + match view.phase { + SlotsPhase::Spinning => "Spinning...".to_string(), + SlotsPhase::Result => { + let winnings = view.winnings.unwrap_or(0); + if winnings == 0 { + return "No win.".to_string(); + } + match view.win_type.as_str() { + "mega" => format!("MEGA WIN! {winnings} Kr"), + "big" => format!("BIG WIN! {winnings} Kr"), _ => format!("Won {winnings} Kr"), - }; + } } - return "No win.".to_string(); + SlotsPhase::Ready => "Press [SPACE] to spin!".to_string(), } - "Press [SPACE] to spin!".to_string() } - fn render_win_lines(&self, state: &GameState) -> String { - if state.phase != GamePhase::Finished { + fn render_win_lines(view: &SlotsView) -> String { + if view.phase != SlotsPhase::Result || view.win_lines.is_empty() { return String::new(); } - match state.data.get("win_lines").map(String::as_str) { - Some(lines) if !lines.is_empty() && lines != "-" => { - let named: Vec = lines - .split(',') - .filter_map(|raw| raw.trim().parse::().ok()) - .map(|line| { - let name = line - .checked_sub(1) - .and_then(|index| LINE_NAMES.get(index)) - .copied(); - match name { - Some(name) => format!("{line} ({name})"), - None => line.to_string(), - } - }) - .collect(); - format!("Winning lines: {}", named.join(", ")) - } - _ => String::new(), - } - } -} -impl Game for SlotsGame { - fn get_name(&self) -> &'static str { - "Slots" + let named: Vec = view + .win_lines + .iter() + .map(|line| { + let name = line + .checked_sub(1) + .and_then(|index| LINE_NAMES.get(index)) + .copied(); + match name { + Some(name) => format!("{line} ({name})"), + None => line.to_string(), + } + }) + .collect(); + format!("Winning lines: {}", named.join(", ")) } - 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("?"); - let winning = Self::highlighted_cells(state); - let grid = self.render_grid(&reels, &winning); + /// Render a slots view to terminal lines. The GUI paints the same view. + pub fn render_view(view: &SlotsView) -> String { + let balance = view + .balance + .map(|balance| balance.to_string()) + .unwrap_or_else(|| "?".to_string()); + let grid = Self::render_grid(&view.reels, &view.win_cells); let paytable = paytable_lines(); format!( "=== SLOTS ===\r\nBet: {} Kr Balance: {} Kr\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n{}\r\n[+/-] Bet [SPACE] Spin [Q] Quit", - state.bet_amount, + view.bet, balance, grid[0], grid[1], grid[2], paytable[0], paytable[1], - self.render_status(state), - self.render_win_lines(state) + Self::render_status(view), + Self::render_win_lines(view) ) } +} + +impl Game for SlotsGame { + fn get_name(&self) -> &'static str { + "Slots" + } + + fn render(&self, state: &GameState) -> String { + Self::render_view(&SlotsView::from_state(state)) + } fn render_lines(&self, _state: &GameState) -> usize { SLOT_BOARD_LINES diff --git a/src/client/tui.rs b/src/client/tui.rs index 9a4e4da..ddea5e2 100644 --- a/src/client/tui.rs +++ b/src/client/tui.rs @@ -3,16 +3,19 @@ //! This is the TUI's half of the `View` boundary: the GUI renders the same //! data with a painter instead of strings. +use crate::client::games::blackjack::BlackjackGame; +use crate::client::games::crash::CrashGame; use crate::client::games::roulette::RouletteGame; +use crate::client::games::slots::SlotsGame; use crate::client::view::View; /// Render any view to terminal lines. pub fn render_view(view: &View) -> Vec { - match view { - View::Roulette(roulette) => RouletteGame::render_view(roulette) - .lines() - .map(str::to_string) - .collect(), - View::Text(lines) => lines.clone(), - } + let rendered = match view { + View::Slots(slots) => SlotsGame::render_view(slots), + View::Roulette(roulette) => RouletteGame::render_view(roulette), + View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack), + View::Crash(crash) => CrashGame::render_view(crash), + }; + rendered.lines().map(str::to_string).collect() } diff --git a/src/client/view.rs b/src/client/view.rs index 3683ac2..77efec6 100644 --- a/src/client/view.rs +++ b/src/client/view.rs @@ -3,8 +3,9 @@ //! The core owns state and navigation; adapters map input to `Command` and //! render `View`. Terminal text lives in [`crate::client::tui`], not here. +use crate::blackjack::BlackjackState; use crate::client::games::roulette::{Cursor, Direction}; -use crate::client::games::traits::GameState; +use crate::client::games::traits::{GamePhase, GameState}; use crate::roulette::{BetTarget, Bets}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -81,23 +82,192 @@ impl RouletteView { } } -/// Everything a frontend can draw. `Text` is the bridge for games that have -/// not been ported to a structured view yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlotsPhase { + Ready, + Spinning, + Result, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotsView { + pub bet: u64, + pub balance: Option, + pub reels: Vec, + pub win_cells: Vec, + pub win_lines: Vec, + pub win_type: String, + pub winnings: Option, + pub phase: SlotsPhase, + pub error: Option, +} + +fn parse_indices(raw: Option<&String>) -> Vec { + raw.map(|value| { + value + .split(',') + .filter_map(|part| part.trim().parse().ok()) + .collect() + }) + .unwrap_or_default() +} + +impl SlotsView { + pub fn from_state(state: &GameState) -> Self { + let reels = state + .data + .get("reels") + .map(|raw| raw.chars().collect()) + .unwrap_or_default(); + let phase = match state.phase { + GamePhase::Playing => SlotsPhase::Spinning, + GamePhase::Finished => SlotsPhase::Result, + GamePhase::WaitingToStart => SlotsPhase::Ready, + }; + + Self { + bet: state.bet_amount, + balance: state + .data + .get("balance") + .and_then(|value| value.parse().ok()), + reels, + win_cells: parse_indices(state.data.get("win_cells")), + win_lines: parse_indices(state.data.get("win_lines")), + win_type: state.data.get("win_type").cloned().unwrap_or_default(), + winnings: state + .data + .get("winnings") + .and_then(|value| value.parse().ok()), + phase, + error: state + .data + .get("error") + .cloned() + .filter(|message| !message.is_empty()), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlackjackView { + pub status: String, + pub balance: Option, + pub bet: u64, + pub hand: Option, + pub error: Option, +} + +impl BlackjackView { + pub fn from_state(state: &GameState) -> Self { + let hand = state + .data + .get("blackjack") + .and_then(|raw| serde_json::from_str(raw).ok()); + + Self { + status: state + .data + .get("status") + .cloned() + .unwrap_or_else(|| "ready".to_string()), + balance: state + .data + .get("balance") + .and_then(|value| value.parse().ok()), + bet: state.bet_amount, + hand, + error: state + .data + .get("error") + .cloned() + .filter(|message| !message.is_empty()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CrashView { + pub status: String, + pub balance: Option, + pub bet: u64, + pub multiplier: f64, + pub potential: u64, + pub crash_point: Option, + pub payout: u64, + pub error: Option, +} + +impl CrashView { + pub fn from_state(state: &GameState) -> Self { + let bet = state.bet_amount; + + Self { + status: state + .data + .get("status") + .cloned() + .unwrap_or_else(|| "ready".to_string()), + balance: state + .data + .get("balance") + .and_then(|value| value.parse().ok()), + bet, + multiplier: state + .data + .get("multiplier") + .and_then(|value| value.parse::().ok()) + .unwrap_or(1.0), + potential: state + .data + .get("potential") + .and_then(|value| value.parse::().ok()) + .unwrap_or(bet), + crash_point: state + .data + .get("crash_point") + .and_then(|value| value.parse::().ok()), + payout: state + .data + .get("payout") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + error: state + .data + .get("error") + .cloned() + .filter(|message| !message.is_empty()), + } + } +} + +/// Everything a frontend can draw. +#[derive(Debug, Clone, PartialEq)] pub enum View { + Slots(SlotsView), Roulette(RouletteView), - Text(Vec), + Blackjack(BlackjackView), + Crash(CrashView), } /// Semantic input. Frontends turn keys and clicks into these. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { + SetBet(u64), + AdjustBet(i64), + Leave, + Spin, + Deal, + Hit, + Stand, + Double, + Start, + CashOut, + Forfeit, MoveCursor(Direction), PlaceBet, PlaceBetAt(BetTarget), ClearBets, SetChip(u64), AdjustChip(i64), - Spin, - Leave, } -- cgit v1.2.3