summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
6 files changed, 436 insertions, 246 deletions
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}