diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 17:58:03 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 17:58:03 +0100 |
| commit | 46202ee2e398ffd49febe6684f5c6a8648da600d (patch) | |
| tree | 00631532b6b4805ddc8a728246787651dd7185f2 | |
| parent | docs: document DOWNLOAD_URL and the XDG reset path (diff) | |
| download | zagan-bet-46202ee2e398ffd49febe6684f5c6a8648da600d.tar.gz zagan-bet-46202ee2e398ffd49febe6684f5c6a8648da600d.zip | |
v4.0.1: introduce Command/View and port roulette to the shared boundary
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 5 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 13 | ||||
| -rw-r--r-- | src/client/core.rs | 109 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 141 | ||||
| -rw-r--r-- | src/client/mod.rs | 2 | ||||
| -rw-r--r-- | src/client/tui.rs | 18 | ||||
| -rw-r--r-- | src/client/view.rs | 103 | ||||
| -rw-r--r-- | tests/client_core.rs | 130 |
9 files changed, 380 insertions, 143 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.0" | 3 | version = "4.0.1" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 0285543..f6cf499 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -26,6 +26,11 @@ existing game flows unchanged, new tests for version parsing and gating. | |||
| 26 | 26 | ||
| 27 | ## Phase 1 — core boundary and roulette | 27 | ## Phase 1 — core boundary and roulette |
| 28 | 28 | ||
| 29 | **In progress.** `Command`/`View` exist and roulette is ported: the core builds | ||
| 30 | a `RouletteView`, frontends send `Command`, and the TUI renders the view. | ||
| 31 | Slots, blackjack and crash still travel through the `View::Text` bridge until | ||
| 32 | Phase 2. | ||
| 33 | |||
| 29 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 34 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 30 | wheel animation, interactive prompts) into the TUI adapter. | 35 | wheel animation, interactive prompts) into the TUI adapter. |
| 31 | - App screens: `Connect`, `Trust`, `TrustMismatch`, `Login`, `Lobby`, | 36 | - App screens: `Connect`, `Trust`, `TrustMismatch`, `Login`, `Lobby`, |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 1debff1..c13fc58 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -209,13 +209,14 @@ impl ClientState { | |||
| 209 | println!("{}", rendered); | 209 | println!("{}", rendered); |
| 210 | } | 210 | } |
| 211 | } | 211 | } |
| 212 | Effect::ReplaceRegion(lines) => self.draw_region(&lines), | 212 | Effect::Render(view) => { |
| 213 | Effect::AnimateRoulette { | 213 | let lines = zagan_bet::client::tui::render_view(&view); |
| 214 | winning, | 214 | self.draw_region(&lines); |
| 215 | final_lines, | 215 | } |
| 216 | } => { | 216 | Effect::AnimateRoulette { winning, view } => { |
| 217 | self.animate_wheel(winning); | 217 | self.animate_wheel(winning); |
| 218 | self.draw_region(&final_lines); | 218 | let lines = zagan_bet::client::tui::render_view(&view); |
| 219 | self.draw_region(&lines); | ||
| 219 | while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { | 220 | while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { |
| 220 | let _ = crossterm::event::read(); | 221 | let _ = crossterm::event::read(); |
| 221 | } | 222 | } |
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::{ | |||
| 4 | parse_roulette_response, parse_spin_response, | 4 | parse_roulette_response, parse_spin_response, |
| 5 | }; | 5 | }; |
| 6 | use crate::client::games::roulette::Direction; | 6 | use crate::client::games::roulette::Direction; |
| 7 | use crate::client::view::{Command, RouletteView, View}; | ||
| 7 | use crate::client::ui::{ | 8 | use crate::client::ui::{ |
| 8 | command_names, completion_lines, display_width, help_lines, history_lines, | 9 | command_names, completion_lines, display_width, help_lines, history_lines, |
| 9 | input_cursor_column, parse_bet_arg, record_history, | 10 | input_cursor_column, parse_bet_arg, record_history, |
| 10 | }; | 11 | }; |
| 11 | use crate::crash::{CrashState, multiplier_at}; | 12 | use crate::crash::{CrashState, multiplier_at}; |
| 12 | use crate::history::MAX_HISTORY; | 13 | use crate::history::MAX_HISTORY; |
| 13 | use crate::roulette::Bets; | 14 | use crate::roulette::{BetTarget, Bets}; |
| 14 | use std::time::{Duration, Instant}; | 15 | use std::time::{Duration, Instant}; |
| 15 | 16 | ||
| 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| @@ -41,13 +42,10 @@ pub enum InteractiveCommand { | |||
| 41 | pub enum Effect { | 42 | pub enum Effect { |
| 42 | /// Append lines to the scrolling transcript. | 43 | /// Append lines to the scrolling transcript. |
| 43 | Print(Vec<String>), | 44 | Print(Vec<String>), |
| 44 | /// Replace the game board region in place. | 45 | /// Draw the current view in the board region. |
| 45 | ReplaceRegion(Vec<String>), | 46 | Render(View), |
| 46 | /// Run the roulette wheel animation, then draw the final board lines. | 47 | /// Run the roulette wheel animation, then draw the final view. |
| 47 | AnimateRoulette { | 48 | AnimateRoulette { winning: u8, view: View }, |
| 48 | winning: u8, | ||
| 49 | final_lines: Vec<String>, | ||
| 50 | }, | ||
| 51 | /// Remove the game board region. | 49 | /// Remove the game board region. |
| 52 | ClearRegion, | 50 | ClearRegion, |
| 53 | /// Redraw the command prompt. `text` is the input without the `> ` prefix. | 51 | /// Redraw the command prompt. `text` is the input without the `> ` prefix. |
| @@ -215,11 +213,8 @@ impl ClientCore { | |||
| 215 | self.balance = Some(result.balance); | 213 | self.balance = Some(result.balance); |
| 216 | let winning = result.number; | 214 | let winning = result.number; |
| 217 | self.game.apply_roulette_result(result); | 215 | self.game.apply_roulette_result(result); |
| 218 | let final_lines = self.board_lines().unwrap_or_default(); | 216 | let view = self.view(); |
| 219 | vec![Effect::AnimateRoulette { | 217 | vec![Effect::AnimateRoulette { winning, view }] |
| 220 | winning, | ||
| 221 | final_lines, | ||
| 222 | }] | ||
| 223 | } | 218 | } |
| 224 | Err(message) => { | 219 | Err(message) => { |
| 225 | self.game.set_error(message); | 220 | self.game.set_error(message); |
| @@ -380,53 +375,62 @@ impl ClientCore { | |||
| 380 | } | 375 | } |
| 381 | 376 | ||
| 382 | fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { | 377 | fn handle_roulette_key(&mut self, key: Key) -> Vec<Effect> { |
| 383 | let direction = match key { | ||
| 384 | Key::Up => Some(Direction::Up), | ||
| 385 | Key::Down => Some(Direction::Down), | ||
| 386 | Key::Left => Some(Direction::Left), | ||
| 387 | Key::Right => Some(Direction::Right), | ||
| 388 | _ => None, | ||
| 389 | }; | ||
| 390 | if let Some(direction) = direction { | ||
| 391 | let cursor = self.game.roulette_cursor().moved(direction); | ||
| 392 | self.game.set_roulette_cursor(&cursor); | ||
| 393 | return self.board_effects(); | ||
| 394 | } | ||
| 395 | |||
| 396 | match key { | 378 | match key { |
| 397 | Key::CtrlC => vec![Effect::Quit], | 379 | Key::CtrlC => vec![Effect::Quit], |
| 398 | Key::Char('q') => { | 380 | Key::Up => self.command(Command::MoveCursor(Direction::Up)), |
| 399 | self.game.end_game(); | 381 | Key::Down => self.command(Command::MoveCursor(Direction::Down)), |
| 400 | vec![ | 382 | Key::Left => self.command(Command::MoveCursor(Direction::Left)), |
| 401 | Effect::ClearRegion, | 383 | Key::Right => self.command(Command::MoveCursor(Direction::Right)), |
| 402 | Effect::Print(vec!["Game exited.".to_string()]), | 384 | Key::Enter => self.command(Command::PlaceBet), |
| 403 | ] | 385 | Key::Char(' ') => self.command(Command::Spin), |
| 386 | Key::Char('c') => self.command(Command::ClearBets), | ||
| 387 | Key::Char('+') | Key::Char('=') => self.command(Command::AdjustChip(10)), | ||
| 388 | Key::Char('-') => self.command(Command::AdjustChip(-10)), | ||
| 389 | Key::Char('q') => self.command(Command::Leave), | ||
| 390 | _ => Vec::new(), | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | /// Apply a semantic command. Both frontends drive the core through this. | ||
| 395 | pub fn command(&mut self, command: Command) -> Vec<Effect> { | ||
| 396 | match command { | ||
| 397 | Command::MoveCursor(direction) => { | ||
| 398 | let cursor = self.game.roulette_cursor().moved(direction); | ||
| 399 | self.game.set_roulette_cursor(&cursor); | ||
| 400 | self.board_effects() | ||
| 404 | } | 401 | } |
| 405 | Key::Char(' ') => self.spin_roulette(), | 402 | Command::PlaceBet => match self.game.roulette_cursor().target() { |
| 406 | Key::Char('c') => { | 403 | Some(target) => self.place_roulette_chip_on(target), |
| 404 | None => Vec::new(), | ||
| 405 | }, | ||
| 406 | Command::PlaceBetAt(target) => self.place_roulette_chip_on(target), | ||
| 407 | Command::ClearBets => { | ||
| 407 | self.game.set_roulette_bets(&Bets::default()); | 408 | self.game.set_roulette_bets(&Bets::default()); |
| 408 | self.game.set_roulette_status("place"); | 409 | self.game.set_roulette_status("place"); |
| 409 | self.board_effects() | 410 | self.board_effects() |
| 410 | } | 411 | } |
| 411 | Key::Char('+') | Key::Char('=') => { | 412 | Command::SetChip(chip) => { |
| 412 | let chip = self.game.roulette_chip().saturating_add(10); | 413 | self.game.set_roulette_chip(chip.max(10)); |
| 413 | self.game.set_roulette_chip(chip); | ||
| 414 | self.board_effects() | 414 | self.board_effects() |
| 415 | } | 415 | } |
| 416 | Key::Char('-') => { | 416 | Command::AdjustChip(delta) => { |
| 417 | let chip = self.game.roulette_chip().saturating_sub(10).max(10); | 417 | let chip = (self.game.roulette_chip() as i64 + delta).max(10) as u64; |
| 418 | self.game.set_roulette_chip(chip); | 418 | self.game.set_roulette_chip(chip); |
| 419 | self.board_effects() | 419 | self.board_effects() |
| 420 | } | 420 | } |
| 421 | Key::Enter => self.place_roulette_chip(), | 421 | Command::Spin => self.spin_roulette(), |
| 422 | _ => Vec::new(), | 422 | Command::Leave => { |
| 423 | self.crash_started = None; | ||
| 424 | self.game.end_game(); | ||
| 425 | vec![ | ||
| 426 | Effect::ClearRegion, | ||
| 427 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 428 | ] | ||
| 429 | } | ||
| 423 | } | 430 | } |
| 424 | } | 431 | } |
| 425 | 432 | ||
| 426 | fn place_roulette_chip(&mut self) -> Vec<Effect> { | 433 | fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { |
| 427 | let Some(target) = self.game.roulette_cursor().target() else { | ||
| 428 | return Vec::new(); | ||
| 429 | }; | ||
| 430 | let mut bets = self.game.roulette_bets(); | 434 | let mut bets = self.game.roulette_bets(); |
| 431 | bets.add(target, self.game.roulette_chip()); | 435 | bets.add(target, self.game.roulette_chip()); |
| 432 | self.place_roulette_bets(bets) | 436 | self.place_roulette_bets(bets) |
| @@ -950,11 +954,18 @@ impl ClientCore { | |||
| 950 | Some(game.render(state).lines().map(str::to_string).collect()) | 954 | Some(game.render(state).lines().map(str::to_string).collect()) |
| 951 | } | 955 | } |
| 952 | 956 | ||
| 957 | /// Snapshot of the current screen for a frontend to draw. | ||
| 958 | pub fn view(&self) -> View { | ||
| 959 | if let Some(state) = self.game.get_current_state() { | ||
| 960 | if state.game_type == "roulette" { | ||
| 961 | return View::Roulette(RouletteView::from_state(state)); | ||
| 962 | } | ||
| 963 | } | ||
| 964 | View::Text(self.board_lines().unwrap_or_default()) | ||
| 965 | } | ||
| 966 | |||
| 953 | fn board_effects(&self) -> Vec<Effect> { | 967 | fn board_effects(&self) -> Vec<Effect> { |
| 954 | self.board_lines() | 968 | vec![Effect::Render(self.view())] |
| 955 | .map(Effect::ReplaceRegion) | ||
| 956 | .into_iter() | ||
| 957 | .collect() | ||
| 958 | } | 969 | } |
| 959 | 970 | ||
| 960 | fn prompt_effects(&self) -> Vec<Effect> { | 971 | fn prompt_effects(&self) -> Vec<Effect> { |
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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::client::view::{RoulettePhase, RouletteView}; | ||
| 2 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; | 3 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; |
| 3 | 4 | ||
| 4 | pub const ROULETTE_BOARD_LINES: usize = 12; | 5 | pub const ROULETTE_BOARD_LINES: usize = 12; |
| @@ -232,22 +233,6 @@ impl RouletteGame { | |||
| 232 | Self | 233 | Self |
| 233 | } | 234 | } |
| 234 | 235 | ||
| 235 | fn bets(state: &GameState) -> Bets { | ||
| 236 | state | ||
| 237 | .data | ||
| 238 | .get("bets") | ||
| 239 | .and_then(|raw| Bets::from_json(raw).ok()) | ||
| 240 | .unwrap_or_default() | ||
| 241 | } | ||
| 242 | |||
| 243 | fn cursor(state: &GameState) -> Cursor { | ||
| 244 | state | ||
| 245 | .data | ||
| 246 | .get("cursor") | ||
| 247 | .map(|raw| Cursor::parse(raw)) | ||
| 248 | .unwrap_or_default() | ||
| 249 | } | ||
| 250 | |||
| 251 | fn zero_cell(cursor: Cursor) -> String { | 236 | fn zero_cell(cursor: Cursor) -> String { |
| 252 | let cell = if cursor == Cursor::Zero { | 237 | let cell = if cursor == Cursor::Zero { |
| 253 | "[ 0 ]".to_string() | 238 | "[ 0 ]".to_string() |
| @@ -329,92 +314,82 @@ impl RouletteGame { | |||
| 329 | format!("Bets: {list} Total: {} Kr", bets.total()) | 314 | format!("Bets: {list} Total: {} Kr", bets.total()) |
| 330 | } | 315 | } |
| 331 | 316 | ||
| 332 | fn status_line(state: &GameState) -> String { | 317 | fn status_line(view: &RouletteView) -> String { |
| 333 | if let Some(error) = state.data.get("error") { | 318 | if let Some(error) = &view.error { |
| 334 | if !error.is_empty() { | 319 | return format!("ERROR: {error}"); |
| 335 | return format!("ERROR: {error}"); | ||
| 336 | } | ||
| 337 | } | 320 | } |
| 338 | 321 | ||
| 339 | match state.data.get("status").map(String::as_str) { | 322 | match view.phase { |
| 340 | Some("result") => { | 323 | RoulettePhase::Result => { |
| 341 | let number = state.data.get("number").map(String::as_str).unwrap_or("?"); | 324 | let number = view.number.unwrap_or(0); |
| 342 | let color = state | 325 | let color = view |
| 343 | .data | 326 | .color |
| 344 | .get("color") | 327 | .as_deref() |
| 345 | .map(|value| value.to_uppercase()) | 328 | .map(str::to_uppercase) |
| 346 | .unwrap_or_default(); | 329 | .unwrap_or_default(); |
| 347 | let parity = number | 330 | let parity = if is_even(number) { |
| 348 | .parse::<u8>() | 331 | " EVEN" |
| 349 | .ok() | 332 | } else if is_odd(number) { |
| 350 | .map(|number| { | 333 | " ODD" |
| 351 | if is_even(number) { | ||
| 352 | " EVEN" | ||
| 353 | } else if is_odd(number) { | ||
| 354 | " ODD" | ||
| 355 | } else { | ||
| 356 | "" | ||
| 357 | } | ||
| 358 | }) | ||
| 359 | .unwrap_or(""); | ||
| 360 | let winnings = state | ||
| 361 | .data | ||
| 362 | .get("winnings") | ||
| 363 | .map(String::as_str) | ||
| 364 | .unwrap_or("0"); | ||
| 365 | let stake = state.bet_amount; | ||
| 366 | if winnings == "0" { | ||
| 367 | format!("No win at {number} {color}{parity}") | ||
| 368 | } else { | 334 | } else { |
| 369 | let profit = winnings.parse::<u64>().unwrap_or(0).saturating_sub(stake); | 335 | "" |
| 370 | format!("Won {winnings} Kr at {number} {color}{parity} (+{profit})") | 336 | }; |
| 371 | } | 337 | match view.winnings.unwrap_or(0) { |
| 372 | } | 338 | 0 => format!("No win at {number} {color}{parity}"), |
| 373 | Some("spinning") => "Spinning...".to_string(), | 339 | winnings => { |
| 374 | _ => { | 340 | let profit = winnings.saturating_sub(view.bet_amount); |
| 375 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); | 341 | format!("Won {winnings} Kr at {number} {color}{parity} (+{profit})") |
| 376 | match Self::cursor(state).target() { | 342 | } |
| 377 | Some(target) => format!( | ||
| 378 | "Bet: {} · pays {} · ENTER places {} Kr", | ||
| 379 | target.label(), | ||
| 380 | target.return_label(), | ||
| 381 | chip | ||
| 382 | ), | ||
| 383 | None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | ||
| 384 | } | 343 | } |
| 385 | } | 344 | } |
| 345 | RoulettePhase::Spinning => "Spinning...".to_string(), | ||
| 346 | RoulettePhase::Placing => match view.cursor.target() { | ||
| 347 | Some(target) => format!( | ||
| 348 | "Bet: {} · pays {} · ENTER places {} Kr", | ||
| 349 | target.label(), | ||
| 350 | target.return_label(), | ||
| 351 | view.chip | ||
| 352 | ), | ||
| 353 | None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | ||
| 354 | }, | ||
| 386 | } | 355 | } |
| 387 | } | 356 | } |
| 388 | } | ||
| 389 | |||
| 390 | impl Game for RouletteGame { | ||
| 391 | fn get_name(&self) -> &'static str { | ||
| 392 | "Roulette" | ||
| 393 | } | ||
| 394 | 357 | ||
| 395 | fn render(&self, state: &GameState) -> String { | 358 | /// Render a roulette view to terminal lines. The GUI draws the same view |
| 396 | let bets = Self::bets(state); | 359 | /// with a painter instead. |
| 397 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); | 360 | pub fn render_view(view: &RouletteView) -> String { |
| 398 | let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); | 361 | let covered = view.cursor.numbers(); |
| 399 | let cursor = Self::cursor(state); | 362 | let balance = view |
| 400 | let covered = cursor.numbers(); | 363 | .balance |
| 364 | .map(|balance| balance.to_string()) | ||
| 365 | .unwrap_or_else(|| "?".to_string()); | ||
| 401 | 366 | ||
| 402 | let lines = [ | 367 | let lines = [ |
| 403 | "=== ROULETTE ===".to_string(), | 368 | "=== ROULETTE ===".to_string(), |
| 404 | format!("Balance: {balance} Kr Chip: {chip} Kr"), | 369 | format!("Balance: {balance} Kr Chip: {} Kr", view.chip), |
| 405 | Self::zero_cell(cursor), | 370 | Self::zero_cell(view.cursor), |
| 406 | Self::number_row(0, &covered), | 371 | Self::number_row(0, &covered), |
| 407 | Self::number_row(1, &covered), | 372 | Self::number_row(1, &covered), |
| 408 | Self::number_row(2, &covered), | 373 | Self::number_row(2, &covered), |
| 409 | Self::dozens_row(cursor), | 374 | Self::dozens_row(view.cursor), |
| 410 | Self::columns_row(cursor), | 375 | Self::columns_row(view.cursor), |
| 411 | Self::outside_row(cursor), | 376 | Self::outside_row(view.cursor), |
| 412 | Self::bets_line(&bets), | 377 | Self::bets_line(&view.bets), |
| 413 | Self::status_line(state), | 378 | Self::status_line(view), |
| 414 | "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit".to_string(), | 379 | "[ARROWS] Move [ENTER] Bet [C]lear [+/-] Chip [SPACE] Spin [Q] Quit".to_string(), |
| 415 | ]; | 380 | ]; |
| 416 | lines.join("\r\n") | 381 | lines.join("\r\n") |
| 417 | } | 382 | } |
| 383 | } | ||
| 384 | |||
| 385 | impl Game for RouletteGame { | ||
| 386 | fn get_name(&self) -> &'static str { | ||
| 387 | "Roulette" | ||
| 388 | } | ||
| 389 | |||
| 390 | fn render(&self, state: &GameState) -> String { | ||
| 391 | Self::render_view(&RouletteView::from_state(state)) | ||
| 392 | } | ||
| 418 | 393 | ||
| 419 | fn render_lines(&self, _state: &GameState) -> usize { | 394 | fn render_lines(&self, _state: &GameState) -> usize { |
| 420 | ROULETTE_BOARD_LINES | 395 | 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; | |||
| 2 | pub mod database; | 2 | pub mod database; |
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod paths; | 4 | pub mod paths; |
| 5 | pub mod tui; | ||
| 5 | pub mod ui; | 6 | pub mod ui; |
| 7 | 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 @@ | |||
| 1 | //! Text rendering for the terminal adapter. | ||
| 2 | //! | ||
| 3 | //! This is the TUI's half of the `View` boundary: the GUI renders the same | ||
| 4 | //! data with a painter instead of strings. | ||
| 5 | |||
| 6 | use crate::client::games::roulette::RouletteGame; | ||
| 7 | use crate::client::view::View; | ||
| 8 | |||
| 9 | /// Render any view to terminal lines. | ||
| 10 | pub fn render_view(view: &View) -> Vec<String> { | ||
| 11 | match view { | ||
| 12 | View::Roulette(roulette) => RouletteGame::render_view(roulette) | ||
| 13 | .lines() | ||
| 14 | .map(str::to_string) | ||
| 15 | .collect(), | ||
| 16 | View::Text(lines) => lines.clone(), | ||
| 17 | } | ||
| 18 | } | ||
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 @@ | |||
| 1 | //! What a frontend draws and what it can ask the core to do. | ||
| 2 | //! | ||
| 3 | //! The core owns state and navigation; adapters map input to `Command` and | ||
| 4 | //! render `View`. Terminal text lives in [`crate::client::tui`], not here. | ||
| 5 | |||
| 6 | use crate::client::games::roulette::{Cursor, Direction}; | ||
| 7 | use crate::client::games::traits::GameState; | ||
| 8 | use crate::roulette::{BetTarget, Bets}; | ||
| 9 | |||
| 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 11 | pub enum RoulettePhase { | ||
| 12 | Placing, | ||
| 13 | Spinning, | ||
| 14 | Result, | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 18 | pub struct RouletteView { | ||
| 19 | pub bet_amount: u64, | ||
| 20 | pub balance: Option<i64>, | ||
| 21 | pub chip: u64, | ||
| 22 | pub bets: Bets, | ||
| 23 | pub cursor: Cursor, | ||
| 24 | pub phase: RoulettePhase, | ||
| 25 | pub number: Option<u8>, | ||
| 26 | pub color: Option<String>, | ||
| 27 | pub winnings: Option<u64>, | ||
| 28 | pub error: Option<String>, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl RouletteView { | ||
| 32 | pub fn from_state(state: &GameState) -> Self { | ||
| 33 | let bets = state | ||
| 34 | .data | ||
| 35 | .get("bets") | ||
| 36 | .and_then(|raw| Bets::from_json(raw).ok()) | ||
| 37 | .unwrap_or_default(); | ||
| 38 | let chip = state | ||
| 39 | .data | ||
| 40 | .get("chip") | ||
| 41 | .and_then(|value| value.parse().ok()) | ||
| 42 | .unwrap_or(10); | ||
| 43 | let cursor = state | ||
| 44 | .data | ||
| 45 | .get("cursor") | ||
| 46 | .map(|raw| Cursor::parse(raw)) | ||
| 47 | .unwrap_or_default(); | ||
| 48 | let phase = match state.data.get("status").map(String::as_str) { | ||
| 49 | Some("spinning") => RoulettePhase::Spinning, | ||
| 50 | Some("result") => RoulettePhase::Result, | ||
| 51 | _ => RoulettePhase::Placing, | ||
| 52 | }; | ||
| 53 | let number = state.data.get("number").and_then(|value| value.parse().ok()); | ||
| 54 | let color = state.data.get("color").cloned(); | ||
| 55 | let winnings = state | ||
| 56 | .data | ||
| 57 | .get("winnings") | ||
| 58 | .and_then(|value| value.parse().ok()); | ||
| 59 | let balance = state | ||
| 60 | .data | ||
| 61 | .get("balance") | ||
| 62 | .and_then(|value| value.parse().ok()); | ||
| 63 | let error = state | ||
| 64 | .data | ||
| 65 | .get("error") | ||
| 66 | .cloned() | ||
| 67 | .filter(|message| !message.is_empty()); | ||
| 68 | |||
| 69 | Self { | ||
| 70 | bet_amount: state.bet_amount, | ||
| 71 | balance, | ||
| 72 | chip, | ||
| 73 | bets, | ||
| 74 | cursor, | ||
| 75 | phase, | ||
| 76 | number, | ||
| 77 | color, | ||
| 78 | winnings, | ||
| 79 | error, | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Everything a frontend can draw. `Text` is the bridge for games that have | ||
| 85 | /// not been ported to a structured view yet. | ||
| 86 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 87 | pub enum View { | ||
| 88 | Roulette(RouletteView), | ||
| 89 | Text(Vec<String>), | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Semantic input. Frontends turn keys and clicks into these. | ||
| 93 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 94 | pub enum Command { | ||
| 95 | MoveCursor(Direction), | ||
| 96 | PlaceBet, | ||
| 97 | PlaceBetAt(BetTarget), | ||
| 98 | ClearBets, | ||
| 99 | SetChip(u64), | ||
| 100 | AdjustChip(i64), | ||
| 101 | Spin, | ||
| 102 | Leave, | ||
| 103 | } | ||
diff --git a/tests/client_core.rs b/tests/client_core.rs index b0bba6f..695dcb1 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -10,7 +10,10 @@ use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | |||
| 10 | use zagan_bet::client::games::response::{ | 10 | use zagan_bet::client::games::response::{ |
| 11 | parse_blackjack_response, parse_crash_response, parse_spin_response, | 11 | parse_blackjack_response, parse_crash_response, parse_spin_response, |
| 12 | }; | 12 | }; |
| 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | ||
| 13 | use zagan_bet::client::ui::command_names; | 14 | use zagan_bet::client::ui::command_names; |
| 15 | use zagan_bet::client::view::{Command, RoulettePhase, View}; | ||
| 16 | use zagan_bet::roulette::BetTarget; | ||
| 14 | use zagan_bet::crash::CrashState; | 17 | use zagan_bet::crash::CrashState; |
| 15 | 18 | ||
| 16 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 19 | const WIN3: &str = "CCCCLLCLODGSDGS"; |
| @@ -36,7 +39,8 @@ impl Screen { | |||
| 36 | for effect in effects { | 39 | for effect in effects { |
| 37 | match effect { | 40 | match effect { |
| 38 | Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), | 41 | Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), |
| 39 | Effect::ReplaceRegion(lines) => { | 42 | Effect::Render(view) => { |
| 43 | let lines = zagan_bet::client::tui::render_view(view); | ||
| 40 | if let Some(start) = self.region_start { | 44 | if let Some(start) = self.region_start { |
| 41 | self.lines.truncate(start); | 45 | self.lines.truncate(start); |
| 42 | } else { | 46 | } else { |
| @@ -52,14 +56,15 @@ impl Screen { | |||
| 52 | self.region_start = None; | 56 | self.region_start = None; |
| 53 | self.region_len = 0; | 57 | self.region_len = 0; |
| 54 | } | 58 | } |
| 55 | Effect::AnimateRoulette { final_lines, .. } => { | 59 | Effect::AnimateRoulette { view, .. } => { |
| 60 | let lines = zagan_bet::client::tui::render_view(view); | ||
| 56 | if let Some(start) = self.region_start { | 61 | if let Some(start) = self.region_start { |
| 57 | self.lines.truncate(start); | 62 | self.lines.truncate(start); |
| 58 | } else { | 63 | } else { |
| 59 | self.region_start = Some(self.lines.len()); | 64 | self.region_start = Some(self.lines.len()); |
| 60 | } | 65 | } |
| 61 | self.lines.extend(final_lines.iter().cloned()); | 66 | self.lines.extend(lines.iter().cloned()); |
| 62 | self.region_len = final_lines.len(); | 67 | self.region_len = lines.len(); |
| 63 | } | 68 | } |
| 64 | Effect::SetPrompt { text, cursor_col } => { | 69 | Effect::SetPrompt { text, cursor_col } => { |
| 65 | self.prompt = text.clone(); | 70 | self.prompt = text.clone(); |
| @@ -1198,3 +1203,120 @@ fn history_rejects_a_bad_count_without_sending() { | |||
| 1198 | .iter() | 1203 | .iter() |
| 1199 | .any(|line| line.contains("Usage: history [1-50]"))); | 1204 | .any(|line| line.contains("Usage: history [1-50]"))); |
| 1200 | } | 1205 | } |
| 1206 | |||
| 1207 | #[test] | ||
| 1208 | fn the_roulette_view_reflects_the_cursor_and_bets() { | ||
| 1209 | let mut h = Harness::new(); | ||
| 1210 | h.enter_roulette(); | ||
| 1211 | |||
| 1212 | let View::Roulette(view) = h.core.view() else { | ||
| 1213 | panic!("expected a roulette view"); | ||
| 1214 | }; | ||
| 1215 | assert_eq!(view.cursor, Cursor::Lattice { x: 1, y: 5 }); | ||
| 1216 | assert_eq!( | ||
| 1217 | view.cursor.target(), | ||
| 1218 | Some(BetTarget::Straight { number: 1 }) | ||
| 1219 | ); | ||
| 1220 | assert!(view.bets.is_empty()); | ||
| 1221 | assert_eq!(view.chip, 10); | ||
| 1222 | assert_eq!(view.phase, RoulettePhase::Placing); | ||
| 1223 | |||
| 1224 | h.core.command(Command::MoveCursor(Direction::Up)); | ||
| 1225 | h.core.command(Command::MoveCursor(Direction::Up)); | ||
| 1226 | let effects = h.core.command(Command::PlaceBet); | ||
| 1227 | h.apply(effects); | ||
| 1228 | |||
| 1229 | let View::Roulette(view) = h.core.view() else { | ||
| 1230 | panic!("expected a roulette view"); | ||
| 1231 | }; | ||
| 1232 | assert_eq!( | ||
| 1233 | view.cursor.target(), | ||
| 1234 | Some(BetTarget::Straight { number: 2 }) | ||
| 1235 | ); | ||
| 1236 | assert_eq!(view.bets.total(), 10); | ||
| 1237 | assert_eq!( | ||
| 1238 | view.bets.amount_on(&BetTarget::Straight { number: 2 }), | ||
| 1239 | 10 | ||
| 1240 | ); | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | #[test] | ||
| 1244 | fn clicking_a_bet_target_does_not_move_the_cursor() { | ||
| 1245 | let mut h = Harness::new(); | ||
| 1246 | h.enter_roulette(); | ||
| 1247 | |||
| 1248 | let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); | ||
| 1249 | h.apply(effects); | ||
| 1250 | |||
| 1251 | let View::Roulette(view) = h.core.view() else { | ||
| 1252 | panic!("expected a roulette view"); | ||
| 1253 | }; | ||
| 1254 | assert_eq!(view.bets.amount_on(&BetTarget::Split { a: 1, b: 4 }), 10); | ||
| 1255 | assert_eq!( | ||
| 1256 | view.cursor.target(), | ||
| 1257 | Some(BetTarget::Straight { number: 1 }), | ||
| 1258 | "a click places a chip without dragging the keyboard cursor" | ||
| 1259 | ); | ||
| 1260 | } | ||
| 1261 | |||
| 1262 | #[test] | ||
| 1263 | fn the_roulette_view_reports_the_result() { | ||
| 1264 | let mut h = Harness::new(); | ||
| 1265 | h.enter_roulette(); | ||
| 1266 | h.core.command(Command::PlaceBet); | ||
| 1267 | |||
| 1268 | h.key(Key::Char(' ')); | ||
| 1269 | let bets = r#"{"chips":[{"target":{"bet":"straight","number":1},"amount":10}]}"#; | ||
| 1270 | h.reply(&format!("ROULETTE_RESULT s1 360 10 1350 1 red {bets}")); | ||
| 1271 | |||
| 1272 | let View::Roulette(view) = h.core.view() else { | ||
| 1273 | panic!("expected a roulette view"); | ||
| 1274 | }; | ||
| 1275 | assert_eq!(view.phase, RoulettePhase::Result); | ||
| 1276 | assert_eq!(view.number, Some(1)); | ||
| 1277 | assert_eq!(view.color.as_deref(), Some("red")); | ||
| 1278 | assert_eq!(view.winnings, Some(360)); | ||
| 1279 | assert_eq!(view.balance, Some(1350)); | ||
| 1280 | assert!(view.error.is_none()); | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | #[test] | ||
| 1284 | fn the_chip_size_is_a_command() { | ||
| 1285 | let mut h = Harness::new(); | ||
| 1286 | h.enter_roulette(); | ||
| 1287 | |||
| 1288 | h.core.command(Command::AdjustChip(10)); | ||
| 1289 | let View::Roulette(view) = h.core.view() else { | ||
| 1290 | panic!("expected a roulette view"); | ||
| 1291 | }; | ||
| 1292 | assert_eq!(view.chip, 20); | ||
| 1293 | |||
| 1294 | h.core.command(Command::SetChip(5)); | ||
| 1295 | let View::Roulette(view) = h.core.view() else { | ||
| 1296 | panic!("expected a roulette view"); | ||
| 1297 | }; | ||
| 1298 | assert_eq!(view.chip, 10, "the chip never goes below 10"); | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | #[test] | ||
| 1302 | fn unported_games_still_reach_the_frontend_as_text() { | ||
| 1303 | let mut h = Harness::new(); | ||
| 1304 | h.enter_slots("10"); | ||
| 1305 | |||
| 1306 | match h.core.view() { | ||
| 1307 | View::Text(lines) => assert_eq!(lines.len(), 10), | ||
| 1308 | other => panic!("expected the text bridge, got {other:?}"), | ||
| 1309 | } | ||
| 1310 | } | ||
| 1311 | |||
| 1312 | #[test] | ||
| 1313 | fn leaving_through_a_command_clears_the_screen() { | ||
| 1314 | let mut h = Harness::new(); | ||
| 1315 | h.enter_roulette(); | ||
| 1316 | |||
| 1317 | let effects = h.core.command(Command::Leave); | ||
| 1318 | h.apply(effects); | ||
| 1319 | |||
| 1320 | assert!(!h.core.is_in_game()); | ||
| 1321 | assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); | ||
| 1322 | } | ||
