summaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/client/core.rs190
-rw-r--r--src/client/games/crash.rs91
-rw-r--r--src/client/games/manager.rs90
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs21
5 files changed, 392 insertions, 1 deletions
diff --git a/src/client/core.rs b/src/client/core.rs
index 77761b4..5f8c513 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -1,12 +1,14 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{ 2use crate::client::games::response::{
3 parse_blackjack_response, parse_roulette_response, parse_spin_response, 3 parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response,
4}; 4};
5use crate::client::ui::{ 5use crate::client::ui::{
6 command_names, completion_lines, display_width, help_lines, input_cursor_column, 6 command_names, completion_lines, display_width, help_lines, input_cursor_column,
7 parse_bet_arg, record_history, 7 parse_bet_arg, record_history,
8}; 8};
9use crate::crash::{multiplier_at, CrashState};
9use crate::roulette::{Bets, NumberBet}; 10use crate::roulette::{Bets, NumberBet};
11use std::time::{Duration, Instant};
10 12
11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Key { 14pub enum Key {
@@ -66,6 +68,9 @@ enum Pending {
66 BlackjackStart, 68 BlackjackStart,
67 BlackjackAction, 69 BlackjackAction,
68 BlackjackForfeit, 70 BlackjackForfeit,
71 CrashStart,
72 CrashCashout,
73 CrashForfeit,
69 Ping, 74 Ping,
70 Echo, 75 Echo,
71 Info, 76 Info,
@@ -90,6 +95,8 @@ pub struct ClientCore {
90 balance: Option<i64>, 95 balance: Option<i64>,
91 host: Option<String>, 96 host: Option<String>,
92 fingerprint: Option<String>, 97 fingerprint: Option<String>,
98 /// Local anchor for the crash multiplier, set when a round starts.
99 crash_started: Option<Instant>,
93} 100}
94 101
95impl ClientCore { 102impl ClientCore {
@@ -105,6 +112,7 @@ impl ClientCore {
105 balance: None, 112 balance: None,
106 host: None, 113 host: None,
107 fingerprint: None, 114 fingerprint: None,
115 crash_started: None,
108 } 116 }
109 } 117 }
110 118
@@ -113,6 +121,7 @@ impl ClientCore {
113 if !connected { 121 if !connected {
114 self.logged_in = None; 122 self.logged_in = None;
115 self.pending = None; 123 self.pending = None;
124 self.crash_started = None;
116 } 125 }
117 } 126 }
118 127
@@ -226,6 +235,49 @@ impl ClientCore {
226 self.board_effects() 235 self.board_effects()
227 } 236 }
228 }, 237 },
238 Some(Pending::CrashStart) => match parse_crash_response(reply) {
239 Ok(state) => self.apply_crash(state),
240 Err(message) => {
241 self.game.set_error(message);
242 self.board_effects()
243 }
244 },
245 Some(Pending::CrashCashout) => match parse_crash_response(reply) {
246 Ok(state) => self.apply_crash(state),
247 Err(message) => {
248 // A pushed bust can settle the round before this reply
249 // arrives. The late request must not overwrite the result.
250 if self.game.crash_status() != "running" {
251 return self.board_effects();
252 }
253 self.game.set_error(message);
254 self.board_effects()
255 }
256 },
257 Some(Pending::CrashForfeit) => match parse_crash_response(reply) {
258 Ok(state) => {
259 self.crash_started = None;
260 self.game.end_game();
261 let message = format!(
262 "Game exited. Round forfeited ({} PLN lost).",
263 state.bet
264 );
265 vec![Effect::ClearRegion, Effect::Print(vec![message])]
266 }
267 Err(_) if self.game.crash_status() != "running" => {
268 // The round ended while the forfeit was in flight.
269 self.crash_started = None;
270 self.game.end_game();
271 vec![
272 Effect::ClearRegion,
273 Effect::Print(vec!["Game exited.".to_string()]),
274 ]
275 }
276 Err(message) => {
277 self.game.set_error(message);
278 self.board_effects()
279 }
280 },
229 Some(Pending::Ping) => { 281 Some(Pending::Ping) => {
230 if reply.trim() == "PONG" { 282 if reply.trim() == "PONG" {
231 print_line("Pong") 283 print_line("Pong")
@@ -270,6 +322,9 @@ impl ClientCore {
270 if game_type == Some("blackjack") { 322 if game_type == Some("blackjack") {
271 return self.handle_blackjack_key(key); 323 return self.handle_blackjack_key(key);
272 } 324 }
325 if game_type == Some("crash") {
326 return self.handle_crash_key(key);
327 }
273 328
274 match key { 329 match key {
275 Key::CtrlC => vec![Effect::Quit], 330 Key::CtrlC => vec![Effect::Quit],
@@ -559,6 +614,138 @@ impl ClientCore {
559 ))] 614 ))]
560 } 615 }
561 616
617 /// Redraw the rising multiplier. Called by the terminal loop on a timer.
618 pub fn tick(&mut self) -> Vec<Effect> {
619 let Some(started) = self.crash_started else {
620 return Vec::new();
621 };
622 self.tick_at(started.elapsed())
623 }
624
625 pub fn tick_at(&mut self, elapsed: Duration) -> Vec<Effect> {
626 if self.crash_started.is_none() {
627 return Vec::new();
628 }
629 if self
630 .game
631 .get_current_state()
632 .map(|state| state.game_type.as_str())
633 != Some("crash")
634 {
635 return Vec::new();
636 }
637 self.game.set_crash_multiplier(multiplier_at(elapsed));
638 self.board_effects()
639 }
640
641 /// True while the server may push a crash event between requests.
642 pub fn expects_push(&self) -> bool {
643 self.crash_started.is_some()
644 }
645
646 pub fn is_async(&self, line: &str) -> bool {
647 line.trim_start().starts_with("CRASH_BUSTED ")
648 }
649
650 /// Handle a server-initiated line. Returns `None` when the line is a normal
651 /// request/response reply and belongs to `handle_reply`.
652 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> {
653 if !self.is_async(line) {
654 return None;
655 }
656 match parse_crash_response(line) {
657 Ok(state) => Some(self.apply_crash(state)),
658 Err(message) => {
659 self.game.set_error(message);
660 Some(self.board_effects())
661 }
662 }
663 }
664
665 fn apply_crash(&mut self, state: CrashState) -> Vec<Effect> {
666 self.balance = Some(state.balance);
667 if state.is_running() {
668 if self.crash_started.is_none() {
669 self.crash_started = Some(Instant::now());
670 }
671 } else {
672 self.crash_started = None;
673 }
674 self.game.apply_crash_state(state);
675 self.board_effects()
676 }
677
678 fn handle_crash_key(&mut self, key: Key) -> Vec<Effect> {
679 let status = self.game.crash_status();
680 let running = status == "running";
681
682 match key {
683 Key::CtrlC => vec![Effect::Quit],
684 Key::Char('q') => {
685 if running {
686 let Some(session) = self.game.crash_session_id() else {
687 return Vec::new();
688 };
689 self.pending = Some(Pending::CrashForfeit);
690 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
691 } else {
692 self.crash_started = None;
693 self.game.end_game();
694 vec![
695 Effect::ClearRegion,
696 Effect::Print(vec!["Game exited.".to_string()]),
697 ]
698 }
699 }
700 Key::Char(' ') if !running && self.pending.is_none() => self.start_crash(),
701 Key::Char('c') if running && self.pending.is_none() => self.cash_out_crash(),
702 Key::Char('+') | Key::Char('=') if !running => {
703 let _ = self.game.adjust_bet(10);
704 self.board_effects()
705 }
706 Key::Char('-') if !running => {
707 let _ = self.game.adjust_bet(-10);
708 self.board_effects()
709 }
710 _ => Vec::new(),
711 }
712 }
713
714 fn start_crash(&mut self) -> Vec<Effect> {
715 let status = self.game.crash_status();
716 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") {
717 return Vec::new();
718 }
719 let Some(request) = self.game.spin_request() else {
720 return Vec::new();
721 };
722 if let Some(balance) = self.balance {
723 let bet = self
724 .game
725 .get_current_state()
726 .map(|state| state.bet_amount)
727 .unwrap_or(0);
728 if bet as i64 > balance {
729 self.game.set_error("Insufficient balance");
730 return self.board_effects();
731 }
732 }
733
734 self.game.set_crash_status("starting");
735 self.pending = Some(Pending::CrashStart);
736 let mut effects = self.board_effects();
737 effects.push(Effect::SendLine(request));
738 effects
739 }
740
741 fn cash_out_crash(&mut self) -> Vec<Effect> {
742 let Some(session) = self.game.crash_session_id() else {
743 return Vec::new();
744 };
745 self.pending = Some(Pending::CrashCashout);
746 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))]
747 }
748
562 fn submit(&mut self) -> Vec<Effect> { 749 fn submit(&mut self) -> Vec<Effect> {
563 let line = self.input.trim().to_string(); 750 let line = self.input.trim().to_string();
564 self.input.clear(); 751 self.input.clear();
@@ -616,6 +803,7 @@ impl ClientCore {
616 self.connected = false; 803 self.connected = false;
617 self.logged_in = None; 804 self.logged_in = None;
618 self.pending = None; 805 self.pending = None;
806 self.crash_started = None;
619 self.game.end_game(); 807 self.game.end_game();
620 vec![ 808 vec![
621 Effect::ClearRegion, 809 Effect::ClearRegion,
diff --git a/src/client/games/crash.rs b/src/client/games/crash.rs
new file mode 100644
index 0000000..19733f2
--- /dev/null
+++ b/src/client/games/crash.rs
@@ -0,0 +1,91 @@
1use super::traits::{Game, GameState};
2
3pub const CRASH_BOARD_LINES: usize = 6;
4
5pub struct CrashGame;
6
7impl CrashGame {
8 pub fn new() -> Self {
9 Self
10 }
11
12 fn value<'a>(state: &'a GameState, key: &str) -> Option<&'a str> {
13 state.data.get(key).map(String::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(),
20 "running" => "Rising... cash out before it crashes.".to_string(),
21 "cashed" => format!(
22 "Cashed out at {}x Won {} PLN",
23 Self::value(state, "multiplier").unwrap_or("1.00"),
24 Self::value(state, "payout").unwrap_or("0")
25 ),
26 "crashed" => format!(
27 "CRASHED at {}x Bet lost",
28 Self::value(state, "crash_point").unwrap_or("?")
29 ),
30 "forfeit" => "Forfeited. Bet lost.".to_string(),
31 _ => "Press [SPACE] to start.".to_string(),
32 }
33 }
34
35 fn controls(state: &GameState) -> &'static str {
36 if Self::value(state, "status") == Some("running") {
37 "[C] Cash out [Q] Forfeit"
38 } else {
39 "[SPACE] Play [+/=/-] Bet [Q] Quit"
40 }
41 }
42}
43
44impl Game for CrashGame {
45 fn get_name(&self) -> &'static str {
46 "Crash"
47 }
48
49 fn render(&self, state: &GameState) -> 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
56 if let Some(error) = Self::value(state, "error") {
57 return format!(
58 "=== CRASH ===\r\nBalance: {balance} PLN Bet: {} PLN\r\n\r\nERROR: {error}\r\n\r\n[SPACE] Play [+/=/-] Bet [Q] Quit",
59 state.bet_amount
60 );
61 }
62
63 format!(
64 "=== CRASH ===\r\nBalance: {} PLN Bet: {} PLN\r\nMultiplier: {}x\r\nPotential: {} PLN\r\n{}\r\n{}",
65 balance,
66 state.bet_amount,
67 multiplier,
68 potential,
69 Self::status_text(state),
70 Self::controls(state)
71 )
72 }
73
74 fn render_lines(&self, _state: &GameState) -> usize {
75 CRASH_BOARD_LINES
76 }
77
78 fn get_help(&self) -> String {
79 "CRASH\n\
80 =====\n\
81 The multiplier rises from 1.00x and crashes at a hidden point.\n\
82 Cash out before it crashes to win bet x multiplier.\n\
83 A round can crash at 1.00x, so an instant cashout still risks the bet.\n\
84 \n\
85 C - cash out at the server's current multiplier\n\
86 SPACE - start the next round\n\
87 +/- - change the next round's bet\n\
88 Q - forfeit an active round (bet is lost), or leave when idle"
89 .to_string()
90 }
91}
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 4e0218b..4d9e6a8 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -1,9 +1,11 @@
1use super::blackjack::BlackjackGame; 1use super::blackjack::BlackjackGame;
2use super::crash::CrashGame;
2use super::response::{RouletteResult, SpinResult}; 3use super::response::{RouletteResult, SpinResult};
3use super::roulette::RouletteGame; 4use super::roulette::RouletteGame;
4use super::slots::SlotsGame; 5use super::slots::SlotsGame;
5use super::traits::{Game, GameError, GamePhase, GameState}; 6use super::traits::{Game, GameError, GamePhase, GameState};
6use crate::blackjack::BlackjackState; 7use crate::blackjack::BlackjackState;
8use crate::crash::{payout, CrashState};
7use crate::roulette::Bets; 9use crate::roulette::Bets;
8use std::collections::HashMap; 10use std::collections::HashMap;
9 11
@@ -18,6 +20,7 @@ impl GameManager {
18 games.insert("slots".to_string(), Box::new(SlotsGame::new())); 20 games.insert("slots".to_string(), Box::new(SlotsGame::new()));
19 games.insert("roulette".to_string(), Box::new(RouletteGame::new())); 21 games.insert("roulette".to_string(), Box::new(RouletteGame::new()));
20 games.insert("blackjack".to_string(), Box::new(BlackjackGame::new())); 22 games.insert("blackjack".to_string(), Box::new(BlackjackGame::new()));
23 games.insert("crash".to_string(), Box::new(CrashGame::new()));
21 24
22 Self { 25 Self {
23 current_session: None, 26 current_session: None,
@@ -57,6 +60,10 @@ impl GameManager {
57 } else if game_type == "blackjack" { 60 } else if game_type == "blackjack" {
58 data.insert("status".to_string(), "ready".to_string()); 61 data.insert("status".to_string(), "ready".to_string());
59 10 62 10
63 } else if game_type == "crash" {
64 data.insert("status".to_string(), "ready".to_string());
65 data.insert("multiplier".to_string(), "1.00".to_string());
66 10
60 } else { 67 } else {
61 10 68 10
62 }; 69 };
@@ -173,11 +180,19 @@ impl GameManager {
173 session.data.remove("winnings"); 180 session.data.remove("winnings");
174 session.data.remove("number"); 181 session.data.remove("number");
175 session.data.remove("color"); 182 session.data.remove("color");
183 session.data.remove("payout");
184 session.data.remove("crash_point");
176 if session.game_type == "roulette" { 185 if session.game_type == "roulette" {
177 session 186 session
178 .data 187 .data
179 .insert("status".to_string(), "place".to_string()); 188 .insert("status".to_string(), "place".to_string());
180 } 189 }
190 if session.game_type == "crash" {
191 session
192 .data
193 .insert("status".to_string(), "ready".to_string());
194 session.data.remove("session_id");
195 }
181 session.data.insert("error".to_string(), message.into()); 196 session.data.insert("error".to_string(), message.into());
182 } 197 }
183 198
@@ -309,6 +324,81 @@ impl GameManager {
309 } 324 }
310 } 325 }
311 326
327 pub fn apply_crash_state(&mut self, state: CrashState) {
328 let Some(session) = self.current_session.as_mut() else {
329 return;
330 };
331 let running = state.is_running();
332 session.phase = if running {
333 GamePhase::Playing
334 } else {
335 GamePhase::Finished
336 };
337 session.bet_amount = state.bet;
338 session.data.remove("error");
339 for key in ["crash_point", "payout"] {
340 session.data.remove(key);
341 }
342 session.data.insert("status".to_string(), state.phase.clone());
343 session
344 .data
345 .insert("session_id".to_string(), state.session_id);
346 session
347 .data
348 .insert("balance".to_string(), state.balance.to_string());
349 session
350 .data
351 .insert("multiplier".to_string(), format!("{:.2}", state.multiplier));
352 session
353 .data
354 .insert("potential".to_string(), state.potential.to_string());
355 session
356 .data
357 .insert("payout".to_string(), state.payout.to_string());
358 if let Some(crash_point) = state.crash_point {
359 session
360 .data
361 .insert("crash_point".to_string(), format!("{crash_point:.2}"));
362 }
363 }
364
365 /// Redraw the live multiplier from the client's local clock.
366 pub fn set_crash_multiplier(&mut self, multiplier: f64) {
367 let Some(session) = self.current_session.as_mut() else {
368 return;
369 };
370 let bet = session.bet_amount;
371 session
372 .data
373 .insert("multiplier".to_string(), format!("{multiplier:.2}"));
374 session
375 .data
376 .insert("potential".to_string(), payout(bet, multiplier).to_string());
377 }
378
379 pub fn set_crash_status(&mut self, status: &str) {
380 if let Some(session) = self.current_session.as_mut() {
381 session
382 .data
383 .insert("status".to_string(), status.to_string());
384 }
385 }
386
387 pub fn crash_status(&self) -> String {
388 self.current_session
389 .as_ref()
390 .and_then(|session| session.data.get("status"))
391 .cloned()
392 .unwrap_or_else(|| "ready".to_string())
393 }
394
395 pub fn crash_session_id(&self) -> Option<String> {
396 self.current_session
397 .as_ref()
398 .and_then(|session| session.data.get("session_id"))
399 .cloned()
400 }
401
312 pub fn get_current_game(&self) -> Option<&dyn Game> { 402 pub fn get_current_game(&self) -> Option<&dyn Game> {
313 self.current_session 403 self.current_session
314 .as_ref() 404 .as_ref()
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs
index 40ba32a..e966a25 100644
--- a/src/client/games/mod.rs
+++ b/src/client/games/mod.rs
@@ -1,4 +1,5 @@
1pub mod blackjack; 1pub mod blackjack;
2pub mod crash;
2pub mod manager; 3pub mod manager;
3pub mod response; 4pub mod response;
4pub mod roulette; 5pub mod roulette;
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
index d9883f7..71e1fed 100644
--- a/src/client/games/response.rs
+++ b/src/client/games/response.rs
@@ -163,3 +163,24 @@ pub fn parse_blackjack_response(
163 .ok_or_else(|| format!("Unexpected server response: {response}"))?; 163 .ok_or_else(|| format!("Unexpected server response: {response}"))?;
164 serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}")) 164 serde_json::from_str(raw).map_err(|error| format!("Invalid blackjack state: {error}"))
165} 165}
166
167pub fn parse_crash_response(response: &str) -> Result<crate::crash::CrashState, String> {
168 let response = response.trim();
169 if response.is_empty() {
170 return Err("No response from server".to_string());
171 }
172 if let Some(message) = response.strip_prefix("ERROR") {
173 let message = message.trim();
174 return Err(if message.is_empty() {
175 "Server error".to_string()
176 } else {
177 message.to_string()
178 });
179 }
180
181 let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "]
182 .iter()
183 .find_map(|prefix| response.strip_prefix(prefix))
184 .ok_or_else(|| format!("Unexpected server response: {response}"))?;
185 serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}"))
186}