summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 17:58:03 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 17:58:03 +0100
commit46202ee2e398ffd49febe6684f5c6a8648da600d (patch)
tree00631532b6b4805ddc8a728246787651dd7185f2
parentdocs: document DOWNLOAD_URL and the XDG reset path (diff)
downloadzagan-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.toml2
-rw-r--r--docs/GUI-PLAN.md5
-rw-r--r--src/bin/client/repl.rs13
-rw-r--r--src/client/core.rs109
-rw-r--r--src/client/games/roulette.rs141
-rw-r--r--src/client/mod.rs2
-rw-r--r--src/client/tui.rs18
-rw-r--r--src/client/view.rs103
-rw-r--r--tests/client_core.rs130
9 files changed, 380 insertions, 143 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 7a16747..0a46623 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.0" 3version = "4.0.1"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "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
30a `RouletteView`, frontends send `Command`, and the TUI renders the view.
31Slots, blackjack and crash still travel through the `View::Text` bridge until
32Phase 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};
6use crate::client::games::roulette::Direction; 6use crate::client::games::roulette::Direction;
7use crate::client::view::{Command, RouletteView, View};
7use crate::client::ui::{ 8use 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};
11use crate::crash::{CrashState, multiplier_at}; 12use crate::crash::{CrashState, multiplier_at};
12use crate::history::MAX_HISTORY; 13use crate::history::MAX_HISTORY;
13use crate::roulette::Bets; 14use crate::roulette::{BetTarget, Bets};
14use std::time::{Duration, Instant}; 15use 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 {
41pub enum Effect { 42pub 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 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::{RoulettePhase, RouletteView};
2use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; 3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd};
3 4
4pub const ROULETTE_BOARD_LINES: usize = 12; 5pub 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
390impl 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
385impl 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;
2pub mod database; 2pub mod database;
3pub mod games; 3pub mod games;
4pub mod paths; 4pub mod paths;
5pub mod tui;
5pub mod ui; 6pub mod ui;
7pub 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
6use crate::client::games::roulette::RouletteGame;
7use crate::client::view::View;
8
9/// Render any view to terminal lines.
10pub 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
6use crate::client::games::roulette::{Cursor, Direction};
7use crate::client::games::traits::GameState;
8use crate::roulette::{BetTarget, Bets};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum RoulettePhase {
12 Placing,
13 Spinning,
14 Result,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub 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
31impl 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)]
87pub 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)]
94pub 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};
10use zagan_bet::client::games::response::{ 10use 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};
13use zagan_bet::client::games::roulette::{Cursor, Direction};
13use zagan_bet::client::ui::command_names; 14use zagan_bet::client::ui::command_names;
15use zagan_bet::client::view::{Command, RoulettePhase, View};
16use zagan_bet::roulette::BetTarget;
14use zagan_bet::crash::CrashState; 17use zagan_bet::crash::CrashState;
15 18
16const WIN3: &str = "CCCCLLCLODGSDGS"; 19const 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]
1208fn 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]
1244fn 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]
1263fn 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]
1284fn 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]
1302fn 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]
1313fn 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}