summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 18:03:24 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 18:03:24 +0100
commit37dedc342c087506e502c78a952b45b1c91602fb (patch)
tree85f4bf0c17823ba670d2d49c921191ca27aca64b
parentv4.0.1: introduce Command/View and port roulette to the shared boundary (diff)
downloadzagan-bet-37dedc342c087506e502c78a952b45b1c91602fb.tar.gz
zagan-bet-37dedc342c087506e502c78a952b45b1c91602fb.zip
v4.0.2: port slots, blackjack and crash to structured views
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md8
-rw-r--r--src/client/core.rs232
-rw-r--r--src/client/games/blackjack.rs59
-rw-r--r--src/client/games/crash.rs67
-rw-r--r--src/client/games/slots.rs125
-rw-r--r--src/client/tui.rs17
-rw-r--r--src/client/view.rs182
-rw-r--r--tests/client_core.rs111
9 files changed, 540 insertions, 263 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 0a46623..fce7a90 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.1" 3version = "4.0.2"
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 f6cf499..83bab1c 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -26,10 +26,10 @@ 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 29**In progress.** `Command`/`View` exist and all four games are ported: the
30a `RouletteView`, frontends send `Command`, and the TUI renders the view. 30core builds a typed view, frontends send `Command`, and the TUI renders it.
31Slots, blackjack and crash still travel through the `View::Text` bridge until 31Remaining for this phase: the app screens (`Connect`, `Trust`, `Login`,
32Phase 2. 32`Lobby`, `History`) and removing `Key` from the core.
33 33
34- Introduce `Command` and `View`; move terminal-only concerns (prompt editing, 34- Introduce `Command` and `View`; move terminal-only concerns (prompt editing,
35 wheel animation, interactive prompts) into the TUI adapter. 35 wheel animation, interactive prompts) into the TUI adapter.
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::{
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::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View};
8use crate::client::ui::{ 8use crate::client::ui::{
9 command_names, completion_lines, display_width, help_lines, history_lines, 9 command_names, completion_lines, display_width, help_lines, history_lines,
10 input_cursor_column, parse_bet_arg, record_history, 10 input_cursor_column, parse_bet_arg, record_history,
@@ -38,7 +38,7 @@ pub enum InteractiveCommand {
38 38
39/// High-level actions produced by the client core. The terminal adapter is 39/// High-level actions produced by the client core. The terminal adapter is
40/// responsible for turning these into crossterm calls and network writes. 40/// responsible for turning these into crossterm calls and network writes.
41#[derive(Debug, Clone, PartialEq, Eq)] 41#[derive(Debug, Clone, PartialEq)]
42pub enum Effect { 42pub enum Effect {
43 /// Append lines to the scrolling transcript. 43 /// Append lines to the scrolling transcript.
44 Print(Vec<String>), 44 Print(Vec<String>),
@@ -213,7 +213,9 @@ impl ClientCore {
213 self.balance = Some(result.balance); 213 self.balance = Some(result.balance);
214 let winning = result.number; 214 let winning = result.number;
215 self.game.apply_roulette_result(result); 215 self.game.apply_roulette_result(result);
216 let view = self.view(); 216 let Some(view) = self.view() else {
217 return Vec::new();
218 };
217 vec![Effect::AnimateRoulette { winning, view }] 219 vec![Effect::AnimateRoulette { winning, view }]
218 } 220 }
219 Err(message) => { 221 Err(message) => {
@@ -340,22 +342,10 @@ impl ClientCore {
340 342
341 match key { 343 match key {
342 Key::CtrlC => vec![Effect::Quit], 344 Key::CtrlC => vec![Effect::Quit],
343 Key::Esc | Key::Char('q') => { 345 Key::Esc | Key::Char('q') => self.command(Command::Leave),
344 self.game.end_game(); 346 Key::Char(' ') => self.command(Command::Spin),
345 vec![ 347 Key::Char('+') | Key::Char('=') => self.command(Command::AdjustBet(10)),
346 Effect::ClearRegion, 348 Key::Char('-') => self.command(Command::AdjustBet(-10)),
347 Effect::Print(vec!["Game exited.".to_string()]),
348 ]
349 }
350 Key::Char(' ') => self.spin(),
351 Key::Char('+') | Key::Char('=') => {
352 let _ = self.game.adjust_bet(10);
353 self.board_effects()
354 }
355 Key::Char('-') => {
356 let _ = self.game.adjust_bet(-10);
357 self.board_effects()
358 }
359 _ => Vec::new(), 349 _ => Vec::new(),
360 } 350 }
361 } 351 }
@@ -393,7 +383,46 @@ impl ClientCore {
393 383
394 /// Apply a semantic command. Both frontends drive the core through this. 384 /// Apply a semantic command. Both frontends drive the core through this.
395 pub fn command(&mut self, command: Command) -> Vec<Effect> { 385 pub fn command(&mut self, command: Command) -> Vec<Effect> {
386 let game = self.current_game_type().map(str::to_string);
387
396 match command { 388 match command {
389 Command::SetBet(bet) => {
390 if game.as_deref() == Some("roulette") {
391 return self.command(Command::SetChip(bet));
392 }
393 if self.game.set_bet(bet).is_ok() {
394 self.board_effects()
395 } else {
396 Vec::new()
397 }
398 }
399 Command::AdjustBet(delta) => {
400 if game.as_deref() == Some("roulette") {
401 return self.command(Command::AdjustChip(delta));
402 }
403 if self.game.adjust_bet(delta).is_ok() {
404 self.board_effects()
405 } else {
406 Vec::new()
407 }
408 }
409 Command::Leave => self.leave_current_game(),
410 Command::Spin => match game.as_deref() {
411 Some("slots") => self.spin(),
412 Some("roulette") => self.spin_roulette(),
413 _ => Vec::new(),
414 },
415 Command::Deal => self.start_blackjack(),
416 Command::Hit => self.blackjack_action("hit"),
417 Command::Stand => self.blackjack_action("stand"),
418 Command::Double => self.blackjack_action("double"),
419 Command::Start => self.start_crash(),
420 Command::CashOut => self.cash_out_crash(),
421 Command::Forfeit => match game.as_deref() {
422 Some("blackjack") => self.forfeit_blackjack(),
423 Some("crash") => self.forfeit_crash(),
424 _ => Vec::new(),
425 },
397 Command::MoveCursor(direction) => { 426 Command::MoveCursor(direction) => {
398 let cursor = self.game.roulette_cursor().moved(direction); 427 let cursor = self.game.roulette_cursor().moved(direction);
399 self.game.set_roulette_cursor(&cursor); 428 self.game.set_roulette_cursor(&cursor);
@@ -418,16 +447,67 @@ impl ClientCore {
418 self.game.set_roulette_chip(chip); 447 self.game.set_roulette_chip(chip);
419 self.board_effects() 448 self.board_effects()
420 } 449 }
421 Command::Spin => self.spin_roulette(), 450 }
422 Command::Leave => { 451 }
423 self.crash_started = None; 452
424 self.game.end_game(); 453 fn current_game_type(&self) -> Option<&str> {
425 vec![ 454 self.game
426 Effect::ClearRegion, 455 .get_current_state()
427 Effect::Print(vec!["Game exited.".to_string()]), 456 .map(|state| state.game_type.as_str())
428 ] 457 }
458
459 fn leave_current_game(&mut self) -> Vec<Effect> {
460 match self.current_game_type().map(str::to_string).as_deref() {
461 Some("blackjack") => {
462 if self
463 .game
464 .blackjack_state()
465 .is_some_and(|hand| hand.phase == "player")
466 {
467 return self.forfeit_blackjack();
468 }
429 } 469 }
470 Some("crash") => {
471 if self.game.crash_status() == "running" {
472 return self.forfeit_crash();
473 }
474 }
475 _ => {}
430 } 476 }
477 self.exit_game()
478 }
479
480 fn exit_game(&mut self) -> Vec<Effect> {
481 self.crash_started = None;
482 self.game.end_game();
483 vec![
484 Effect::ClearRegion,
485 Effect::Print(vec!["Game exited.".to_string()]),
486 ]
487 }
488
489 fn forfeit_blackjack(&mut self) -> Vec<Effect> {
490 let Some(hand) = self
491 .game
492 .blackjack_state()
493 .filter(|hand| hand.phase == "player")
494 else {
495 return self.exit_game();
496 };
497 self.game.set_blackjack_status("forfeiting");
498 self.pending = Some(Pending::BlackjackForfeit);
499 vec![Effect::SendLine(format!(
500 "BLACKJACK_ACTION {} forfeit",
501 hand.session_id
502 ))]
503 }
504
505 fn forfeit_crash(&mut self) -> Vec<Effect> {
506 let Some(session) = self.game.crash_session_id() else {
507 return self.exit_game();
508 };
509 self.pending = Some(Pending::CrashForfeit);
510 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
431 } 511 }
432 512
433 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { 513 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> {
@@ -482,46 +562,23 @@ impl ClientCore {
482 .cloned() 562 .cloned()
483 .unwrap_or_else(|| "ready".to_string()); 563 .unwrap_or_else(|| "ready".to_string());
484 let hand = self.game.blackjack_state(); 564 let hand = self.game.blackjack_state();
565 let player = hand.as_ref().is_some_and(|hand| hand.phase == "player");
566 let can_double = hand.as_ref().is_some_and(|hand| {
567 hand.phase == "player" && hand.actions.iter().any(|action| action == "double")
568 });
485 569
486 match key { 570 match key {
487 Key::CtrlC => vec![Effect::Quit], 571 Key::CtrlC => vec![Effect::Quit],
488 Key::Char('q') => { 572 Key::Char('q') => self.command(Command::Leave),
489 if let Some(hand) = hand.filter(|hand| hand.phase == "player") { 573 Key::Char(' ') => self.command(Command::Deal),
490 self.game.set_blackjack_status("forfeiting"); 574 Key::Char('h') if player => self.command(Command::Hit),
491 self.pending = Some(Pending::BlackjackForfeit); 575 Key::Char('s') if player => self.command(Command::Stand),
492 vec![Effect::SendLine(format!( 576 Key::Char('d') if can_double => self.command(Command::Double),
493 "BLACKJACK_ACTION {} forfeit",
494 hand.session_id
495 ))]
496 } else {
497 self.game.end_game();
498 vec![
499 Effect::ClearRegion,
500 Effect::Print(vec!["Game exited.".to_string()]),
501 ]
502 }
503 }
504 Key::Char(' ') => self.start_blackjack(),
505 Key::Char('h') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => {
506 self.blackjack_action("hit")
507 }
508 Key::Char('s') if hand.as_ref().is_some_and(|hand| hand.phase == "player") => {
509 self.blackjack_action("stand")
510 }
511 Key::Char('d')
512 if hand.as_ref().is_some_and(|hand| {
513 hand.phase == "player" && hand.actions.iter().any(|action| action == "double")
514 }) =>
515 {
516 self.blackjack_action("double")
517 }
518 Key::Char('+') | Key::Char('=') if matches!(status.as_str(), "ready" | "done") => { 577 Key::Char('+') | Key::Char('=') if matches!(status.as_str(), "ready" | "done") => {
519 let _ = self.game.adjust_bet(10); 578 self.command(Command::AdjustBet(10))
520 self.board_effects()
521 } 579 }
522 Key::Char('-') if matches!(status.as_str(), "ready" | "done") => { 580 Key::Char('-') if matches!(status.as_str(), "ready" | "done") => {
523 let _ = self.game.adjust_bet(-10); 581 self.command(Command::AdjustBet(-10))
524 self.board_effects()
525 } 582 }
526 _ => Vec::new(), 583 _ => Vec::new(),
527 } 584 }
@@ -643,32 +700,15 @@ impl ClientCore {
643 700
644 match key { 701 match key {
645 Key::CtrlC => vec![Effect::Quit], 702 Key::CtrlC => vec![Effect::Quit],
646 Key::Char('q') => { 703 Key::Char('q') => self.command(Command::Leave),
647 if running { 704 Key::Char(' ') if !running && self.pending.is_none() => self.command(Command::Start),
648 let Some(session) = self.game.crash_session_id() else { 705 Key::Char('c') if running && self.pending.is_none() => {
649 return Vec::new(); 706 self.command(Command::CashOut)
650 };
651 self.pending = Some(Pending::CrashForfeit);
652 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
653 } else {
654 self.crash_started = None;
655 self.game.end_game();
656 vec![
657 Effect::ClearRegion,
658 Effect::Print(vec!["Game exited.".to_string()]),
659 ]
660 }
661 } 707 }
662 Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(),
663 Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(),
664 Key::Char('+') | Key::Char('=') if !running => { 708 Key::Char('+') | Key::Char('=') if !running => {
665 let _ = self.game.adjust_bet(10); 709 self.command(Command::AdjustBet(10))
666 self.board_effects()
667 }
668 Key::Char('-') if !running => {
669 let _ = self.game.adjust_bet(-10);
670 self.board_effects()
671 } 710 }
711 Key::Char('-') if !running => self.command(Command::AdjustBet(-10)),
672 _ => Vec::new(), 712 _ => Vec::new(),
673 } 713 }
674 } 714 }
@@ -948,24 +988,20 @@ impl ClientCore {
948 effects 988 effects
949 } 989 }
950 990
951 fn board_lines(&self) -> Option<Vec<String>> {
952 let game = self.game.get_current_game()?;
953 let state = self.game.get_current_state()?;
954 Some(game.render(state).lines().map(str::to_string).collect())
955 }
956
957 /// Snapshot of the current screen for a frontend to draw. 991 /// Snapshot of the current screen for a frontend to draw.
958 pub fn view(&self) -> View { 992 pub fn view(&self) -> Option<View> {
959 if let Some(state) = self.game.get_current_state() { 993 let state = self.game.get_current_state()?;
960 if state.game_type == "roulette" { 994 Some(match state.game_type.as_str() {
961 return View::Roulette(RouletteView::from_state(state)); 995 "slots" => View::Slots(SlotsView::from_state(state)),
962 } 996 "roulette" => View::Roulette(RouletteView::from_state(state)),
963 } 997 "blackjack" => View::Blackjack(BlackjackView::from_state(state)),
964 View::Text(self.board_lines().unwrap_or_default()) 998 "crash" => View::Crash(CrashView::from_state(state)),
999 _ => return None,
1000 })
965 } 1001 }
966 1002
967 fn board_effects(&self) -> Vec<Effect> { 1003 fn board_effects(&self) -> Vec<Effect> {
968 vec![Effect::Render(self.view())] 1004 self.view().map(Effect::Render).into_iter().collect()
969 } 1005 }
970 1006
971 fn prompt_effects(&self) -> Vec<Effect> { 1007 fn prompt_effects(&self) -> Vec<Effect> {
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 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::blackjack::{BlackjackState, card_label}; 2use crate::blackjack::card_label;
3use crate::client::view::BlackjackView;
3 4
4pub const BLACKJACK_BOARD_LINES: usize = 6; 5pub const BLACKJACK_BOARD_LINES: usize = 6;
5 6
@@ -10,13 +11,6 @@ impl BlackjackGame {
10 Self 11 Self
11 } 12 }
12 13
13 fn state(state: &GameState) -> Option<BlackjackState> {
14 state
15 .data
16 .get("blackjack")
17 .and_then(|raw| serde_json::from_str(raw).ok())
18 }
19
20 fn cards(cards: &[String]) -> String { 14 fn cards(cards: &[String]) -> String {
21 cards 15 cards
22 .iter() 16 .iter()
@@ -43,40 +37,29 @@ impl BlackjackGame {
43 _ => "Hand complete.".to_string(), 37 _ => "Hand complete.".to_string(),
44 } 38 }
45 } 39 }
46}
47 40
48impl Game for BlackjackGame { 41 /// Render a blackjack view to terminal lines. The GUI paints the same view.
49 fn get_name(&self) -> &'static str { 42 pub fn render_view(view: &BlackjackView) -> String {
50 "Blackjack" 43 let balance = view
51 } 44 .balance
52 45 .map(|balance| balance.to_string())
53 fn render(&self, state: &GameState) -> String { 46 .unwrap_or_else(|| "?".to_string());
54 let status = state
55 .data
56 .get("status")
57 .map(String::as_str)
58 .unwrap_or("ready");
59 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?");
60 let hand = Self::state(state);
61 47
62 if let Some(error) = state.data.get("error") { 48 if let Some(error) = &view.error {
63 let bet = hand 49 let bet = view.hand.as_ref().map(|hand| hand.bet).unwrap_or(view.bet);
64 .as_ref()
65 .map(|hand| hand.bet)
66 .unwrap_or(state.bet_amount);
67 return format!( 50 return format!(
68 "=== BLACKJACK ===\r\nBalance: {balance} Kr Bet: {bet} Kr\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit" 51 "=== BLACKJACK ===\r\nBalance: {balance} Kr Bet: {bet} Kr\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit"
69 ); 52 );
70 } 53 }
71 54
72 let (dealer, dealer_total, player, player_total, bet, text, controls) = 55 let (dealer, dealer_total, player, player_total, bet, text, controls) =
73 if let Some(hand) = &hand { 56 if let Some(hand) = &view.hand {
74 let bet = if status == "player" { 57 let bet = if view.status == "player" {
75 hand.bet 58 hand.bet
76 } else { 59 } else {
77 state.bet_amount 60 view.bet
78 }; 61 };
79 let text = match status { 62 let text = match view.status.as_str() {
80 "player" => { 63 "player" => {
81 if hand.actions.iter().any(|action| action == "double") { 64 if hand.actions.iter().any(|action| action == "double") {
82 "Hit, stand or double.".to_string() 65 "Hit, stand or double.".to_string()
@@ -89,7 +72,7 @@ impl Game for BlackjackGame {
89 "done" => Self::result_text(&hand.result, hand.winnings), 72 "done" => Self::result_text(&hand.result, hand.winnings),
90 _ => "Press [SPACE] to deal.".to_string(), 73 _ => "Press [SPACE] to deal.".to_string(),
91 }; 74 };
92 let controls = if status == "player" { 75 let controls = if view.status == "player" {
93 if hand.actions.iter().any(|action| action == "double") { 76 if hand.actions.iter().any(|action| action == "double") {
94 "[H]it [S]tand [D]ouble [Q] Forfeit".to_string() 77 "[H]it [S]tand [D]ouble [Q] Forfeit".to_string()
95 } else { 78 } else {
@@ -113,7 +96,7 @@ impl Game for BlackjackGame {
113 0, 96 0,
114 "—".to_string(), 97 "—".to_string(),
115 0, 98 0,
116 state.bet_amount, 99 view.bet,
117 "Press [SPACE] to deal.".to_string(), 100 "Press [SPACE] to deal.".to_string(),
118 "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(), 101 "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(),
119 ) 102 )
@@ -124,6 +107,16 @@ impl Game for BlackjackGame {
124 balance, bet, dealer, dealer_total, player, player_total, text, controls 107 balance, bet, dealer, dealer_total, player, player_total, text, controls
125 ) 108 )
126 } 109 }
110}
111
112impl Game for BlackjackGame {
113 fn get_name(&self) -> &'static str {
114 "Blackjack"
115 }
116
117 fn render(&self, state: &GameState) -> String {
118 Self::render_view(&BlackjackView::from_state(state))
119 }
127 120
128 fn render_lines(&self, _state: &GameState) -> usize { 121 fn render_lines(&self, _state: &GameState) -> usize {
129 BLACKJACK_BOARD_LINES 122 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 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::CrashView;
2 3
3pub const CRASH_BOARD_LINES: usize = 6; 4pub const CRASH_BOARD_LINES: usize = 6;
4 5
@@ -9,67 +10,67 @@ impl CrashGame {
9 Self 10 Self
10 } 11 }
11 12
12 fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> { 13 fn status_text(view: &CrashView) -> String {
13 state.data.get(key).map(String::as_str) 14 match view.status.as_str() {
14 }
15
16 fn status_text(state: &GameState) -> String {
17 let status = Self::value(state, "status").unwrap_or("ready");
18 match status {
19 "starting" => "Starting...".to_string(), 15 "starting" => "Starting...".to_string(),
20 "running" => "Rising... cash out before it crashes.".to_string(), 16 "running" => "Rising... cash out before it crashes.".to_string(),
21 "cashed" => format!( 17 "cashed" => format!(
22 "Cashed out at {}x Won {} Kr", 18 "Cashed out at {:.2}x Won {} Kr",
23 Self::value(state, "multiplier").unwrap_or("1.00"), 19 view.multiplier, view.payout
24 Self::value(state, "payout").unwrap_or("0")
25 ), 20 ),
26 "crashed" => format!( 21 "crashed" => format!(
27 "CRASHED at {}x Bet lost", 22 "CRASHED at {}x Bet lost",
28 Self::value(state, "crash_point").unwrap_or("?") 23 view.crash_point
24 .map(|point| format!("{point:.2}"))
25 .unwrap_or_else(|| "?".to_string())
29 ), 26 ),
30 "forfeit" => "Forfeited. Bet lost.".to_string(), 27 "forfeit" => "Forfeited. Bet lost.".to_string(),
31 _ => "Press [SPACE] to start.".to_string(), 28 _ => "Press [SPACE] to start.".to_string(),
32 } 29 }
33 } 30 }
34 31
35 fn controls(state: &GameState) -> &'static str { 32 fn controls(view: &CrashView) -> &'static str {
36 if Self::value(state, "status") == Some("running") { 33 if view.status == "running" {
37 "[C] Cash out [Q] Forfeit" 34 "[C] Cash out [Q] Forfeit"
38 } else { 35 } else {
39 "[SPACE] Play [+/=/-] Bet [Q] Quit" 36 "[SPACE] Play [+/=/-] Bet [Q] Quit"
40 } 37 }
41 } 38 }
42}
43 39
44impl Game for CrashGame { 40 /// Render a crash view to terminal lines. The GUI paints the same view.
45 fn get_name(&self) -> &'static str { 41 pub fn render_view(view: &CrashView) -> String {
46 "Crash" 42 let balance = view
47 } 43 .balance
48 44 .map(|balance| balance.to_string())
49 fn render(&self, state: &GameState) -> String { 45 .unwrap_or_else(|| "?".to_string());
50 let balance = Self::value(state, "balance").unwrap_or("?");
51 let multiplier = Self::value(state, "multiplier").unwrap_or("1.00");
52 let potential = Self::value(state, "potential")
53 .map(str::to_string)
54 .unwrap_or_else(|| state.bet_amount.to_string());
55 46
56 if let Some(error) = Self::value(state, "error") { 47 if let Some(error) = &view.error {
57 return format!( 48 return format!(
58 "=== CRASH ===\r\nBalance: {balance} Kr Bet: {} Kr\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit", 49 "=== CRASH ===\r\nBalance: {balance} Kr Bet: {} Kr\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit",
59 state.bet_amount 50 view.bet
60 ); 51 );
61 } 52 }
62 53
63 format!( 54 format!(
64 "=== CRASH ===\r\nBalance: {} Kr Bet: {} Kr\r\nMultiplier: {}x\r\nPotential: {} Kr\r\n{}\r\n{}", 55 "=== CRASH ===\r\nBalance: {} Kr Bet: {} Kr\r\nMultiplier: {:.2}x\r\nPotential: {} Kr\r\n{}\r\n{}",
65 balance, 56 balance,
66 state.bet_amount, 57 view.bet,
67 multiplier, 58 view.multiplier,
68 potential, 59 view.potential,
69 Self::status_text(state), 60 Self::status_text(view),
70 Self::controls(state) 61 Self::controls(view)
71 ) 62 )
72 } 63 }
64}
65
66impl Game for CrashGame {
67 fn get_name(&self) -> &'static str {
68 "Crash"
69 }
70
71 fn render(&self, state: &GameState) -> String {
72 Self::render_view(&CrashView::from_state(state))
73 }
73 74
74 fn render_lines(&self, _state: &GameState) -> usize { 75 fn render_lines(&self, _state: &GameState) -> usize {
75 CRASH_BOARD_LINES 76 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 @@
1use super::traits::{Game, GamePhase, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::{SlotsPhase, SlotsView};
2use crate::slots::{LINE_NAMES, REELS, ROWS, paytable_lines}; 3use crate::slots::{LINE_NAMES, REELS, ROWS, paytable_lines};
3 4
4/// Height of the game board. It must never depend on the spin result. 5/// Height of the game board. It must never depend on the spin result.
@@ -28,105 +29,91 @@ impl SlotsGame {
28 } 29 }
29 } 30 }
30 31
31 fn highlighted_cells(state: &GameState) -> Vec<usize> { 32 fn render_grid(reels: &[char], winning: &[usize]) -> [String; ROWS] {
32 state
33 .data
34 .get("win_cells")
35 .map(|raw| {
36 raw.split(',')
37 .filter_map(|value| value.trim().parse::<usize>().ok())
38 .collect()
39 })
40 .unwrap_or_default()
41 }
42
43 fn render_grid(&self, reels: &str, winning: &[usize]) -> [String; ROWS] {
44 let chars: Vec<char> = reels.chars().collect();
45 std::array::from_fn(|row| { 33 std::array::from_fn(|row| {
46 (0..REELS) 34 (0..REELS)
47 .map(|col| Self::cell(&chars, row, col, winning)) 35 .map(|col| Self::cell(reels, row, col, winning))
48 .collect::<Vec<_>>() 36 .collect::<Vec<_>>()
49 .join(" ") 37 .join(" ")
50 }) 38 })
51 } 39 }
52 40
53 fn render_status(&self, state: &GameState) -> String { 41 fn render_status(view: &SlotsView) -> String {
54 if let Some(error) = state.data.get("error") { 42 if let Some(error) = &view.error {
55 return format!("ERROR: {error}"); 43 return format!("ERROR: {error}");
56 } 44 }
57 if state.phase == GamePhase::Playing { 45
58 return "Spinning...".to_string(); 46 match view.phase {
59 } 47 SlotsPhase::Spinning => "Spinning...".to_string(),
60 if state.phase == GamePhase::Finished { 48 SlotsPhase::Result => {
61 let winnings = state 49 let winnings = view.winnings.unwrap_or(0);
62 .data 50 if winnings == 0 {
63 .get("winnings") 51 return "No win.".to_string();
64 .map(String::as_str) 52 }
65 .unwrap_or("0"); 53 match view.win_type.as_str() {
66 if winnings != "0" { 54 "mega" => format!("MEGA WIN! {winnings} Kr"),
67 return match state.data.get("win_type").map(String::as_str) { 55 "big" => format!("BIG WIN! {winnings} Kr"),
68 Some("mega") => format!("MEGA WIN! {winnings} Kr"),
69 Some("big") => format!("BIG WIN! {winnings} Kr"),
70 _ => format!("Won {winnings} Kr"), 56 _ => format!("Won {winnings} Kr"),
71 }; 57 }
72 } 58 }
73 return "No win.".to_string(); 59 SlotsPhase::Ready => "Press [SPACE] to spin!".to_string(),
74 } 60 }
75 "Press [SPACE] to spin!".to_string()
76 } 61 }
77 62
78 fn render_win_lines(&self, state: &GameState) -> String { 63 fn render_win_lines(view: &SlotsView) -> String {
79 if state.phase != GamePhase::Finished { 64 if view.phase != SlotsPhase::Result || view.win_lines.is_empty() {
80 return String::new(); 65 return String::new();
81 } 66 }
82 match state.data.get("win_lines").map(String::as_str) {
83 Some(lines) if !lines.is_empty() && lines != "-" => {
84 let named: Vec<String> = lines
85 .split(',')
86 .filter_map(|raw| raw.trim().parse::<usize>().ok())
87 .map(|line| {
88 let name = line
89 .checked_sub(1)
90 .and_then(|index| LINE_NAMES.get(index))
91 .copied();
92 match name {
93 Some(name) => format!("{line} ({name})"),
94 None => line.to_string(),
95 }
96 })
97 .collect();
98 format!("Winning lines: {}", named.join(", "))
99 }
100 _ => String::new(),
101 }
102 }
103}
104 67
105impl Game for SlotsGame { 68 let named: Vec<String> = view
106 fn get_name(&self) -> &'static str { 69 .win_lines
107 "Slots" 70 .iter()
71 .map(|line| {
72 let name = line
73 .checked_sub(1)
74 .and_then(|index| LINE_NAMES.get(index))
75 .copied();
76 match name {
77 Some(name) => format!("{line} ({name})"),
78 None => line.to_string(),
79 }
80 })
81 .collect();
82 format!("Winning lines: {}", named.join(", "))
108 } 83 }
109 84
110 fn render(&self, state: &GameState) -> String { 85 /// Render a slots view to terminal lines. The GUI paints the same view.
111 let reels = state.data.get("reels").cloned().unwrap_or_default(); 86 pub fn render_view(view: &SlotsView) -> String {
112 let balance = state.data.get("balance").map(String::as_str).unwrap_or("?"); 87 let balance = view
113 let winning = Self::highlighted_cells(state); 88 .balance
114 let grid = self.render_grid(&reels, &winning); 89 .map(|balance| balance.to_string())
90 .unwrap_or_else(|| "?".to_string());
91 let grid = Self::render_grid(&view.reels, &view.win_cells);
115 let paytable = paytable_lines(); 92 let paytable = paytable_lines();
116 93
117 format!( 94 format!(
118 "=== 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", 95 "=== 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",
119 state.bet_amount, 96 view.bet,
120 balance, 97 balance,
121 grid[0], 98 grid[0],
122 grid[1], 99 grid[1],
123 grid[2], 100 grid[2],
124 paytable[0], 101 paytable[0],
125 paytable[1], 102 paytable[1],
126 self.render_status(state), 103 Self::render_status(view),
127 self.render_win_lines(state) 104 Self::render_win_lines(view)
128 ) 105 )
129 } 106 }
107}
108
109impl Game for SlotsGame {
110 fn get_name(&self) -> &'static str {
111 "Slots"
112 }
113
114 fn render(&self, state: &GameState) -> String {
115 Self::render_view(&SlotsView::from_state(state))
116 }
130 117
131 fn render_lines(&self, _state: &GameState) -> usize { 118 fn render_lines(&self, _state: &GameState) -> usize {
132 SLOT_BOARD_LINES 119 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 @@
3//! This is the TUI's half of the `View` boundary: the GUI renders the same 3//! This is the TUI's half of the `View` boundary: the GUI renders the same
4//! data with a painter instead of strings. 4//! data with a painter instead of strings.
5 5
6use crate::client::games::blackjack::BlackjackGame;
7use crate::client::games::crash::CrashGame;
6use crate::client::games::roulette::RouletteGame; 8use crate::client::games::roulette::RouletteGame;
9use crate::client::games::slots::SlotsGame;
7use crate::client::view::View; 10use crate::client::view::View;
8 11
9/// Render any view to terminal lines. 12/// Render any view to terminal lines.
10pub fn render_view(view: &View) -> Vec<String> { 13pub fn render_view(view: &View) -> Vec<String> {
11 match view { 14 let rendered = match view {
12 View::Roulette(roulette) => RouletteGame::render_view(roulette) 15 View::Slots(slots) => SlotsGame::render_view(slots),
13 .lines() 16 View::Roulette(roulette) => RouletteGame::render_view(roulette),
14 .map(str::to_string) 17 View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack),
15 .collect(), 18 View::Crash(crash) => CrashGame::render_view(crash),
16 View::Text(lines) => lines.clone(), 19 };
17 } 20 rendered.lines().map(str::to_string).collect()
18} 21}
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 @@
3//! The core owns state and navigation; adapters map input to `Command` and 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. 4//! render `View`. Terminal text lives in [`crate::client::tui`], not here.
5 5
6use crate::blackjack::BlackjackState;
6use crate::client::games::roulette::{Cursor, Direction}; 7use crate::client::games::roulette::{Cursor, Direction};
7use crate::client::games::traits::GameState; 8use crate::client::games::traits::{GamePhase, GameState};
8use crate::roulette::{BetTarget, Bets}; 9use crate::roulette::{BetTarget, Bets};
9 10
10#[derive(Debug, Clone, Copy, PartialEq, Eq)] 11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -81,23 +82,192 @@ impl RouletteView {
81 } 82 }
82} 83}
83 84
84/// Everything a frontend can draw. `Text` is the bridge for games that have 85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85/// not been ported to a structured view yet. 86pub enum SlotsPhase {
87 Ready,
88 Spinning,
89 Result,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SlotsView {
94 pub bet: u64,
95 pub balance: Option<i64>,
96 pub reels: Vec<char>,
97 pub win_cells: Vec<usize>,
98 pub win_lines: Vec<usize>,
99 pub win_type: String,
100 pub winnings: Option<u64>,
101 pub phase: SlotsPhase,
102 pub error: Option<String>,
103}
104
105fn parse_indices(raw: Option<&String>) -> Vec<usize> {
106 raw.map(|value| {
107 value
108 .split(',')
109 .filter_map(|part| part.trim().parse().ok())
110 .collect()
111 })
112 .unwrap_or_default()
113}
114
115impl SlotsView {
116 pub fn from_state(state: &GameState) -> Self {
117 let reels = state
118 .data
119 .get("reels")
120 .map(|raw| raw.chars().collect())
121 .unwrap_or_default();
122 let phase = match state.phase {
123 GamePhase::Playing => SlotsPhase::Spinning,
124 GamePhase::Finished => SlotsPhase::Result,
125 GamePhase::WaitingToStart => SlotsPhase::Ready,
126 };
127
128 Self {
129 bet: state.bet_amount,
130 balance: state
131 .data
132 .get("balance")
133 .and_then(|value| value.parse().ok()),
134 reels,
135 win_cells: parse_indices(state.data.get("win_cells")),
136 win_lines: parse_indices(state.data.get("win_lines")),
137 win_type: state.data.get("win_type").cloned().unwrap_or_default(),
138 winnings: state
139 .data
140 .get("winnings")
141 .and_then(|value| value.parse().ok()),
142 phase,
143 error: state
144 .data
145 .get("error")
146 .cloned()
147 .filter(|message| !message.is_empty()),
148 }
149 }
150}
151
86#[derive(Debug, Clone, PartialEq, Eq)] 152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct BlackjackView {
154 pub status: String,
155 pub balance: Option<i64>,
156 pub bet: u64,
157 pub hand: Option<BlackjackState>,
158 pub error: Option<String>,
159}
160
161impl BlackjackView {
162 pub fn from_state(state: &GameState) -> Self {
163 let hand = state
164 .data
165 .get("blackjack")
166 .and_then(|raw| serde_json::from_str(raw).ok());
167
168 Self {
169 status: state
170 .data
171 .get("status")
172 .cloned()
173 .unwrap_or_else(|| "ready".to_string()),
174 balance: state
175 .data
176 .get("balance")
177 .and_then(|value| value.parse().ok()),
178 bet: state.bet_amount,
179 hand,
180 error: state
181 .data
182 .get("error")
183 .cloned()
184 .filter(|message| !message.is_empty()),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq)]
190pub struct CrashView {
191 pub status: String,
192 pub balance: Option<i64>,
193 pub bet: u64,
194 pub multiplier: f64,
195 pub potential: u64,
196 pub crash_point: Option<f64>,
197 pub payout: u64,
198 pub error: Option<String>,
199}
200
201impl CrashView {
202 pub fn from_state(state: &GameState) -> Self {
203 let bet = state.bet_amount;
204
205 Self {
206 status: state
207 .data
208 .get("status")
209 .cloned()
210 .unwrap_or_else(|| "ready".to_string()),
211 balance: state
212 .data
213 .get("balance")
214 .and_then(|value| value.parse().ok()),
215 bet,
216 multiplier: state
217 .data
218 .get("multiplier")
219 .and_then(|value| value.parse::<f64>().ok())
220 .unwrap_or(1.0),
221 potential: state
222 .data
223 .get("potential")
224 .and_then(|value| value.parse::<u64>().ok())
225 .unwrap_or(bet),
226 crash_point: state
227 .data
228 .get("crash_point")
229 .and_then(|value| value.parse::<f64>().ok()),
230 payout: state
231 .data
232 .get("payout")
233 .and_then(|value| value.parse::<u64>().ok())
234 .unwrap_or(0),
235 error: state
236 .data
237 .get("error")
238 .cloned()
239 .filter(|message| !message.is_empty()),
240 }
241 }
242}
243
244/// Everything a frontend can draw.
245#[derive(Debug, Clone, PartialEq)]
87pub enum View { 246pub enum View {
247 Slots(SlotsView),
88 Roulette(RouletteView), 248 Roulette(RouletteView),
89 Text(Vec<String>), 249 Blackjack(BlackjackView),
250 Crash(CrashView),
90} 251}
91 252
92/// Semantic input. Frontends turn keys and clicks into these. 253/// Semantic input. Frontends turn keys and clicks into these.
93#[derive(Debug, Clone, PartialEq, Eq)] 254#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Command { 255pub enum Command {
256 SetBet(u64),
257 AdjustBet(i64),
258 Leave,
259 Spin,
260 Deal,
261 Hit,
262 Stand,
263 Double,
264 Start,
265 CashOut,
266 Forfeit,
95 MoveCursor(Direction), 267 MoveCursor(Direction),
96 PlaceBet, 268 PlaceBet,
97 PlaceBetAt(BetTarget), 269 PlaceBetAt(BetTarget),
98 ClearBets, 270 ClearBets,
99 SetChip(u64), 271 SetChip(u64),
100 AdjustChip(i64), 272 AdjustChip(i64),
101 Spin,
102 Leave,
103} 273}
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 695dcb1..0a2cb7b 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -12,7 +12,7 @@ use zagan_bet::client::games::response::{
12}; 12};
13use zagan_bet::client::games::roulette::{Cursor, Direction}; 13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::ui::command_names; 14use zagan_bet::client::ui::command_names;
15use zagan_bet::client::view::{Command, RoulettePhase, View}; 15use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View};
16use zagan_bet::roulette::BetTarget; 16use zagan_bet::roulette::BetTarget;
17use zagan_bet::crash::CrashState; 17use zagan_bet::crash::CrashState;
18 18
@@ -1209,7 +1209,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() {
1209 let mut h = Harness::new(); 1209 let mut h = Harness::new();
1210 h.enter_roulette(); 1210 h.enter_roulette();
1211 1211
1212 let View::Roulette(view) = h.core.view() else { 1212 let Some(View::Roulette(view)) = h.core.view() else {
1213 panic!("expected a roulette view"); 1213 panic!("expected a roulette view");
1214 }; 1214 };
1215 assert_eq!(view.cursor, Cursor::Lattice { x: 1, y: 5 }); 1215 assert_eq!(view.cursor, Cursor::Lattice { x: 1, y: 5 });
@@ -1226,7 +1226,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() {
1226 let effects = h.core.command(Command::PlaceBet); 1226 let effects = h.core.command(Command::PlaceBet);
1227 h.apply(effects); 1227 h.apply(effects);
1228 1228
1229 let View::Roulette(view) = h.core.view() else { 1229 let Some(View::Roulette(view)) = h.core.view() else {
1230 panic!("expected a roulette view"); 1230 panic!("expected a roulette view");
1231 }; 1231 };
1232 assert_eq!( 1232 assert_eq!(
@@ -1248,7 +1248,7 @@ fn clicking_a_bet_target_does_not_move_the_cursor() {
1248 let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); 1248 let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 }));
1249 h.apply(effects); 1249 h.apply(effects);
1250 1250
1251 let View::Roulette(view) = h.core.view() else { 1251 let Some(View::Roulette(view)) = h.core.view() else {
1252 panic!("expected a roulette view"); 1252 panic!("expected a roulette view");
1253 }; 1253 };
1254 assert_eq!(view.bets.amount_on(&BetTarget::Split { a: 1, b: 4 }), 10); 1254 assert_eq!(view.bets.amount_on(&BetTarget::Split { a: 1, b: 4 }), 10);
@@ -1269,7 +1269,7 @@ fn the_roulette_view_reports_the_result() {
1269 let bets = r#"{"chips":[{"target":{"bet":"straight","number":1},"amount":10}]}"#; 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}")); 1270 h.reply(&format!("ROULETTE_RESULT s1 360 10 1350 1 red {bets}"));
1271 1271
1272 let View::Roulette(view) = h.core.view() else { 1272 let Some(View::Roulette(view)) = h.core.view() else {
1273 panic!("expected a roulette view"); 1273 panic!("expected a roulette view");
1274 }; 1274 };
1275 assert_eq!(view.phase, RoulettePhase::Result); 1275 assert_eq!(view.phase, RoulettePhase::Result);
@@ -1286,27 +1286,31 @@ fn the_chip_size_is_a_command() {
1286 h.enter_roulette(); 1286 h.enter_roulette();
1287 1287
1288 h.core.command(Command::AdjustChip(10)); 1288 h.core.command(Command::AdjustChip(10));
1289 let View::Roulette(view) = h.core.view() else { 1289 let Some(View::Roulette(view)) = h.core.view() else {
1290 panic!("expected a roulette view"); 1290 panic!("expected a roulette view");
1291 }; 1291 };
1292 assert_eq!(view.chip, 20); 1292 assert_eq!(view.chip, 20);
1293 1293
1294 h.core.command(Command::SetChip(5)); 1294 h.core.command(Command::SetChip(5));
1295 let View::Roulette(view) = h.core.view() else { 1295 let Some(View::Roulette(view)) = h.core.view() else {
1296 panic!("expected a roulette view"); 1296 panic!("expected a roulette view");
1297 }; 1297 };
1298 assert_eq!(view.chip, 10, "the chip never goes below 10"); 1298 assert_eq!(view.chip, 10, "the chip never goes below 10");
1299} 1299}
1300 1300
1301#[test] 1301#[test]
1302fn unported_games_still_reach_the_frontend_as_text() { 1302fn slots_reach_the_frontend_as_a_structured_view() {
1303 let mut h = Harness::new(); 1303 let mut h = Harness::new();
1304 h.enter_slots("10"); 1304 h.enter_slots("10");
1305 h.key(Key::Char(' '));
1305 1306
1306 match h.core.view() { 1307 let view = h.core.view().expect("a slots view");
1307 View::Text(lines) => assert_eq!(lines.len(), 10), 1308 let View::Slots(view) = view else {
1308 other => panic!("expected the text bridge, got {other:?}"), 1309 panic!("expected a slots view");
1309 } 1310 };
1311 assert_eq!(view.bet, 10);
1312 assert_eq!(view.phase, SlotsPhase::Spinning);
1313 assert_eq!(h.board().len(), 10);
1310} 1314}
1311 1315
1312#[test] 1316#[test]
@@ -1320,3 +1324,86 @@ fn leaving_through_a_command_clears_the_screen() {
1320 assert!(!h.core.is_in_game()); 1324 assert!(!h.core.is_in_game());
1321 assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); 1325 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
1322} 1326}
1327
1328#[test]
1329fn the_slots_view_carries_the_result() {
1330 let mut h = Harness::new();
1331 h.enter_slots("10");
1332 h.key(Key::Char(' '));
1333 h.reply(&format!(
1334 "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6"
1335 ));
1336
1337 let Some(View::Slots(view)) = h.core.view() else {
1338 panic!("expected a slots view");
1339 };
1340 assert_eq!(view.phase, SlotsPhase::Result);
1341 assert_eq!(view.winnings, Some(20));
1342 assert_eq!(view.win_type, "win");
1343 assert_eq!(view.win_lines, vec![1]);
1344 assert_eq!(view.win_cells, vec![0, 3, 6]);
1345 assert_eq!(view.reels.len(), 15);
1346}
1347
1348#[test]
1349fn the_blackjack_view_carries_the_hand_and_legal_actions() {
1350 let mut h = Harness::new();
1351 h.enter_blackjack();
1352 h.key(Key::Char(' '));
1353
1354 let player = bj_state(
1355 "bj1",
1356 "player",
1357 &["10S", "6H"],
1358 &["9D", "?"],
1359 16,
1360 9,
1361 10,
1362 10,
1363 990,
1364 &["hit", "stand", "double"],
1365 "playing",
1366 0,
1367 );
1368 h.reply(&format!(
1369 "BLACKJACK_STATE {}",
1370 serde_json::to_string(&player).unwrap()
1371 ));
1372
1373 let Some(View::Blackjack(view)) = h.core.view() else {
1374 panic!("expected a blackjack view");
1375 };
1376 assert_eq!(view.status, "player");
1377 assert_eq!(view.bet, 10);
1378 let hand = view.hand.expect("the hand is part of the view");
1379 assert_eq!(hand.player_total, 16);
1380 assert_eq!(hand.actions, vec!["hit", "stand", "double"]);
1381 assert_eq!(hand.session_id, "bj1");
1382}
1383
1384#[test]
1385fn the_crash_view_follows_the_round() {
1386 let mut h = Harness::new();
1387 h.enter_crash();
1388 h.key(Key::Char(' '));
1389
1390 let running = crash_state("running", 1.0, None, 0, 990);
1391 h.reply(&crash_line("CRASH_START", &running));
1392
1393 let Some(View::Crash(view)) = h.core.view() else {
1394 panic!("expected a crash view");
1395 };
1396 assert_eq!(view.status, "running");
1397 assert_eq!(view.bet, 10);
1398 assert_eq!(view.balance, Some(990));
1399 assert!((view.multiplier - 1.0).abs() < 1e-9);
1400 assert_eq!(view.potential, 10);
1401 assert_eq!(view.crash_point, None);
1402
1403 h.tick(Duration::from_secs(5));
1404 let Some(View::Crash(view)) = h.core.view() else {
1405 panic!("expected a crash view");
1406 };
1407 assert!((view.multiplier - 2.0).abs() < 1e-6);
1408 assert_eq!(view.potential, 20);
1409}