diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 18:03:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 18:03:24 +0100 |
| commit | 37dedc342c087506e502c78a952b45b1c91602fb (patch) | |
| tree | 85f4bf0c17823ba670d2d49c921191ca27aca64b | |
| parent | v4.0.1: introduce Command/View and port roulette to the shared boundary (diff) | |
| download | zagan-bet-37dedc342c087506e502c78a952b45b1c91602fb.tar.gz zagan-bet-37dedc342c087506e502c78a952b45b1c91602fb.zip | |
v4.0.2: port slots, blackjack and crash to structured views
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 8 | ||||
| -rw-r--r-- | src/client/core.rs | 232 | ||||
| -rw-r--r-- | src/client/games/blackjack.rs | 59 | ||||
| -rw-r--r-- | src/client/games/crash.rs | 67 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 125 | ||||
| -rw-r--r-- | src/client/tui.rs | 17 | ||||
| -rw-r--r-- | src/client/view.rs | 182 | ||||
| -rw-r--r-- | tests/client_core.rs | 111 |
9 files changed, 540 insertions, 263 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.1" | 3 | version = "4.0.2" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 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 |
| 30 | a `RouletteView`, frontends send `Command`, and the TUI renders the view. | 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. |
| 31 | Slots, blackjack and crash still travel through the `View::Text` bridge until | 31 | Remaining for this phase: the app screens (`Connect`, `Trust`, `Login`, |
| 32 | Phase 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 | }; |
| 6 | use crate::client::games::roulette::Direction; | 6 | use crate::client::games::roulette::Direction; |
| 7 | use crate::client::view::{Command, RouletteView, View}; | 7 | use crate::client::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View}; |
| 8 | use crate::client::ui::{ | 8 | use 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)] |
| 42 | pub enum Effect { | 42 | pub 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::blackjack::{BlackjackState, card_label}; | 2 | use crate::blackjack::card_label; |
| 3 | use crate::client::view::BlackjackView; | ||
| 3 | 4 | ||
| 4 | pub const BLACKJACK_BOARD_LINES: usize = 6; | 5 | pub 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 | ||
| 48 | impl 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 | |||
| 112 | impl 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 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::client::view::CrashView; | ||
| 2 | 3 | ||
| 3 | pub const CRASH_BOARD_LINES: usize = 6; | 4 | pub 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 | ||
| 44 | impl 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 | |||
| 66 | impl 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 @@ | |||
| 1 | use super::traits::{Game, GamePhase, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::client::view::{SlotsPhase, SlotsView}; | ||
| 2 | use crate::slots::{LINE_NAMES, REELS, ROWS, paytable_lines}; | 3 | use 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 | ||
| 105 | impl 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 | |||
| 109 | impl 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 | ||
| 6 | use crate::client::games::blackjack::BlackjackGame; | ||
| 7 | use crate::client::games::crash::CrashGame; | ||
| 6 | use crate::client::games::roulette::RouletteGame; | 8 | use crate::client::games::roulette::RouletteGame; |
| 9 | use crate::client::games::slots::SlotsGame; | ||
| 7 | use crate::client::view::View; | 10 | use crate::client::view::View; |
| 8 | 11 | ||
| 9 | /// Render any view to terminal lines. | 12 | /// Render any view to terminal lines. |
| 10 | pub fn render_view(view: &View) -> Vec<String> { | 13 | pub 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 | ||
| 6 | use crate::blackjack::BlackjackState; | ||
| 6 | use crate::client::games::roulette::{Cursor, Direction}; | 7 | use crate::client::games::roulette::{Cursor, Direction}; |
| 7 | use crate::client::games::traits::GameState; | 8 | use crate::client::games::traits::{GamePhase, GameState}; |
| 8 | use crate::roulette::{BetTarget, Bets}; | 9 | use 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. | 86 | pub enum SlotsPhase { |
| 87 | Ready, | ||
| 88 | Spinning, | ||
| 89 | Result, | ||
| 90 | } | ||
| 91 | |||
| 92 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 93 | pub 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 | |||
| 105 | fn 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 | |||
| 115 | impl 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)] |
| 153 | pub 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 | |||
| 161 | impl 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)] | ||
| 190 | pub 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 | |||
| 201 | impl 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)] | ||
| 87 | pub enum View { | 246 | pub 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)] |
| 94 | pub enum Command { | 255 | pub 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 | }; |
| 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; |
| 14 | use zagan_bet::client::ui::command_names; | 14 | use zagan_bet::client::ui::command_names; |
| 15 | use zagan_bet::client::view::{Command, RoulettePhase, View}; | 15 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; |
| 16 | use zagan_bet::roulette::BetTarget; | 16 | use zagan_bet::roulette::BetTarget; |
| 17 | use zagan_bet::crash::CrashState; | 17 | use 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] |
| 1302 | fn unported_games_still_reach_the_frontend_as_text() { | 1302 | fn 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] | ||
| 1329 | fn 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] | ||
| 1349 | fn 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] | ||
| 1385 | fn 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 | } | ||
