summaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 18:22:40 +0100
commitb5a52fc0312afdf5d5fbe53275dc88d6d881c2ff (patch)
treed12e3c7f6c23ae1698199d16ee763551edfd8c12 /src/client
parentv2.1.2: ignore ANSI when measuring board height (diff)
downloadzagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.tar.gz
zagan-bet-b5a52fc0312afdf5d5fbe53275dc88d6d881c2ff.zip
v2.2.0: add blackjack with reserved bets and hit/stand/double
Diffstat (limited to 'src/client')
-rw-r--r--src/client/core.rs154
-rw-r--r--src/client/games/blackjack.rs148
-rw-r--r--src/client/games/manager.rs49
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs22
5 files changed, 369 insertions, 5 deletions
diff --git a/src/client/core.rs b/src/client/core.rs
index e005f2a..6e9c28b 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -1,5 +1,7 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{parse_roulette_response, parse_spin_response}; 2use crate::client::games::response::{
3 parse_blackjack_response, parse_roulette_response, parse_spin_response,
4};
3use crate::client::ui::{ 5use crate::client::ui::{
4 command_names, completion_lines, display_width, help_lines, input_cursor_column, 6 command_names, completion_lines, display_width, help_lines, input_cursor_column,
5 parse_bet_arg, record_history, 7 parse_bet_arg, record_history,
@@ -61,6 +63,9 @@ pub enum Effect {
61enum Pending { 63enum Pending {
62 Spin, 64 Spin,
63 RouletteSpin, 65 RouletteSpin,
66 BlackjackStart,
67 BlackjackAction,
68 BlackjackCancel,
64 Ping, 69 Ping,
65 Echo, 70 Echo,
66 Info, 71 Info,
@@ -191,6 +196,32 @@ impl ClientCore {
191 self.board_effects() 196 self.board_effects()
192 } 197 }
193 }, 198 },
199 Some(Pending::BlackjackStart) | Some(Pending::BlackjackAction) => {
200 match parse_blackjack_response(reply) {
201 Ok(state) => {
202 self.balance = Some(state.balance);
203 self.game.apply_blackjack_state(state);
204 self.board_effects()
205 }
206 Err(message) => {
207 self.game.set_error(message);
208 self.board_effects()
209 }
210 }
211 }
212 Some(Pending::BlackjackCancel) => match parse_blackjack_response(reply) {
213 Ok(_) => {
214 self.game.end_game();
215 vec![
216 Effect::ClearRegion,
217 Effect::Print(vec!["Game exited.".to_string()]),
218 ]
219 }
220 Err(message) => {
221 self.game.set_error(message);
222 self.board_effects()
223 }
224 },
194 Some(Pending::Ping) => { 225 Some(Pending::Ping) => {
195 if reply.trim() == "PONG" { 226 if reply.trim() == "PONG" {
196 print_line("Pong") 227 print_line("Pong")
@@ -225,14 +256,16 @@ impl ClientCore {
225 } 256 }
226 257
227 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> { 258 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> {
228 if self 259 let game_type = self
229 .game 260 .game
230 .get_current_state() 261 .get_current_state()
231 .map(|state| state.game_type.as_str()) 262 .map(|state| state.game_type.as_str());
232 == Some("roulette") 263 if game_type == Some("roulette") {
233 {
234 return self.handle_roulette_key(key); 264 return self.handle_roulette_key(key);
235 } 265 }
266 if game_type == Some("blackjack") {
267 return self.handle_blackjack_key(key);
268 }
236 269
237 match key { 270 match key {
238 Key::CtrlC => vec![Effect::Quit], 271 Key::CtrlC => vec![Effect::Quit],
@@ -411,6 +444,117 @@ impl ClientCore {
411 .unwrap_or_default() 444 .unwrap_or_default()
412 } 445 }
413 446
447 fn handle_blackjack_key(&mut self, key: Key) -> Vec<Effect> {
448 let status = self
449 .game
450 .get_current_state()
451 .and_then(|state| state.data.get("status"))
452 .cloned()
453 .unwrap_or_else(|| "ready".to_string());
454 let hand = self.game.blackjack_state();
455
456 match key {
457 Key::CtrlC => vec![Effect::Quit],
458 Key::Char('q') => {
459 if let Some(hand) = hand.filter(|hand| hand.phase == "player") {
460 self.game.set_blackjack_status("cancelling");
461 self.pending = Some(Pending::BlackjackCancel);
462 vec![Effect::SendLine(format!(
463 "BLACKJACK_ACTION {} cancel",
464 hand.session_id
465 ))]
466 } else {
467 self.game.end_game();
468 vec![
469 Effect::ClearRegion,
470 Effect::Print(vec!["Game exited.".to_string()]),
471 ]
472 }
473 }
474 Key::Char(' ') => self.start_blackjack(),
475 Key::Char('h')
476 if hand.as_ref().is_some_and(|hand| hand.phase == "player") =>
477 {
478 self.blackjack_action("hit")
479 }
480 Key::Char('s')
481 if hand.as_ref().is_some_and(|hand| hand.phase == "player") =>
482 {
483 self.blackjack_action("stand")
484 }
485 Key::Char('d')
486 if hand.as_ref().is_some_and(|hand| {
487 hand.phase == "player"
488 && hand.actions.iter().any(|action| action == "double")
489 }) =>
490 {
491 self.blackjack_action("double")
492 }
493 Key::Char('+') | Key::Char('=')
494 if matches!(status.as_str(), "ready" | "done") =>
495 {
496 let _ = self.game.adjust_bet(10);
497 self.board_effects()
498 }
499 Key::Char('-') if matches!(status.as_str(), "ready" | "done") => {
500 let _ = self.game.adjust_bet(-10);
501 self.board_effects()
502 }
503 _ => Vec::new(),
504 }
505 }
506
507 fn start_blackjack(&mut self) -> Vec<Effect> {
508 let status = self
509 .game
510 .get_current_state()
511 .and_then(|state| state.data.get("status"))
512 .cloned()
513 .unwrap_or_else(|| "ready".to_string());
514 if !matches!(status.as_str(), "ready" | "done") {
515 return Vec::new();
516 }
517 let Some(request) = self.game.spin_request() else {
518 return Vec::new();
519 };
520 if let Some(balance) = self.balance {
521 let bet = self
522 .game
523 .get_current_state()
524 .map(|state| state.bet_amount)
525 .unwrap_or(0);
526 if bet as i64 > balance {
527 self.game.set_error("Insufficient balance");
528 return self.board_effects();
529 }
530 }
531
532 self.game.set_blackjack_status("dealing");
533 self.pending = Some(Pending::BlackjackStart);
534 let mut effects = self.board_effects();
535 effects.push(Effect::SendLine(request));
536 effects
537 }
538
539 fn blackjack_action(&mut self, action: &str) -> Vec<Effect> {
540 let Some(hand) = self.game.blackjack_state() else {
541 return Vec::new();
542 };
543 if hand.phase != "player" {
544 return Vec::new();
545 }
546 if action == "double"
547 && !hand.actions.iter().any(|available| available == "double")
548 {
549 return Vec::new();
550 }
551 self.pending = Some(Pending::BlackjackAction);
552 vec![Effect::SendLine(format!(
553 "BLACKJACK_ACTION {} {}",
554 hand.session_id, action
555 ))]
556 }
557
414 fn submit(&mut self) -> Vec<Effect> { 558 fn submit(&mut self) -> Vec<Effect> {
415 let line = self.input.trim().to_string(); 559 let line = self.input.trim().to_string();
416 self.input.clear(); 560 self.input.clear();
diff --git a/src/client/games/blackjack.rs b/src/client/games/blackjack.rs
new file mode 100644
index 0000000..a34eb63
--- /dev/null
+++ b/src/client/games/blackjack.rs
@@ -0,0 +1,148 @@
1use super::traits::{Game, GameState};
2use crate::blackjack::{card_label, BlackjackState};
3
4pub const BLACKJACK_BOARD_LINES: usize = 6;
5
6pub struct BlackjackGame;
7
8impl BlackjackGame {
9 pub fn new() -> Self {
10 Self
11 }
12
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 {
21 cards
22 .iter()
23 .map(|card| {
24 if card == "?" {
25 "[ ? ]".to_string()
26 } else {
27 format!("[{}]", card_label(card))
28 }
29 })
30 .collect::<Vec<_>>()
31 .join(" ")
32 }
33
34 fn result_text(result: &str, winnings: u64) -> String {
35 match result {
36 "blackjack" => format!("BLACKJACK! Won {winnings} PLN"),
37 "win" => format!("Win! Won {winnings} PLN"),
38 "push" => "Push. Bet returned.".to_string(),
39 "lose" => "Dealer wins.".to_string(),
40 "bust" => "Bust.".to_string(),
41 "cancelled" => "Cancelled.".to_string(),
42 _ => "Hand complete.".to_string(),
43 }
44 }
45}
46
47impl Game for BlackjackGame {
48 fn get_name(&self) -> &'static str {
49 "Blackjack"
50 }
51
52 fn render(&self, state: &GameState) -> String {
53 let status = state
54 .data
55 .get("status")
56 .map(String::as_str)
57 .unwrap_or("ready");
58 let balance = state
59 .data
60 .get("balance")
61 .map(String::as_str)
62 .unwrap_or("?");
63 let hand = Self::state(state);
64
65 if let Some(error) = state.data.get("error") {
66 let bet = hand.as_ref().map(|hand| hand.bet).unwrap_or(state.bet_amount);
67 return format!(
68 "=== BLACKJACK ===\r\nBalance: {balance} PLN Bet: {bet} PLN\r\n\r\n\r\nERROR: {error}\r\n[Q] Quit"
69 );
70 }
71
72 let (dealer, dealer_total, player, player_total, bet, text, controls) =
73 if let Some(hand) = &hand {
74 let bet = if status == "player" {
75 hand.bet
76 } else {
77 state.bet_amount
78 };
79 let text = match status {
80 "player" => {
81 if hand.actions.iter().any(|action| action == "double") {
82 "Hit, stand or double.".to_string()
83 } else {
84 "Hit or stand.".to_string()
85 }
86 }
87 "dealing" => "Dealing...".to_string(),
88 "cancelling" => "Cancelling...".to_string(),
89 "done" => Self::result_text(&hand.result, hand.winnings),
90 _ => "Press [SPACE] to deal.".to_string(),
91 };
92 let controls = if status == "player" {
93 if hand.actions.iter().any(|action| action == "double") {
94 "[H]it [S]tand [D]ouble [Q]uit".to_string()
95 } else {
96 "[H]it [S]tand [Q]uit".to_string()
97 }
98 } else {
99 "[SPACE] New hand [+/-] Bet [Q] Quit".to_string()
100 };
101 (
102 Self::cards(&hand.dealer),
103 hand.dealer_total,
104 Self::cards(&hand.player),
105 hand.player_total,
106 bet,
107 text,
108 controls,
109 )
110 } else {
111 (
112 "—".to_string(),
113 0,
114 "—".to_string(),
115 0,
116 state.bet_amount,
117 "Press [SPACE] to deal.".to_string(),
118 "[SPACE] Deal [+/-] Bet [Q] Quit".to_string(),
119 )
120 };
121
122 format!(
123 "=== BLACKJACK ===\r\nBalance: {} PLN Bet: {} PLN\r\nDealer: {} {}\r\nYou: {} {}\r\n{}\r\n{}",
124 balance, bet, dealer, dealer_total, player, player_total, text, controls
125 )
126 }
127
128 fn render_lines(&self, _state: &GameState) -> usize {
129 BLACKJACK_BOARD_LINES
130 }
131
132 fn get_help(&self) -> String {
133 "BLACKJACK\n\
134 =========\n\
135 Six-deck shoe, fresh for every hand.\n\
136 Dealer stands on all 17s.\n\
137 Blackjack pays 3:2.\n\
138 Double is allowed on the first two cards.\n\
139 \n\
140 H - hit\n\
141 S - stand\n\
142 D - double (when available)\n\
143 SPACE - deal a new hand\n\
144 +/- - change the next hand's bet\n\
145 Q - leave the table"
146 .to_string()
147 }
148}
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 02e9451..4e0218b 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -1,7 +1,9 @@
1use super::blackjack::BlackjackGame;
1use super::response::{RouletteResult, SpinResult}; 2use super::response::{RouletteResult, SpinResult};
2use super::roulette::RouletteGame; 3use super::roulette::RouletteGame;
3use super::slots::SlotsGame; 4use super::slots::SlotsGame;
4use super::traits::{Game, GameError, GamePhase, GameState}; 5use super::traits::{Game, GameError, GamePhase, GameState};
6use crate::blackjack::BlackjackState;
5use crate::roulette::Bets; 7use crate::roulette::Bets;
6use std::collections::HashMap; 8use std::collections::HashMap;
7 9
@@ -15,6 +17,7 @@ impl GameManager {
15 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new(); 17 let mut games: HashMap<String, Box<dyn Game>> = HashMap::new();
16 games.insert("slots".to_string(), Box::new(SlotsGame::new())); 18 games.insert("slots".to_string(), Box::new(SlotsGame::new()));
17 games.insert("roulette".to_string(), Box::new(RouletteGame::new())); 19 games.insert("roulette".to_string(), Box::new(RouletteGame::new()));
20 games.insert("blackjack".to_string(), Box::new(BlackjackGame::new()));
18 21
19 Self { 22 Self {
20 current_session: None, 23 current_session: None,
@@ -51,6 +54,9 @@ impl GameManager {
51 data.insert("status".to_string(), "place".to_string()); 54 data.insert("status".to_string(), "place".to_string());
52 data.insert("number_input".to_string(), String::new()); 55 data.insert("number_input".to_string(), String::new());
53 0 56 0
57 } else if game_type == "blackjack" {
58 data.insert("status".to_string(), "ready".to_string());
59 10
54 } else { 60 } else {
55 10 61 10
56 }; 62 };
@@ -260,6 +266,49 @@ impl GameManager {
260 .insert("number_input".to_string(), String::new()); 266 .insert("number_input".to_string(), String::new());
261 } 267 }
262 268
269 pub fn apply_blackjack_state(&mut self, state: BlackjackState) {
270 let Some(session) = self.current_session.as_mut() else {
271 return;
272 };
273 session.phase = if state.phase == "player" {
274 GamePhase::Playing
275 } else {
276 GamePhase::Finished
277 };
278 session.bet_amount = state.base_bet;
279 session.data.remove("error");
280 session
281 .data
282 .insert("balance".to_string(), state.balance.to_string());
283 session.data.insert(
284 "status".to_string(),
285 if state.phase == "player" {
286 "player".to_string()
287 } else {
288 "done".to_string()
289 },
290 );
291 session.data.insert(
292 "blackjack".to_string(),
293 serde_json::to_string(&state).unwrap_or_default(),
294 );
295 }
296
297 pub fn blackjack_state(&self) -> Option<BlackjackState> {
298 self.current_session
299 .as_ref()
300 .and_then(|session| session.data.get("blackjack"))
301 .and_then(|raw| serde_json::from_str(raw).ok())
302 }
303
304 pub fn set_blackjack_status(&mut self, status: &str) {
305 if let Some(session) = self.current_session.as_mut() {
306 session
307 .data
308 .insert("status".to_string(), status.to_string());
309 }
310 }
311
263 pub fn get_current_game(&self) -> Option<&dyn Game> { 312 pub fn get_current_game(&self) -> Option<&dyn Game> {
264 self.current_session 313 self.current_session
265 .as_ref() 314 .as_ref()
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs
index 131ffbb..40ba32a 100644
--- a/src/client/games/mod.rs
+++ b/src/client/games/mod.rs
@@ -1,3 +1,4 @@
1pub mod blackjack;
1pub mod manager; 2pub mod manager;
2pub mod response; 3pub mod response;
3pub mod roulette; 4pub mod roulette;
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
index 73080dd..d9883f7 100644
--- a/src/client/games/response.rs
+++ b/src/client/games/response.rs
@@ -141,3 +141,25 @@ pub fn parse_roulette_response(response: &str) -> Result<RouletteResult, String>
141 bets, 141 bets,
142 }) 142 })
143} 143}
144
145pub fn parse_blackjack_response(
146 response: &str,
147) -> Result<crate::blackjack::BlackjackState, String> {
148 let response = response.trim();
149 if response.is_empty() {
150 return Err("No response from server".to_string());
151 }
152 if let Some(message) = response.strip_prefix("ERROR") {
153 let message = message.trim();
154 return Err(if message.is_empty() {
155 "Server error".to_string()
156 } else {
157 message.to_string()
158 });
159 }
160
161 let raw = response
162 .strip_prefix("BLACKJACK_STATE ")
163 .ok_or_else(|| format!("Unexpected server response: {response}"))?;
164 serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}"))
165}