summaryrefslogtreecommitdiffstats
path: root/src/client
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:28:20 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:28:20 +0100
commit86d5f832c8452aad2313489b82eb8fd514215b95 (patch)
tree596a9be39c3d91ebc20cc8b877549e3d10895901 /src/client
parentv0.2.2: split client/server logic into the library (diff)
downloadzagan-bet-86d5f832c8452aad2313489b82eb8fd514215b95.tar.gz
zagan-bet-86d5f832c8452aad2313489b82eb8fd514215b95.zip
v0.2.3: add ClientCore and fix the game board rendering
Diffstat (limited to '')
-rw-r--r--src/client/core.rs494
-rw-r--r--src/client/games/manager.rs140
-rw-r--r--src/client/games/mod.rs1
-rw-r--r--src/client/games/response.rs60
-rw-r--r--src/client/games/slots.rs58
-rw-r--r--src/client/mod.rs2
-rw-r--r--src/client/ui.rs143
7 files changed, 831 insertions, 67 deletions
diff --git a/src/client/core.rs b/src/client/core.rs
new file mode 100644
index 0000000..0e54fba
--- /dev/null
+++ b/src/client/core.rs
@@ -0,0 +1,494 @@
1use crate::client::games::manager::GameManager;
2use crate::client::games::response::parse_spin_response;
3use crate::client::ui::{
4 command_names, help_lines, input_cursor_column, parse_bet_arg, record_history,
5};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Key {
9 Char(char),
10 Enter,
11 Backspace,
12 Up,
13 Down,
14 Tab,
15 Esc,
16 CtrlC,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum InteractiveCommand {
21 Login,
22 Register,
23 SetCred,
24 Connect(Option<String>),
25}
26
27/// High-level actions produced by the client core. The terminal adapter is
28/// responsible for turning these into crossterm calls and network writes.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Effect {
31 /// Append lines to the scrolling transcript.
32 Print(Vec<String>),
33 /// Replace the game board region in place.
34 ReplaceRegion(Vec<String>),
35 /// Remove the game board region.
36 ClearRegion,
37 /// Redraw the command prompt. `text` is the input without the `> ` prefix.
38 SetPrompt { text: String, cursor_col: u16 },
39 /// Send one protocol line and feed the reply to `handle_reply`.
40 SendLine(String),
41 /// Delegate an interactive prompt flow to the terminal adapter.
42 Interactive(InteractiveCommand),
43 /// Drop the connection.
44 Disconnect,
45 /// Let the adapter read the host database.
46 ListHosts,
47 /// Let the adapter delete a host from the database.
48 ForgetHost(String),
49 /// Leave the program.
50 Quit,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Pending {
55 Spin,
56 Ping,
57 Echo,
58 Info,
59}
60
61pub struct ClientCore {
62 input: String,
63 history: Vec<String>,
64 history_index: usize,
65 game: GameManager,
66 connected: bool,
67 logged_in: Option<String>,
68 pending: Option<Pending>,
69 balance: Option<i64>,
70 host: Option<String>,
71 fingerprint: Option<String>,
72}
73
74impl ClientCore {
75 pub fn new() -> Self {
76 Self {
77 input: String::new(),
78 history: Vec::new(),
79 history_index: 0,
80 game: GameManager::new(),
81 connected: false,
82 logged_in: None,
83 pending: None,
84 balance: None,
85 host: None,
86 fingerprint: None,
87 }
88 }
89
90 pub fn set_connected(&mut self, connected: bool) {
91 self.connected = connected;
92 if !connected {
93 self.logged_in = None;
94 self.pending = None;
95 }
96 }
97
98 pub fn set_logged_in(&mut self, user: Option<String>) {
99 self.logged_in = user;
100 }
101
102 pub fn set_balance(&mut self, balance: i64) {
103 self.balance = Some(balance);
104 self.game.set_balance(balance);
105 }
106
107 pub fn set_host_info(&mut self, host: Option<String>, fingerprint: Option<String>) {
108 self.host = host;
109 self.fingerprint = fingerprint;
110 }
111
112 pub fn is_in_game(&self) -> bool {
113 self.game.is_in_game()
114 }
115
116 pub fn prompt_effect(&self) -> Option<Effect> {
117 if self.is_in_game() {
118 return None;
119 }
120 Some(Effect::SetPrompt {
121 text: self.input.clone(),
122 cursor_col: input_cursor_column(2, self.input.chars().count()),
123 })
124 }
125
126 pub fn handle_key(&mut self, key: Key) -> Vec<Effect> {
127 if self.game.is_in_game() {
128 return self.handle_game_key(key);
129 }
130
131 match key {
132 Key::CtrlC => vec![Effect::Quit],
133 Key::Enter => self.submit(),
134 Key::Backspace => {
135 self.input.pop();
136 self.prompt_effects()
137 }
138 Key::Char(c) => {
139 self.input.push(c);
140 self.prompt_effects()
141 }
142 Key::Up => self.history_previous(),
143 Key::Down => self.history_next(),
144 Key::Tab => self.complete(),
145 Key::Esc => Vec::new(),
146 }
147 }
148
149 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> {
150 match self.pending.take() {
151 Some(Pending::Spin) => match parse_spin_response(reply) {
152 Ok(result) => {
153 self.balance = Some(result.balance);
154 self.game.apply_result(result);
155 self.board_effects()
156 }
157 Err(message) => {
158 self.game.set_error(message);
159 self.board_effects()
160 }
161 },
162 Some(Pending::Ping) => {
163 if reply.trim() == "PONG" {
164 print_line("Pong")
165 } else {
166 self.connected = false;
167 self.logged_in = None;
168 vec![
169 Effect::Disconnect,
170 Effect::Print(vec![
171 "Connection lost".to_string(),
172 ]),
173 ]
174 }
175 }
176 Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())),
177 Some(Pending::Info) => {
178 if self.game.is_in_game() {
179 // Balance fetch while entering a game. A failure is not
180 // fatal: the board keeps showing `?`.
181 if let Some(balance) = info_balance(reply) {
182 self.balance = Some(balance);
183 self.game.set_balance(balance);
184 return self.board_effects();
185 }
186 Vec::new()
187 } else {
188 vec![Effect::Print(info_lines(reply))]
189 }
190 }
191 None => print_line(reply.trim()),
192 }
193 }
194
195 fn handle_game_key(&mut self, key: Key) -> Vec<Effect> {
196 match key {
197 Key::CtrlC => vec![Effect::Quit],
198 Key::Esc | Key::Char('q') => {
199 self.game.end_game();
200 vec![
201 Effect::ClearRegion,
202 Effect::Print(vec!["Game exited.".to_string()]),
203 ]
204 }
205 Key::Char(' ') => self.spin(),
206 Key::Char('+') => {
207 let _ = self.game.adjust_bet(10);
208 self.board_effects()
209 }
210 Key::Char('-') => {
211 let _ = self.game.adjust_bet(-10);
212 self.board_effects()
213 }
214 _ => Vec::new(),
215 }
216 }
217
218 fn spin(&mut self) -> Vec<Effect> {
219 let Some(request) = self.game.spin_request() else {
220 return Vec::new();
221 };
222 if self.game.begin_spin().is_err() {
223 return Vec::new();
224 }
225 self.pending = Some(Pending::Spin);
226
227 let mut effects = self.board_effects();
228 effects.push(Effect::SendLine(request));
229 effects
230 }
231
232 fn submit(&mut self) -> Vec<Effect> {
233 let line = self.input.trim().to_string();
234 self.input.clear();
235 if line.is_empty() {
236 return Vec::new();
237 }
238
239 record_history(&mut self.history, &line);
240 self.history_index = self.history.len();
241
242 let parts: Vec<&str> = line.splitn(3, ' ').collect();
243 match parts[0] {
244 "play" => self.cmd_play(&parts),
245 "games" => self.available_games(None),
246 "help" => vec![Effect::Print(help_lines())],
247 "connect" => {
248 if self.connected {
249 print_line("Already connected. Disconnect first.")
250 } else {
251 let target = parts.get(1).map(|value| value.to_string());
252 vec![Effect::Interactive(InteractiveCommand::Connect(target))]
253 }
254 }
255 "login" => {
256 if !self.connected {
257 print_line("Not connected. Use 'connect <ip>:<port>' first.")
258 } else if self.logged_in.is_some() {
259 print_line(format!(
260 "Already logged in as {}",
261 self.logged_in.as_deref().unwrap_or("?")
262 ))
263 } else {
264 vec![Effect::Interactive(InteractiveCommand::Login)]
265 }
266 }
267 "register" => {
268 if !self.connected {
269 print_line("Not connected. Use 'connect <ip>:<port>' first.")
270 } else {
271 vec![Effect::Interactive(InteractiveCommand::Register)]
272 }
273 }
274 "setcred" => {
275 if self.host.is_none() {
276 print_line("No host connected. Use 'connect <ip>:<port>' first.")
277 } else {
278 vec![Effect::Interactive(InteractiveCommand::SetCred)]
279 }
280 }
281 "logout" => {
282 self.logged_in = None;
283 print_line("Logged out")
284 }
285 "disconnect" => {
286 self.connected = false;
287 self.logged_in = None;
288 self.pending = None;
289 self.game.end_game();
290 vec![
291 Effect::ClearRegion,
292 Effect::Disconnect,
293 Effect::Print(vec!["Disconnected".to_string()]),
294 ]
295 }
296 "ping" => {
297 if !self.connected {
298 print_line("Not connected. Use 'connect <ip>:<port>' first.")
299 } else {
300 self.pending = Some(Pending::Ping);
301 vec![Effect::SendLine("PING".to_string())]
302 }
303 }
304 "send" => {
305 if !self.connected {
306 print_line("Not connected. Use 'connect <ip>:<port>' first.")
307 } else if parts.len() < 2 {
308 print_line("Usage: send <message>")
309 } else {
310 let message = parts[1..].join(" ");
311 self.pending = Some(Pending::Echo);
312 vec![Effect::SendLine(message)]
313 }
314 }
315 "info" => self.cmd_info(),
316 "hosts" => vec![Effect::ListHosts],
317 "forget-host" => {
318 let target = parts
319 .get(1)
320 .map(|value| value.to_string())
321 .or_else(|| self.host.clone());
322 match target {
323 Some(target) => vec![Effect::ForgetHost(target)],
324 None => print_line("Usage: forget-host <ip>:<port> (or connect first)"),
325 }
326 }
327 "quit" => vec![Effect::Quit],
328 _ => vec![Effect::Print(help_lines())],
329 }
330 }
331
332 fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> {
333 if !self.connected {
334 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
335 }
336 if self.logged_in.is_none() {
337 return print_line("Not logged in. Use 'login' first.");
338 }
339 if parts.len() < 2 {
340 return self.available_games(Some("Usage: play <game> [bet]"));
341 }
342
343 let bet = match parts.get(2) {
344 Some(raw) => match parse_bet_arg(raw) {
345 Ok(bet) => Some(bet),
346 Err(message) => return print_line(message),
347 },
348 None => None,
349 };
350
351 if self.game.is_in_game() {
352 return print_line("Already in a game. Use 'quit' to exit current game.");
353 }
354
355 match self.game.enter_game(parts[1]) {
356 Ok(()) => {
357 if let Some(bet) = bet {
358 let _ = self.game.set_bet(bet);
359 }
360 if let Some(balance) = self.balance {
361 self.game.set_balance(balance);
362 }
363
364 let mut effects = self.board_effects();
365 self.pending = Some(Pending::Info);
366 effects.push(Effect::SendLine("INFO".to_string()));
367 effects
368 }
369 Err(_) => print_line("Failed to enter game"),
370 }
371 }
372
373 fn available_games(&self, usage: Option<&str>) -> Vec<Effect> {
374 let mut lines = vec!["Available games:".to_string()];
375 for (game_type, name) in self.game.get_available_games() {
376 lines.push(format!(" - {} ({})", game_type, name));
377 }
378 if let Some(usage) = usage {
379 lines.push(String::new());
380 lines.push(usage.to_string());
381 }
382 vec![Effect::Print(lines)]
383 }
384
385 fn cmd_info(&mut self) -> Vec<Effect> {
386 if !self.connected {
387 return print_line("Not connected to any host.");
388 }
389
390 let mut effects = Vec::new();
391 match &self.host {
392 Some(host) => effects.push(Effect::Print(vec![format!("Connected to: {host}")])),
393 None => effects.push(Effect::Print(vec!["Connected to: (unknown)".to_string()])),
394 }
395 match &self.fingerprint {
396 Some(fingerprint) => {
397 effects.push(Effect::Print(vec![format!("Fingerprint: {fingerprint}")]))
398 }
399 None => effects.push(Effect::Print(vec![
400 "Fingerprint: (not trusted)".to_string(),
401 ])),
402 }
403
404 self.pending = Some(Pending::Info);
405 effects.push(Effect::SendLine("INFO".to_string()));
406 effects
407 }
408
409 fn history_previous(&mut self) -> Vec<Effect> {
410 if self.history_index == 0 {
411 return Vec::new();
412 }
413 self.history_index -= 1;
414 self.input = self.history[self.history_index].clone();
415 self.prompt_effects()
416 }
417
418 fn history_next(&mut self) -> Vec<Effect> {
419 if self.history_index >= self.history.len() {
420 return Vec::new();
421 }
422 self.history_index += 1;
423 self.input = if self.history_index == self.history.len() {
424 String::new()
425 } else {
426 self.history[self.history_index].clone()
427 };
428 self.prompt_effects()
429 }
430
431 fn complete(&mut self) -> Vec<Effect> {
432 let prefix = self.input.trim();
433 let matches: Vec<&'static str> = command_names()
434 .into_iter()
435 .filter(|command| command.starts_with(prefix))
436 .collect();
437
438 let mut effects = Vec::new();
439 if matches.len() == 1 {
440 self.input = matches[0].to_string();
441 } else if matches.len() > 1 {
442 let mut lines = vec!["Possible completions:".to_string()];
443 lines.push(matches.join(" "));
444 effects.push(Effect::Print(lines));
445 }
446 if let Some(prompt) = self.prompt_effect() {
447 effects.push(prompt);
448 }
449 effects
450 }
451
452 fn board_lines(&self) -> Option<Vec<String>> {
453 let game = self.game.get_current_game()?;
454 let state = self.game.get_current_state()?;
455 Some(game.render(state).lines().map(str::to_string).collect())
456 }
457
458 fn board_effects(&self) -> Vec<Effect> {
459 self.board_lines()
460 .map(Effect::ReplaceRegion)
461 .into_iter()
462 .collect()
463 }
464
465 fn prompt_effects(&self) -> Vec<Effect> {
466 self.prompt_effect().into_iter().collect()
467 }
468}
469
470fn print_line(message: impl Into<String>) -> Vec<Effect> {
471 vec![Effect::Print(vec![message.into()])]
472}
473
474fn info_balance(reply: &str) -> Option<i64> {
475 let parts: Vec<&str> = reply.split_whitespace().collect();
476 if parts.first() == Some(&"INFO") && parts.len() == 4 {
477 parts[3].parse().ok()
478 } else {
479 None
480 }
481}
482
483fn info_lines(reply: &str) -> Vec<String> {
484 let parts: Vec<&str> = reply.split_whitespace().collect();
485 if parts.first() == Some(&"INFO") && parts.len() == 4 {
486 vec![
487 format!("User: {}", parts[1]),
488 format!("Account: {}", parts[2]),
489 format!("Balance: {} PLN", parts[3]),
490 ]
491 } else {
492 vec![reply.trim().to_string()]
493 }
494}
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index be96b1a..71cd28c 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -1,11 +1,10 @@
1use super::response::SpinResult;
1use super::slots::SlotsGame; 2use super::slots::SlotsGame;
2use super::traits::{Game, GameError, GamePhase, GameState}; 3use super::traits::{Game, GameError, GamePhase, GameState};
3use std::collections::HashMap; 4use std::collections::HashMap;
4use std::io::Write;
5 5
6pub struct GameManager { 6pub struct GameManager {
7 current_session: Option<GameState>, 7 current_session: Option<GameState>,
8 pending_game_type: Option<String>,
9 games: HashMap<String, Box<dyn Game>>, 8 games: HashMap<String, Box<dyn Game>>,
10} 9}
11 10
@@ -16,16 +15,18 @@ impl GameManager {
16 15
17 Self { 16 Self {
18 current_session: None, 17 current_session: None,
19 pending_game_type: None,
20 games, 18 games,
21 } 19 }
22 } 20 }
23 21
24 pub fn get_available_games(&self) -> Vec<(&str, &str)> { 22 pub fn get_available_games(&self) -> Vec<(&str, &str)> {
25 self.games 23 let mut games: Vec<(&str, &str)> = self
24 .games
26 .iter() 25 .iter()
27 .map(|(k, v)| (k.as_str(), v.get_name())) 26 .map(|(key, game)| (key.as_str(), game.get_name()))
28 .collect() 27 .collect();
28 games.sort_unstable_by_key(|(key, _)| *key);
29 games
29 } 30 }
30 31
31 pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { 32 pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> {
@@ -37,8 +38,6 @@ impl GameManager {
37 return Err(GameError::InvalidAction); 38 return Err(GameError::InvalidAction);
38 } 39 }
39 40
40 self.pending_game_type = Some(game_type.to_string());
41
42 self.current_session = Some(GameState { 41 self.current_session = Some(GameState {
43 game_type: game_type.to_string(), 42 game_type: game_type.to_string(),
44 bet_amount: 10, 43 bet_amount: 10,
@@ -49,28 +48,75 @@ impl GameManager {
49 Ok(()) 48 Ok(())
50 } 49 }
51 50
52 pub fn start_game<W: Write>(&mut self, stream: &mut W) -> Result<(), GameError> { 51 /// The wire request for a spin, if the current phase allows one.
53 let game_type = self 52 pub fn spin_request(&self) -> Option<String> {
54 .pending_game_type 53 let session = self.current_session.as_ref()?;
55 .as_ref() 54 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
56 .ok_or(GameError::InvalidAction)?; 55 return None;
56 }
57 Some(format!(
58 "PLAY_START {} {}",
59 session.game_type, session.bet_amount
60 ))
61 }
57 62
58 let state = self 63 /// Move the board into its spinning state and drop the previous result
64 /// so stale reels/winnings cannot leak into the new spin.
65 pub fn begin_spin(&mut self) -> Result<(), GameError> {
66 let session = self
59 .current_session 67 .current_session
60 .as_mut() 68 .as_mut()
61 .ok_or(GameError::InvalidAction)?; 69 .ok_or(GameError::InvalidAction)?;
62 70 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
63 if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished {
64 return Err(GameError::InvalidAction); 71 return Err(GameError::InvalidAction);
65 } 72 }
66 73
67 let bet_amount = state.bet_amount; 74 session.phase = GamePhase::Playing;
75 session.data.remove("error");
76 session.data.remove("reels");
77 session.data.remove("win_type");
78 session.data.remove("winnings");
79 Ok(())
80 }
68 81
69 let writer = stream; 82 /// Store the settled spin so `render()` can draw it in place.
70 writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) 83 pub fn apply_result(&mut self, result: SpinResult) {
71 .map_err(|_| GameError::NetworkError)?; 84 let Some(session) = self.current_session.as_mut() else {
85 return;
86 };
87 session.phase = GamePhase::Finished;
88 session.bet_amount = result.bet_amount;
89 session.data.remove("error");
90 session.data.insert("reels".to_string(), result.reels);
91 session
92 .data
93 .insert("win_type".to_string(), result.win_type);
94 session
95 .data
96 .insert("winnings".to_string(), result.winnings.to_string());
97 session
98 .data
99 .insert("balance".to_string(), result.balance.to_string());
100 }
72 101
73 Ok(()) 102 /// Show a failed spin without leaving the player stuck in `Playing`.
103 pub fn set_error(&mut self, message: impl Into<String>) {
104 let Some(session) = self.current_session.as_mut() else {
105 return;
106 };
107 session.phase = GamePhase::WaitingToStart;
108 session.data.remove("reels");
109 session.data.remove("win_type");
110 session.data.remove("winnings");
111 session.data.insert("error".to_string(), message.into());
112 }
113
114 pub fn set_balance(&mut self, balance: i64) {
115 if let Some(session) = self.current_session.as_mut() {
116 session
117 .data
118 .insert("balance".to_string(), balance.to_string());
119 }
74 } 120 }
75 121
76 pub fn get_current_game(&self) -> Option<&dyn Game> { 122 pub fn get_current_game(&self) -> Option<&dyn Game> {
@@ -83,49 +129,37 @@ impl GameManager {
83 self.current_session.as_ref() 129 self.current_session.as_ref()
84 } 130 }
85 131
86 pub fn get_current_render_lines(&self) -> Option<usize> {
87 let session = self.current_session.as_ref()?;
88 let game = self.games.get(&session.game_type)?;
89 Some(game.render_lines(session))
90 }
91
92 pub fn is_in_game(&self) -> bool { 132 pub fn is_in_game(&self) -> bool {
93 self.current_session.is_some() 133 self.current_session.is_some()
94 } 134 }
95 135
96 pub fn is_game_started(&self) -> bool { 136 pub fn end_game(&mut self) {
97 self.current_session 137 self.current_session = None;
98 .as_ref()
99 .map(|s| s.phase != GamePhase::WaitingToStart)
100 .unwrap_or(false)
101 } 138 }
102 139
103 pub fn reset_phase_to_waiting(&mut self) { 140 pub fn set_bet(&mut self, bet: u64) -> Result<u64, GameError> {
104 if let Some(ref mut session) = self.current_session { 141 let session = self
105 session.phase = GamePhase::WaitingToStart; 142 .current_session
106 session.data.clear(); 143 .as_mut()
144 .ok_or(GameError::InvalidAction)?;
145 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
146 return Err(GameError::InvalidAction);
107 } 147 }
108 } 148 session.bet_amount = bet.max(10);
109 149 Ok(session.bet_amount)
110 pub fn end_game(&mut self) {
111 self.current_session = None;
112 self.pending_game_type = None;
113 } 150 }
114 151
115 pub fn adjust_bet(&mut self, delta: i64) -> Result<u64, GameError> { 152 pub fn adjust_bet(&mut self, delta: i64) -> Result<u64, GameError> {
116 if let Some(ref session) = self.current_session { 153 let session = self
117 if session.phase != GamePhase::WaitingToStart { 154 .current_session
118 return Err(GameError::InvalidAction); 155 .as_mut()
119 } 156 .ok_or(GameError::InvalidAction)?;
157 if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished {
158 return Err(GameError::InvalidAction);
120 } 159 }
121 160
122 if let Some(ref mut session) = self.current_session { 161 let current_bet = session.bet_amount as i64;
123 let current_bet = session.bet_amount as i64; 162 session.bet_amount = (current_bet + delta).max(10) as u64;
124 let new_bet = (current_bet + delta).max(10) as u64; 163 Ok(session.bet_amount)
125 session.bet_amount = new_bet;
126 Ok(new_bet)
127 } else {
128 Err(GameError::InvalidAction)
129 }
130 } 164 }
131} 165}
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs
index aa74574..63cc579 100644
--- a/src/client/games/mod.rs
+++ b/src/client/games/mod.rs
@@ -1,3 +1,4 @@
1pub mod manager; 1pub mod manager;
2pub mod response;
2pub mod slots; 3pub mod slots;
3pub mod traits; 4pub mod traits;
diff --git a/src/client/games/response.rs b/src/client/games/response.rs
new file mode 100644
index 0000000..6948805
--- /dev/null
+++ b/src/client/games/response.rs
@@ -0,0 +1,60 @@
1#[derive(Debug, Clone, PartialEq, Eq)]
2pub struct SpinResult {
3 pub session_id: String,
4 pub winnings: u64,
5 pub bet_amount: u64,
6 pub balance: i64,
7 pub reels: String,
8 pub win_type: String,
9}
10
11/// Parse one server reply to `PLAY_START`.
12///
13/// `Ok` carries the settled spin; `Err` carries a message that is safe to
14/// show to the player (server errors are reported without the `ERROR ` prefix).
15pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> {
16 let response = response.trim();
17 if response.is_empty() {
18 return Err("No response from server".to_string());
19 }
20
21 if let Some(message) = response.strip_prefix("ERROR") {
22 let message = message.trim();
23 return Err(if message.is_empty() {
24 "Server error".to_string()
25 } else {
26 message.to_string()
27 });
28 }
29
30 let parts: Vec<&str> = response.split_whitespace().collect();
31 if parts.first() != Some(&"GAME_RESULT") {
32 return Err(format!("Unexpected server response: {response}"));
33 }
34 if parts.len() < 7 {
35 return Err("Malformed GAME_RESULT from server".to_string());
36 }
37
38 let winnings = parts[2]
39 .parse::<u64>()
40 .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?;
41 let bet_amount = parts[3]
42 .parse::<u64>()
43 .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?;
44 let balance = parts[4]
45 .parse::<i64>()
46 .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?;
47
48 if parts[5].chars().count() != 3 {
49 return Err(format!("Invalid reels from server: {}", parts[5]));
50 }
51
52 Ok(SpinResult {
53 session_id: parts[1].to_string(),
54 winnings,
55 bet_amount,
56 balance,
57 reels: parts[5].to_string(),
58 win_type: parts[6].to_string(),
59 })
60}
diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs
index 6e2ebbe..5eed34d 100644
--- a/src/client/games/slots.rs
+++ b/src/client/games/slots.rs
@@ -1,4 +1,8 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GamePhase, GameState};
2
3/// Height of the game board. It must never depend on the spin result, or an
4/// in-place redraw would move to the wrong line.
5pub const SLOT_BOARD_LINES: usize = 7;
2 6
3pub struct SlotsGame; 7pub struct SlotsGame;
4 8
@@ -8,17 +12,36 @@ impl SlotsGame {
8 } 12 }
9 13
10 fn render_reels(&self, reels: &str) -> String { 14 fn render_reels(&self, reels: &str) -> String {
11 if reels.is_empty() { 15 let mut chars = reels.chars();
12 "[ ? ] [ ? ] [ ? ]".to_string() 16 format!(
13 } else { 17 "[ {} ] [ {} ] [ {} ]",
14 let chars: Vec<char> = reels.chars().collect(); 18 chars.next().unwrap_or('?'),
15 format!( 19 chars.next().unwrap_or('?'),
16 "[ {} ] [ {} ] [ {} ]\r", 20 chars.next().unwrap_or('?')
17 chars.get(0).unwrap_or(&'?'), 21 )
18 chars.get(1).unwrap_or(&'?'), 22 }
19 chars.get(2).unwrap_or(&'?') 23
20 ) 24 /// The fifth board line: current status, result, or error.
25 fn render_status(&self, state: &GameState) -> String {
26 if let Some(error) = state.data.get("error") {
27 return format!("ERROR: {error}");
28 }
29 if state.phase == GamePhase::Playing {
30 return "Spinning...".to_string();
31 }
32 if state.phase == GamePhase::Finished {
33 let winnings = state
34 .data
35 .get("winnings")
36 .map(String::as_str)
37 .unwrap_or("0");
38 return match state.data.get("win_type").map(String::as_str) {
39 Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"),
40 Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"),
41 _ => "No win.".to_string(),
42 };
21 } 43 }
44 "Press [SPACE] to spin!".to_string()
22 } 45 }
23} 46}
24 47
@@ -29,16 +52,23 @@ impl Game for SlotsGame {
29 52
30 fn render(&self, state: &GameState) -> String { 53 fn render(&self, state: &GameState) -> String {
31 let reels = state.data.get("reels").cloned().unwrap_or_default(); 54 let reels = state.data.get("reels").cloned().unwrap_or_default();
55 let balance = state
56 .data
57 .get("balance")
58 .map(String::as_str)
59 .unwrap_or("?");
32 60
33 format!( 61 format!(
34 "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", 62 "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit",
35 state.bet_amount, 63 state.bet_amount,
36 self.render_reels(&reels) 64 balance,
65 self.render_reels(&reels),
66 self.render_status(state)
37 ) 67 )
38 } 68 }
39 69
40 fn render_lines(&self, _state: &GameState) -> usize { 70 fn render_lines(&self, _state: &GameState) -> usize {
41 6 71 SLOT_BOARD_LINES
42 } 72 }
43 73
44 fn get_help(&self) -> String { 74 fn get_help(&self) -> String {
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 5b38158..dc8423b 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -1,2 +1,4 @@
1pub mod core;
1pub mod database; 2pub mod database;
2pub mod games; 3pub mod games;
4pub mod ui;
diff --git a/src/client/ui.rs b/src/client/ui.rs
new file mode 100644
index 0000000..036c36a
--- /dev/null
+++ b/src/client/ui.rs
@@ -0,0 +1,143 @@
1//! Pure text/history helpers for the client TUI.
2//!
3//! Nothing here touches the terminal, so it is unit-tested from `tests/`
4//! without a PTY.
5
6struct CommandHelp {
7 name: &'static str,
8 usage: &'static str,
9 description: &'static str,
10}
11
12const COMMANDS: &[CommandHelp] = &[
13 CommandHelp {
14 name: "connect",
15 usage: "connect <ip>:<port>",
16 description: "connect to the server",
17 },
18 CommandHelp {
19 name: "send",
20 usage: "send <message>",
21 description: "send a message to the server",
22 },
23 CommandHelp {
24 name: "disconnect",
25 usage: "disconnect",
26 description: "disconnect from the server",
27 },
28 CommandHelp {
29 name: "ping",
30 usage: "ping",
31 description: "ping the server",
32 },
33 CommandHelp {
34 name: "login",
35 usage: "login",
36 description: "login to the server",
37 },
38 CommandHelp {
39 name: "register",
40 usage: "register",
41 description: "register a new user",
42 },
43 CommandHelp {
44 name: "setcred",
45 usage: "setcred",
46 description: "set credentials for a host",
47 },
48 CommandHelp {
49 name: "logout",
50 usage: "logout",
51 description: "logout from the server",
52 },
53 CommandHelp {
54 name: "info",
55 usage: "info",
56 description: "show account and connection info",
57 },
58 CommandHelp {
59 name: "play",
60 usage: "play <game> [bet]",
61 description: "start a game (e.g. play slots 50)",
62 },
63 CommandHelp {
64 name: "games",
65 usage: "games",
66 description: "list available games",
67 },
68 CommandHelp {
69 name: "help",
70 usage: "help",
71 description: "show this message",
72 },
73 CommandHelp {
74 name: "forget-host",
75 usage: "forget-host [ip:port]",
76 description: "clear saved trust fingerprint and credentials",
77 },
78 CommandHelp {
79 name: "hosts",
80 usage: "hosts",
81 description: "list remembered hosts",
82 },
83 CommandHelp {
84 name: "quit",
85 usage: "quit",
86 description: "quit the program",
87 },
88];
89
90pub fn command_names() -> Vec<&'static str> {
91 COMMANDS.iter().map(|command| command.name).collect()
92}
93
94pub fn help_lines() -> Vec<String> {
95 let mut lines = vec!["Available commands:".to_string()];
96 for command in COMMANDS {
97 lines.push(format!(
98 " {:<28} - {}",
99 command.usage, command.description
100 ));
101 }
102 lines
103}
104
105/// Add a command to the history, skipping blanks and consecutive duplicates.
106pub fn record_history(history: &mut Vec<String>, line: &str) {
107 let line = line.trim();
108 if line.is_empty() {
109 return;
110 }
111 if history.last().is_some_and(|last| last == line) {
112 return;
113 }
114 history.push(line.to_string());
115}
116
117/// Parse the optional bet argument of `play <game> [bet]`.
118pub fn parse_bet_arg(raw: &str) -> Result<u64, String> {
119 let bet = raw
120 .trim()
121 .parse::<u64>()
122 .map_err(|_| format!("Invalid bet: {raw}"))?;
123 if bet < 10 {
124 return Err("Bet must be at least 10 PLN".to_string());
125 }
126 Ok(bet)
127}
128
129/// Lines to move the cursor up to reach the first line of a region that ends
130/// on the current line. `render()` has no trailing newline, so a 7-line board
131/// needs `MoveUp(6)`.
132pub fn lines_to_move_up(previous_lines: usize) -> u16 {
133 previous_lines
134 .saturating_sub(1)
135 .min(u16::MAX as usize) as u16
136}
137
138/// Column (0-based) to move to after the given prompt and input text.
139pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 {
140 prompt_len
141 .saturating_add(input_len)
142 .min(u16::MAX as usize) as u16
143}