From 46202ee2e398ffd49febe6684f5c6a8648da600d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 17:58:03 +0100 Subject: v4.0.1: introduce Command/View and port roulette to the shared boundary --- src/client/core.rs | 109 ++++++++++++++++++--------------- src/client/games/roulette.rs | 141 ++++++++++++++++++------------------------- src/client/mod.rs | 2 + src/client/tui.rs | 18 ++++++ src/client/view.rs | 103 +++++++++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 132 deletions(-) create mode 100644 src/client/tui.rs create mode 100644 src/client/view.rs (limited to 'src/client') diff --git a/src/client/core.rs b/src/client/core.rs index 0b2e683..7e43fb7 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -4,13 +4,14 @@ 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::ui::{ command_names, completion_lines, display_width, help_lines, history_lines, input_cursor_column, parse_bet_arg, record_history, }; use crate::crash::{CrashState, multiplier_at}; use crate::history::MAX_HISTORY; -use crate::roulette::Bets; +use crate::roulette::{BetTarget, Bets}; use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,13 +42,10 @@ pub enum InteractiveCommand { pub enum Effect { /// Append lines to the scrolling transcript. Print(Vec), - /// Replace the game board region in place. - ReplaceRegion(Vec), - /// Run the roulette wheel animation, then draw the final board lines. - AnimateRoulette { - winning: u8, - final_lines: Vec, - }, + /// Draw the current view in the board region. + Render(View), + /// Run the roulette wheel animation, then draw the final view. + AnimateRoulette { winning: u8, view: View }, /// Remove the game board region. ClearRegion, /// Redraw the command prompt. `text` is the input without the `> ` prefix. @@ -215,11 +213,8 @@ impl ClientCore { self.balance = Some(result.balance); let winning = result.number; self.game.apply_roulette_result(result); - let final_lines = self.board_lines().unwrap_or_default(); - vec![Effect::AnimateRoulette { - winning, - final_lines, - }] + let view = self.view(); + vec![Effect::AnimateRoulette { winning, view }] } Err(message) => { self.game.set_error(message); @@ -380,53 +375,62 @@ impl ClientCore { } fn handle_roulette_key(&mut self, key: Key) -> Vec { - let direction = match key { - Key::Up => Some(Direction::Up), - Key::Down => Some(Direction::Down), - Key::Left => Some(Direction::Left), - Key::Right => Some(Direction::Right), - _ => None, - }; - if let Some(direction) = direction { - let cursor = self.game.roulette_cursor().moved(direction); - self.game.set_roulette_cursor(&cursor); - return self.board_effects(); - } - match key { Key::CtrlC => vec![Effect::Quit], - Key::Char('q') => { - self.game.end_game(); - vec![ - Effect::ClearRegion, - Effect::Print(vec!["Game exited.".to_string()]), - ] + Key::Up => self.command(Command::MoveCursor(Direction::Up)), + Key::Down => self.command(Command::MoveCursor(Direction::Down)), + Key::Left => self.command(Command::MoveCursor(Direction::Left)), + Key::Right => self.command(Command::MoveCursor(Direction::Right)), + Key::Enter => self.command(Command::PlaceBet), + Key::Char(' ') => self.command(Command::Spin), + Key::Char('c') => self.command(Command::ClearBets), + Key::Char('+') | Key::Char('=') => self.command(Command::AdjustChip(10)), + Key::Char('-') => self.command(Command::AdjustChip(-10)), + Key::Char('q') => self.command(Command::Leave), + _ => Vec::new(), + } + } + + /// Apply a semantic command. Both frontends drive the core through this. + pub fn command(&mut self, command: Command) -> Vec { + match command { + Command::MoveCursor(direction) => { + let cursor = self.game.roulette_cursor().moved(direction); + self.game.set_roulette_cursor(&cursor); + self.board_effects() } - Key::Char(' ') => self.spin_roulette(), - Key::Char('c') => { + Command::PlaceBet => match self.game.roulette_cursor().target() { + Some(target) => self.place_roulette_chip_on(target), + None => Vec::new(), + }, + Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), + Command::ClearBets => { self.game.set_roulette_bets(&Bets::default()); self.game.set_roulette_status("place"); self.board_effects() } - Key::Char('+') | Key::Char('=') => { - let chip = self.game.roulette_chip().saturating_add(10); - self.game.set_roulette_chip(chip); + Command::SetChip(chip) => { + self.game.set_roulette_chip(chip.max(10)); self.board_effects() } - Key::Char('-') => { - let chip = self.game.roulette_chip().saturating_sub(10).max(10); + Command::AdjustChip(delta) => { + let chip = (self.game.roulette_chip() as i64 + delta).max(10) as u64; self.game.set_roulette_chip(chip); self.board_effects() } - Key::Enter => self.place_roulette_chip(), - _ => Vec::new(), + 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 place_roulette_chip(&mut self) -> Vec { - let Some(target) = self.game.roulette_cursor().target() else { - return Vec::new(); - }; + fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec { let mut bets = self.game.roulette_bets(); bets.add(target, self.game.roulette_chip()); self.place_roulette_bets(bets) @@ -950,11 +954,18 @@ impl ClientCore { 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()) + } + fn board_effects(&self) -> Vec { - self.board_lines() - .map(Effect::ReplaceRegion) - .into_iter() - .collect() + vec![Effect::Render(self.view())] } fn prompt_effects(&self) -> Vec { diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index b3a8d9c..ec9573c 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs @@ -1,4 +1,5 @@ use super::traits::{Game, GameState}; +use crate::client::view::{RoulettePhase, RouletteView}; use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; pub const ROULETTE_BOARD_LINES: usize = 12; @@ -232,22 +233,6 @@ impl RouletteGame { Self } - fn bets(state: &GameState) -> Bets { - state - .data - .get("bets") - .and_then(|raw| Bets::from_json(raw).ok()) - .unwrap_or_default() - } - - fn cursor(state: &GameState) -> Cursor { - state - .data - .get("cursor") - .map(|raw| Cursor::parse(raw)) - .unwrap_or_default() - } - fn zero_cell(cursor: Cursor) -> String { let cell = if cursor == Cursor::Zero { "[ 0 ]".to_string() @@ -329,92 +314,82 @@ impl RouletteGame { format!("Bets: {list} Total: {} Kr", bets.total()) } - fn status_line(state: &GameState) -> String { - if let Some(error) = state.data.get("error") { - if !error.is_empty() { - return format!("ERROR: {error}"); - } + fn status_line(view: &RouletteView) -> String { + if let Some(error) = &view.error { + return format!("ERROR: {error}"); } - match state.data.get("status").map(String::as_str) { - Some("result") => { - let number = state.data.get("number").map(String::as_str).unwrap_or("?"); - let color = state - .data - .get("color") - .map(|value| value.to_uppercase()) + match view.phase { + RoulettePhase::Result => { + let number = view.number.unwrap_or(0); + let color = view + .color + .as_deref() + .map(str::to_uppercase) .unwrap_or_default(); - let parity = number - .parse::() - .ok() - .map(|number| { - if is_even(number) { - " EVEN" - } else if is_odd(number) { - " ODD" - } else { - "" - } - }) - .unwrap_or(""); - let winnings = state - .data - .get("winnings") - .map(String::as_str) - .unwrap_or("0"); - let stake = state.bet_amount; - if winnings == "0" { - format!("No win at {number} {color}{parity}") + let parity = if is_even(number) { + " EVEN" + } else if is_odd(number) { + " ODD" } else { - let profit = winnings.parse::().unwrap_or(0).saturating_sub(stake); - format!("Won {winnings} Kr at {number} {color}{parity} (+{profit})") - } - } - Some("spinning") => "Spinning...".to_string(), - _ => { - let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); - match Self::cursor(state).target() { - Some(target) => format!( - "Bet: {} · pays {} · ENTER places {} Kr", - target.label(), - target.return_label(), - chip - ), - None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), + "" + }; + match view.winnings.unwrap_or(0) { + 0 => format!("No win at {number} {color}{parity}"), + winnings => { + let profit = winnings.saturating_sub(view.bet_amount); + format!("Won {winnings} Kr at {number} {color}{parity} (+{profit})") + } } } + RoulettePhase::Spinning => "Spinning...".to_string(), + RoulettePhase::Placing => match view.cursor.target() { + Some(target) => format!( + "Bet: {} · pays {} · ENTER places {} Kr", + target.label(), + target.return_label(), + view.chip + ), + None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), + }, } } -} - -impl Game for RouletteGame { - fn get_name(&self) -> &'static str { - "Roulette" - } - fn render(&self, state: &GameState) -> String { - let bets = Self::bets(state); - let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); - let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); - let cursor = Self::cursor(state); - let covered = cursor.numbers(); + /// Render a roulette view to terminal lines. The GUI draws the same view + /// with a painter instead. + pub fn render_view(view: &RouletteView) -> String { + let covered = view.cursor.numbers(); + let balance = view + .balance + .map(|balance| balance.to_string()) + .unwrap_or_else(|| "?".to_string()); let lines = [ "=== ROULETTE ===".to_string(), - format!("Balance: {balance} Kr Chip: {chip} Kr"), - Self::zero_cell(cursor), + format!("Balance: {balance} Kr Chip: {} Kr", view.chip), + Self::zero_cell(view.cursor), Self::number_row(0, &covered), Self::number_row(1, &covered), Self::number_row(2, &covered), - Self::dozens_row(cursor), - Self::columns_row(cursor), - Self::outside_row(cursor), - Self::bets_line(&bets), - Self::status_line(state), + Self::dozens_row(view.cursor), + Self::columns_row(view.cursor), + Self::outside_row(view.cursor), + Self::bets_line(&view.bets), + Self::status_line(view), "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit".to_string(), ]; lines.join("\r\n") } +} + +impl Game for RouletteGame { + fn get_name(&self) -> &'static str { + "Roulette" + } + + fn render(&self, state: &GameState) -> String { + Self::render_view(&RouletteView::from_state(state)) + } fn render_lines(&self, _state: &GameState) -> usize { ROULETTE_BOARD_LINES diff --git a/src/client/mod.rs b/src/client/mod.rs index 6841be3..a79a460 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -2,4 +2,6 @@ pub mod core; pub mod database; pub mod games; pub mod paths; +pub mod tui; pub mod ui; +pub mod view; diff --git a/src/client/tui.rs b/src/client/tui.rs new file mode 100644 index 0000000..9a4e4da --- /dev/null +++ b/src/client/tui.rs @@ -0,0 +1,18 @@ +//! Text rendering for the terminal adapter. +//! +//! 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::roulette::RouletteGame; +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(), + } +} diff --git a/src/client/view.rs b/src/client/view.rs new file mode 100644 index 0000000..3683ac2 --- /dev/null +++ b/src/client/view.rs @@ -0,0 +1,103 @@ +//! What a frontend draws and what it can ask the core to do. +//! +//! 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::client::games::roulette::{Cursor, Direction}; +use crate::client::games::traits::GameState; +use crate::roulette::{BetTarget, Bets}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoulettePhase { + Placing, + Spinning, + Result, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouletteView { + pub bet_amount: u64, + pub balance: Option, + pub chip: u64, + pub bets: Bets, + pub cursor: Cursor, + pub phase: RoulettePhase, + pub number: Option, + pub color: Option, + pub winnings: Option, + pub error: Option, +} + +impl RouletteView { + pub fn from_state(state: &GameState) -> Self { + let bets = state + .data + .get("bets") + .and_then(|raw| Bets::from_json(raw).ok()) + .unwrap_or_default(); + let chip = state + .data + .get("chip") + .and_then(|value| value.parse().ok()) + .unwrap_or(10); + let cursor = state + .data + .get("cursor") + .map(|raw| Cursor::parse(raw)) + .unwrap_or_default(); + let phase = match state.data.get("status").map(String::as_str) { + Some("spinning") => RoulettePhase::Spinning, + Some("result") => RoulettePhase::Result, + _ => RoulettePhase::Placing, + }; + let number = state.data.get("number").and_then(|value| value.parse().ok()); + let color = state.data.get("color").cloned(); + let winnings = state + .data + .get("winnings") + .and_then(|value| value.parse().ok()); + let balance = state + .data + .get("balance") + .and_then(|value| value.parse().ok()); + let error = state + .data + .get("error") + .cloned() + .filter(|message| !message.is_empty()); + + Self { + bet_amount: state.bet_amount, + balance, + chip, + bets, + cursor, + phase, + number, + color, + winnings, + error, + } + } +} + +/// Everything a frontend can draw. `Text` is the bridge for games that have +/// not been ported to a structured view yet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum View { + Roulette(RouletteView), + Text(Vec), +} + +/// Semantic input. Frontends turn keys and clicks into these. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + MoveCursor(Direction), + PlaceBet, + PlaceBetAt(BetTarget), + ClearBets, + SetChip(u64), + AdjustChip(i64), + Spin, + Leave, +} -- cgit v1.2.3