summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--Cargo.toml2
-rw-r--r--src/bin/client/repl.rs1074
-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
-rw-r--r--tests/client_core.rs216
10 files changed, 1382 insertions, 808 deletions
diff --git a/Cargo.toml b/Cargo.toml
index a5b6a2c..33f7700 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.2.2" 3version = "0.2.3"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index 24605e2..e9d32a0 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -1,34 +1,34 @@
1use rusqlite::Connection; 1use rusqlite::Connection;
2use std::io::{stdout, BufRead, BufReader, Write}; 2use std::io::{stdout, Write};
3use std::sync::{Arc, Mutex}; 3use std::sync::{Arc, Mutex};
4use std::time::Instant;
5
6use crate::connection::TlsStream;
7use zagan_bet::client::database;
8use zagan_bet::client::games::manager::GameManager;
9use zagan_bet::client::games::traits::GamePhase;
10 4
11use crossterm::{ 5use crossterm::{
12 cursor::{Hide, MoveToColumn, Show}, 6 cursor::{MoveToColumn, MoveUp, Show},
13 event::{read, Event, KeyCode, KeyModifiers}, 7 event::{read, Event, KeyCode, KeyEventKind, KeyModifiers},
14 execute, 8 execute,
15 terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, 9 terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
16}; 10};
17 11
18use sha2::{Digest, Sha256}; 12use sha2::{Digest, Sha256};
19 13
14use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
15use zagan_bet::client::database;
16use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up};
17
18use crate::connection::TlsStream;
19
20pub struct ClientState { 20pub struct ClientState {
21 pub stream: Option<TlsStream>, 21 pub stream: Option<TlsStream>,
22 pub conn: Connection, 22 pub conn: Connection,
23 pub addr: Option<String>, 23 pub addr: Option<String>,
24 pub default_port: u16, 24 pub default_port: u16,
25 pub history: Vec<String>, 25 core: ClientCore,
26 pub history_index: usize,
27 pub logged_in_user: Option<String>,
28 pub game_manager: GameManager,
29 cleanup_done: Arc<Mutex<bool>>, 26 cleanup_done: Arc<Mutex<bool>>,
30 in_game_mode: bool, 27 /// Number of terminal lines currently occupied by the game board.
31 spinning: bool, 28 game_lines: usize,
29 /// Text last drawn after the `> ` prompt, used to keep the command echo
30 /// in the transcript before printing command output.
31 prompt_text: String,
32} 32}
33 33
34impl ClientState { 34impl ClientState {
@@ -49,128 +49,40 @@ impl ClientState {
49 conn, 49 conn,
50 addr, 50 addr,
51 default_port, 51 default_port,
52 history: vec![], 52 core: ClientCore::new(),
53 history_index: 0,
54 logged_in_user: None,
55 game_manager: GameManager::new(),
56 cleanup_done: Arc::new(Mutex::new(false)), 53 cleanup_done: Arc::new(Mutex::new(false)),
57 in_game_mode: false, 54 game_lines: 0,
58 spinning: false, 55 prompt_text: String::new(),
59 }
60 }
61
62 pub fn enter_game_mode(&mut self) {
63 self.in_game_mode = true;
64 }
65
66 pub fn exit_game_mode(&mut self) {
67 self.in_game_mode = false;
68 self.game_manager.end_game();
69 }
70
71 pub fn handle_game_key(&mut self, key_code: KeyCode) -> bool {
72 if !self.game_manager.is_in_game() {
73 return false;
74 }
75
76 match key_code {
77 KeyCode::Esc | KeyCode::Char('q') => {
78 self.exit_game_mode();
79 println!("\nGame exited.");
80 return true;
81 }
82 KeyCode::Char(' ') => {
83 if self.spinning {
84 return true; // Discard keypress while busy
85 }
86 let can_spin = self.game_manager.get_current_state()
87 .map(|s| s.phase == GamePhase::WaitingToStart || s.phase == GamePhase::Finished)
88 .unwrap_or(false);
89 if can_spin {
90 if let Some(mut stream) = self.stream.take() {
91 self.spinning = true;
92 match self.game_manager.start_game(&mut stream) {
93 Ok(()) => {
94 let response = self.read_server_response(&mut stream);
95 if response.trim().starts_with("ERROR") {
96 println!("\n{}", response.trim());
97 } else {
98 self.play_spin_animation(&response);
99 }
100 self.game_manager.reset_phase_to_waiting();
101 }
102 Err(e) => println!("\nError starting game: {:?}", e),
103 }
104 self.stream = Some(stream);
105 // Drain buffered key events BEFORE clearing spinning
106 // to prevent queued spins from triggering immediately
107 while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
108 let _ = crossterm::event::read();
109 }
110 self.spinning = false;
111 }
112 }
113 return true;
114 }
115 KeyCode::Char('+') => {
116 // Increase bet
117 match self.game_manager.adjust_bet(10) {
118 Ok(_new_bet) => {
119 if let Some(state) = self.game_manager.get_current_state() {
120 if let Some(game) = self.game_manager.get_current_game() {
121 let lines =
122 self.game_manager.get_current_render_lines().unwrap_or(8);
123 redraw_game_lines(&game.render(state), lines);
124 }
125 }
126 }
127 Err(e) => println!("\nError: {:?}", e),
128 }
129 return true;
130 }
131 KeyCode::Char('-') => {
132 // Decrease bet
133 match self.game_manager.adjust_bet(-10) {
134 Ok(_new_bet) => {
135 if let Some(state) = self.game_manager.get_current_state() {
136 if let Some(game) = self.game_manager.get_current_game() {
137 let lines =
138 self.game_manager.get_current_render_lines().unwrap_or(8);
139 redraw_game_lines(&game.render(state), lines);
140 }
141 }
142 }
143 Err(e) => println!("\nError: {:?}", e),
144 }
145 return true;
146 }
147 _ => return true, // Lock into game mode: ignore unknown keys
148 } 56 }
149 } 57 }
150 58
151 pub fn connect_to(&mut self, addr: &str) { 59 pub fn connect_to(&mut self, addr: &str) {
152 self.addr = Some(addr.to_string()); 60 self.addr = Some(addr.to_string());
153 match crate::connection::connect(addr, &self.conn) { 61 match crate::connection::connect(addr, &self.conn) {
154 Ok((s, authed_user)) => { 62 Ok((stream, authed_user)) => {
155 self.stream = Some(s); 63 self.stream = Some(stream);
156 self.logged_in_user = authed_user; 64 self.core.set_connected(true);
65 self.core.set_logged_in(authed_user);
66 let fingerprint = database::get_fingerprint(&self.conn, addr);
67 self.core
68 .set_host_info(Some(addr.to_string()), fingerprint);
157 database::save_host(&self.conn, addr); 69 database::save_host(&self.conn, addr);
158 } 70 }
159 Err(e) => println!("\nFailed to connect: {}", e), 71 Err(e) => {
72 self.core.set_connected(false);
73 println!("\nFailed to connect: {}", e);
74 }
160 } 75 }
161 } 76 }
162 77
163 pub fn run(&mut self) { 78 pub fn run(&mut self) {
164 // Set up cleanup handlers
165 let cleanup_done = self.cleanup_done.clone(); 79 let cleanup_done = self.cleanup_done.clone();
166 80
167 // Set up panic handler
168 let panic_cleanup = cleanup_done.clone(); 81 let panic_cleanup = cleanup_done.clone();
169 std::panic::set_hook(Box::new(move |_| { 82 std::panic::set_hook(Box::new(move |_| {
170 restore_terminal(&panic_cleanup); 83 restore_terminal(&panic_cleanup);
171 })); 84 }));
172 85
173 // Set up Ctrl+C handler
174 let ctrlc_cleanup = cleanup_done.clone(); 86 let ctrlc_cleanup = cleanup_done.clone();
175 if let Err(e) = ctrlc::set_handler(move || { 87 if let Err(e) = ctrlc::set_handler(move || {
176 restore_terminal(&ctrlc_cleanup); 88 restore_terminal(&ctrlc_cleanup);
@@ -180,653 +92,331 @@ impl ClientState {
180 } 92 }
181 93
182 enable_raw_mode().unwrap(); 94 enable_raw_mode().unwrap();
183 'outer: loop { 95 self.apply_prompt();
184 let mut current_input = String::new();
185 self.history_index = self.history.len();
186 redraw("");
187 loop {
188 let event = read().unwrap();
189 match event {
190 Event::Key(key) => {
191 // Only handle key press events, not release events
192 if key.kind == crossterm::event::KeyEventKind::Press {
193 // Handle game mode keys first
194 if self.game_manager.is_in_game() && self.handle_game_key(key.code) {
195 // Game key was handled, continue loop
196 if !self.game_manager.is_in_game() {
197 // Game ended, redraw command prompt
198 redraw("");
199 }
200 continue;
201 }
202 96
203 // Handle normal command mode keys 97 loop {
204 match key.code { 98 let event = read().unwrap();
205 KeyCode::Up => { 99 if let Event::Key(key) = event {
206 if self.history_index > 0 { 100 if key.kind != KeyEventKind::Press {
207 self.history_index -= 1; 101 continue;
208 current_input = self.history[self.history_index].clone(); 102 }
209 redraw(&current_input); 103 let Some(key) = map_key(key) else {
210 } 104 continue;
211 } 105 };
212 KeyCode::Down => { 106 let effects = self.core.handle_key(key);
213 if self.history_index < self.history.len() { 107 if self.apply_effects(effects) {
214 self.history_index += 1; 108 break;
215 current_input = if self.history_index == self.history.len()
216 {
217 "".to_string()
218 } else {
219 self.history[self.history_index].clone()
220 };
221 redraw(&current_input);
222 }
223 }
224 KeyCode::Enter => {
225 let line = current_input.trim().to_string();
226 if !line.is_empty() {
227 self.history.push(line.clone());
228 }
229 let line_trimmed = current_input.trim().to_string();
230 if !line_trimmed.is_empty() {
231 self.history.push(line_trimmed.clone());
232 }
233
234 // Process normal commands
235 let parts: Vec<&str> = line_trimmed.splitn(3, ' ').collect();
236 match parts[0] {
237 "play" => {
238 if self.stream.is_none() {
239 println!("\nNot connected. Use 'connect <ip>:<port>' first.");
240 continue 'outer;
241 }
242 if self.logged_in_user.is_none() {
243 println!("\nNot logged in. Use 'login' first.");
244 continue 'outer;
245 }
246 if parts.len() < 2 {
247 let games = self.game_manager.get_available_games();
248 println!("\nAvailable games:");
249 for (gt, name) in games {
250 println!(" - {} ({})", gt, name);
251 }
252 println!("\nUsage: play <game>");
253 continue 'outer;
254 }
255 let game_type = parts[1];
256
257 if self.game_manager.is_in_game() {
258 println!("\nAlready in a game. Use 'quit' to exit current game.");
259 continue 'outer;
260 }
261
262 match self.game_manager.enter_game(game_type) {
263 Ok(()) => {
264 self.enter_game_mode();
265 if let Some(game) =
266 self.game_manager.get_current_game()
267 {
268 if let Some(state) =
269 self.game_manager.get_current_state()
270 {
271 redraw_game(&game.render(state));
272 }
273 }
274 }
275 Err(e) => {
276 println!("\nFailed to enter game: {:?}", e);
277 }
278 }
279 }
280 "games" => {
281 let games = self.game_manager.get_available_games();
282 println!("\nAvailable games:");
283 for (gt, name) in games {
284 println!(" - {} ({})", gt, name);
285 }
286 }
287 "connect" => {
288 if self.stream.is_some() {
289 println!("\nAlready connected. Disconnect first.");
290 continue 'outer;
291 }
292 let addr_str = if parts.len() < 2 {
293 if let Some(h) = &self.addr {
294 h.clone()
295 } else {
296 println!("\nNo host specified. Usage: connect <ip>:<port>");
297 continue 'outer;
298 }
299 } else {
300 let mut a = parts[1].to_string();
301 if a == "localhost" {
302 a = "127.0.0.1".to_string();
303 }
304 if !a.contains(':') {
305 a = format!("{}:{}", a, self.default_port);
306 }
307 a
308 };
309 self.connect_to(&addr_str);
310 }
311 "send" => {
312 if self.stream.is_none() {
313 println!("\nNot connected. Use 'connect <ip>:<port>' first.");
314 continue 'outer;
315 }
316 if parts.len() < 2 {
317 println!("\nUsage: send <message>");
318 continue 'outer;
319 }
320 let msg = parts[1];
321 let s = self.stream.as_mut().unwrap();
322 let writer_stream = s.try_clone().unwrap();
323 let mut writer = writer_stream;
324 let response_reader_stream = s.try_clone().unwrap();
325 let mut response_reader =
326 BufReader::new(response_reader_stream);
327 if let Err(_) = writeln!(writer, "{}", msg) {
328 self.stream = None;
329 println!("\nConnection lost");
330 continue 'outer;
331 }
332 let mut response = String::new();
333 if let Err(_) = response_reader.read_line(&mut response)
334 {
335 self.stream = None;
336 println!("\nConnection lost");
337 } else {
338 println!("\nEcho: {}", response.trim());
339 }
340 }
341 "disconnect" => {
342 self.stream = None;
343 self.logged_in_user = None;
344 println!("\nDisconnected");
345 }
346 "ping" => {
347 if self.stream.is_none() {
348 println!("\nNot connected. Use 'connect <ip>:<port>' first.");
349 continue 'outer;
350 }
351 let s = self.stream.as_mut().unwrap();
352 let writer_stream = s.try_clone().unwrap();
353 let mut writer = writer_stream;
354 let start = Instant::now();
355 let response_reader_stream = s.try_clone().unwrap();
356 let mut response_reader =
357 BufReader::new(response_reader_stream);
358 if let Err(_) = writeln!(writer, "PING") {
359 self.stream = None;
360 println!("\nConnection lost");
361 continue 'outer;
362 }
363 let mut response = String::new();
364 if let Err(_) = response_reader.read_line(&mut response)
365 {
366 self.stream = None;
367 println!("\nConnection lost");
368 } else {
369 if response.trim() == "PONG" {
370 let elapsed = start.elapsed();
371 println!("\nPong ({:?})", elapsed);
372 } else {
373 self.stream = None;
374 println!("\nConnection lost");
375 }
376 }
377 }
378 "register" => {
379 if self.stream.is_none() {
380 println!("\nNot connected. Use 'connect <ip>:<port>' first.");
381 continue 'outer;
382 }
383 let username = read_line("\nUsername: ");
384 if username.is_empty() {
385 continue 'outer;
386 }
387 let password = read_password("Password: ");
388 if password.is_empty() {
389 continue 'outer;
390 }
391 let hashed = format!(
392 "{:x}",
393 Sha256::digest(password.as_bytes())
394 );
395 let msg = format!("REGISTER {} {}", username, hashed);
396 let s = self.stream.as_mut().unwrap();
397 let writer_stream = s.try_clone().unwrap();
398 let mut writer = writer_stream;
399 let response_reader_stream = s.try_clone().unwrap();
400 let mut response_reader =
401 BufReader::new(response_reader_stream);
402 if let Err(_) = writeln!(writer, "{}", msg) {
403 self.stream = None;
404 println!("\nConnection lost");
405 continue 'outer;
406 }
407 let mut response = String::new();
408 if let Err(_) = response_reader.read_line(&mut response)
409 {
410 self.stream = None;
411 println!("\nConnection lost");
412 } else {
413 let resp = response.trim();
414 if resp == format!("REGISTERED {}", username) {
415 println!(
416 "\nSuccessfully registered as {}",
417 username
418 );
419 } else if resp.starts_with("ERROR ") {
420 println!(
421 "\nRegistration failed: {}",
422 &resp[6..]
423 );
424 } else {
425 println!("\n{}", resp);
426 }
427 }
428 }
429 "login" => {
430 if self.stream.is_none() {
431 println!("\nNot connected. Use 'connect <ip>:<port>' first.");
432 continue 'outer;
433 }
434 if self.logged_in_user.is_some() {
435 println!(
436 "\nAlready logged in as {}",
437 self.logged_in_user.as_ref().unwrap()
438 );
439 continue 'outer;
440 }
441 loop {
442 let username = read_line("\nUsername: ");
443 if username.is_empty() {
444 break;
445 }
446 let password = read_password("Password: ");
447 if password.is_empty() {
448 break;
449 }
450 let hashed = format!(
451 "{:x}",
452 Sha256::digest(password.as_bytes())
453 );
454 let msg = format!("AUTH {} {}", username, hashed);
455 let s = self.stream.as_mut().unwrap();
456 let writer_stream = s.try_clone().unwrap();
457 let mut writer = writer_stream;
458 let response_reader_stream = s.try_clone().unwrap();
459 let mut response_reader =
460 BufReader::new(response_reader_stream);
461 if let Err(_) = writeln!(writer, "{}", msg) {
462 self.stream = None;
463 println!("\nConnection lost");
464 break;
465 }
466 let mut response = String::new();
467 if let Err(_) =
468 response_reader.read_line(&mut response)
469 {
470 self.stream = None;
471 println!("\nConnection lost");
472 break;
473 } else {
474 let resp = response.trim();
475 if resp.starts_with("OK") {
476 // Save credentials
477 if let Some(addr) = &self.addr {
478 if let Err(e) =
479 database::save_credentials(
480 &self.conn, addr, &username,
481 &hashed,
482 )
483 {
484 println!("Failed to save credentials: {}", e);
485 }
486 }
487 self.logged_in_user =
488 Some(username.clone());
489 println!("\nLogged in as {}", username);
490 break;
491 } else if resp.starts_with("ERROR ") {
492 println!(
493 "\nAuthentication failed: {}",
494 &resp[6..]
495 );
496 println!(
497 "Try again or press Enter to cancel."
498 );
499 } else {
500 println!("\n{}", resp);
501 }
502 }
503 }
504 }
505 "setcred" => {
506 if self.addr.is_none() {
507 println!("\nNo host connected. Use 'connect <ip>:<port>' first.");
508 continue 'outer;
509 }
510 let username = read_line("\nUsername: ");
511 if username.is_empty() {
512 continue 'outer;
513 }
514 let password = read_password("Password: ");
515 if password.is_empty() {
516 continue 'outer;
517 }
518 let hashed = format!(
519 "{:x}",
520 Sha256::digest(password.as_bytes())
521 );
522 if let Some(addr) = &self.addr {
523 if let Err(e) = database::save_credentials(
524 &self.conn, addr, &username, &hashed,
525 ) {
526 println!("Failed to save credentials: {}", e);
527 } else {
528 println!("Credentials saved for {}", addr);
529 }
530 }
531 }
532 "logout" => {
533 self.logged_in_user = None;
534 println!("\nLogged out");
535 }
536 "info" => {
537 if let Some(addr) = &self.addr {
538 let fp = database::get_fingerprint(&self.conn, addr);
539 println!("\nConnected to: {}", addr);
540 match fp {
541 Some(fp) => println!("Fingerprint: {}", fp),
542 None => println!("Fingerprint: (not trusted)"),
543 }
544 } else {
545 println!("\nNot connected to any host.");
546 }
547 if let Some(s) = &self.stream {
548 let mut s = s.try_clone().unwrap();
549 let _ = writeln!(s, "INFO");
550 let mut reader = BufReader::new(s);
551 let mut response = String::new();
552 let _ = reader.read_line(&mut response);
553 if !response.is_empty() {
554 println!("{}", response.trim());
555 }
556 }
557 }
558 "help" => {
559 if self.game_manager.is_in_game() {
560 if let Some(game) =
561 self.game_manager.get_current_game()
562 {
563 println!("\n{}", game.get_help());
564 }
565 } else {
566 help();
567 }
568 }
569 "quit" => {
570 if self.game_manager.is_in_game() {
571 self.game_manager.end_game();
572 println!("\nGame exited.");
573 } else {
574 restore_terminal(&self.cleanup_done);
575 break 'outer;
576 }
577 }
578 "forget-host" => {
579 let target = if parts.len() > 1 {
580 parts[1].to_string()
581 } else if let Some(addr) = &self.addr {
582 addr.clone()
583 } else {
584 println!(
585 "\nUsage: forget-host <ip>:<port> (or connect first)"
586 );
587 continue 'outer;
588 };
589 match database::forget_host(&self.conn, &target) {
590 Ok(_) => println!(
591 "\nCleared trust and stored credentials for {}.",
592 target
593 ),
594 Err(e) => println!("\nFailed to forget host: {}", e),
595 }
596 }
597 "hosts" => {
598 let hosts = database::get_all_hosts(&self.conn);
599 if hosts.is_empty() {
600 println!("\nNo remembered hosts.");
601 } else {
602 println!("\nRemembered hosts:");
603 for (host, fp) in &hosts {
604 match fp {
605 Some(fp) => println!(" {} - {}", host, fp),
606 None => println!(" {} - (no fingerprint)", host),
607 }
608 }
609 }
610 }
611 _ => help(),
612 }
613 break;
614 }
615 KeyCode::Backspace => {
616 if !current_input.is_empty() {
617 current_input.pop();
618 execute!(
619 stdout(),
620 MoveToColumn(2),
621 Clear(ClearType::UntilNewLine)
622 )
623 .unwrap();
624 print!("{}", current_input);
625 stdout().flush().unwrap();
626 }
627 }
628 KeyCode::Char(c) => {
629 if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' {
630 restore_terminal(&self.cleanup_done);
631 std::process::exit(0);
632 } else if key.modifiers.contains(KeyModifiers::CONTROL)
633 && c == 'd'
634 {
635 restore_terminal(&self.cleanup_done);
636 std::process::exit(0);
637 } else if c == '\t' {
638 self.handle_tab_completion(&mut current_input);
639 } else {
640 current_input.push(c);
641 print!("{}", c);
642 stdout().flush().unwrap();
643 }
644 }
645 KeyCode::Tab => {
646 self.handle_tab_completion(&mut current_input);
647 }
648 _ => {}
649 }
650 }
651 }
652 _ => {}
653 } 109 }
110 self.apply_prompt();
654 } 111 }
655 } 112 }
113
656 restore_terminal(&self.cleanup_done); 114 restore_terminal(&self.cleanup_done);
657 } 115 }
658 116
659 fn handle_tab_completion(&mut self, current_input: &mut String) { 117 fn apply_effects(&mut self, effects: Vec<Effect>) -> bool {
660 let prefix = current_input.trim(); 118 for effect in effects {
661 let commands = get_commands(); 119 match effect {
662 let matches: Vec<_> = commands.iter().filter(|c| c.starts_with(prefix)).collect(); 120 Effect::Print(lines) => {
663 if matches.len() == 1 { 121 self.commit_prompt_line();
664 *current_input = matches[0].clone(); 122 for line in lines {
665 redraw(current_input); 123 println!("{}", line);
666 } else if matches.len() > 1 { 124 }
667 execute!(stdout(), Show).unwrap(); 125 }
668 println!("\nPossible completions:"); 126 Effect::ReplaceRegion(lines) => self.draw_region(&lines),
669 for m in &matches { 127 Effect::ClearRegion => self.clear_region(),
670 print!("{} ", m); 128 Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col),
129 Effect::SendLine(line) => {
130 let reply = self.send_and_read(&line);
131 let more = self.core.handle_reply(&reply);
132 if self.stream.is_none() {
133 self.core.set_connected(false);
134 }
135 if self.apply_effects(more) {
136 return true;
137 }
138 }
139 Effect::Interactive(command) => {
140 self.commit_prompt_line();
141 self.run_interactive(command);
142 }
143 Effect::Disconnect => {
144 self.stream = None;
145 self.clear_region();
146 }
147 Effect::ListHosts => {
148 self.commit_prompt_line();
149 self.list_hosts();
150 }
151 Effect::ForgetHost(target) => {
152 self.commit_prompt_line();
153 match database::forget_host(&self.conn, &target) {
154 Ok(_) => println!(
155 "Cleared trust and stored credentials for {}.",
156 target
157 ),
158 Err(error) => println!("Failed to forget host: {}", error),
159 }
160 }
161 Effect::Quit => {
162 let had_board = self.game_lines > 0;
163 self.clear_region();
164 self.commit_prompt_line();
165 if had_board {
166 println!();
167 }
168 return true;
169 }
671 } 170 }
672 println!();
673 execute!(stdout(), Hide).unwrap();
674 redraw(current_input);
675 } 171 }
676 // else do nothing 172 false
677 } 173 }
678 174
679 /// Read the server response after PLAY_START. 175 fn apply_prompt(&mut self) {
680 fn read_server_response(&self, stream: &mut TlsStream) -> String { 176 if let Some(prompt) = self.core.prompt_effect() {
681 let mut response = String::new(); 177 let _ = self.apply_effects(vec![prompt]);
682 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); 178 }
683 let _ = reader.read_line(&mut response);
684 response
685 } 179 }
686 180
687 /// Run the spinning animation and display the final result. 181 /// Leave the command currently on the prompt line visible in the
688 fn play_spin_animation(&mut self, response: &str) { 182 /// transcript before printing output below it.
689 use std::io::Write; 183 fn commit_prompt_line(&mut self) {
690 use std::thread; 184 if self.prompt_text.is_empty() {
691 use std::time::Duration; 185 return;
692
693 let symbols = ['C', 'L', 'O', 'G', 'D', 'S'];
694 let frames = 20;
695 let delay = Duration::from_millis(50);
696
697 for _ in 0..frames {
698 let reels: String = (0..3)
699 .map(|_| symbols[rand::random::<usize>() % symbols.len()])
700 .collect();
701
702 execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap();
703 print!(
704 "[ {} ] [ {} ] [ {} ]",
705 reels.chars().nth(0).unwrap(),
706 reels.chars().nth(1).unwrap(),
707 reels.chars().nth(2).unwrap()
708 );
709 stdout().flush().unwrap();
710
711 thread::sleep(delay);
712 } 186 }
187 print!("\r\n");
188 stdout().flush().unwrap();
189 self.prompt_text.clear();
190 }
713 191
714 let response = response.trim(); 192 fn draw_region(&mut self, lines: &[String]) {
715 if response.starts_with("GAME_RESULT") { 193 if self.game_lines == 0 {
716 let parts: Vec<&str> = response.split_whitespace().collect(); 194 self.commit_prompt_line();
717 if parts.len() >= 7 { 195 } else {
718 let reels = parts[5]; 196 execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap();
719 let win_type = parts[6]; 197 }
720 198 execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap();
721 execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap(); 199 print!("{}", lines.join("\r\n"));
722 print!( 200 stdout().flush().unwrap();
723 "[ {} ] [ {} ] [ {} ]", 201 self.game_lines = lines.len().max(1);
724 reels.chars().nth(0).unwrap_or('?'), 202 }
725 reels.chars().nth(1).unwrap_or('?'), 203
726 reels.chars().nth(2).unwrap_or('?') 204 fn clear_region(&mut self) {
727 ); 205 if self.game_lines == 0 {
728 println!(); 206 return;
729 207 }
730 if win_type == "jackpot" { 208 execute!(
731 println!("\n🎉 JACKPOT! 🎉"); 209 stdout(),
732 } else if win_type == "two" { 210 MoveToColumn(0),
733 println!("\n✨ Two of a kind! ✨"); 211 MoveUp(lines_to_move_up(self.game_lines)),
734 } else { 212 Clear(ClearType::FromCursorDown)
735 println!("\nNo win."); 213 )
214 .unwrap();
215 self.game_lines = 0;
216 }
217
218 fn draw_prompt(&mut self, text: &str, cursor_col: u16) {
219 execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap();
220 print!("> {}", text);
221 stdout().flush().unwrap();
222 execute!(stdout(), MoveToColumn(cursor_col)).unwrap();
223 self.prompt_text = text.to_string();
224 }
225
226 fn send_and_read(&mut self, line: &str) -> String {
227 let Some(mut stream) = self.stream.take() else {
228 return "ERROR Not connected".to_string();
229 };
230
231 let result = (|| -> std::io::Result<String> {
232 stream.write_line(line)?;
233 let mut response = String::new();
234 let read = stream.read_line(&mut response)?;
235 if read == 0 {
236 return Err(std::io::Error::new(
237 std::io::ErrorKind::UnexpectedEof,
238 "connection closed",
239 ));
240 }
241 Ok(response)
242 })();
243
244 match result {
245 Ok(response) => {
246 self.stream = Some(stream);
247 response
248 }
249 Err(_) => {
250 self.stream = None;
251 "ERROR Connection lost".to_string()
252 }
253 }
254 }
255
256 fn run_interactive(&mut self, command: InteractiveCommand) {
257 match command {
258 InteractiveCommand::Login => self.do_login(),
259 InteractiveCommand::Register => self.do_register(),
260 InteractiveCommand::SetCred => self.do_setcred(),
261 InteractiveCommand::Connect(target) => {
262 if self.stream.is_some() {
263 println!("Already connected. Disconnect first.");
264 return;
265 }
266 let addr = match target {
267 Some(target) => self.normalize_addr(target),
268 None => match &self.addr {
269 Some(addr) => addr.clone(),
270 None => {
271 println!("No host specified. Usage: connect <ip>:<port>");
272 return;
273 }
274 },
275 };
276 self.connect_to(&addr);
277 }
278 }
279 }
280
281 fn normalize_addr(&self, mut addr: String) -> String {
282 if addr == "localhost" {
283 addr = "127.0.0.1".to_string();
284 }
285 if !addr.contains(':') {
286 addr = format!("{}:{}", addr, self.default_port);
287 }
288 addr
289 }
290
291 fn do_login(&mut self) {
292 loop {
293 let username = read_line("Username: ");
294 if username.is_empty() {
295 break;
296 }
297 let password = read_password("Password: ");
298 if password.is_empty() {
299 break;
300 }
301
302 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
303 let message = format!("AUTH {} {}", username, hashed);
304 let response = self.send_and_read(&message);
305 let response = response.trim();
306
307 if response.starts_with("OK") {
308 if let Some(addr) = &self.addr {
309 if let Err(error) =
310 database::save_credentials(&self.conn, addr, &username, &hashed)
311 {
312 println!("Failed to save credentials: {}", error);
313 }
736 } 314 }
315 self.core.set_logged_in(Some(username.clone()));
316 println!("Logged in as {}", username);
317 break;
318 } else if response.starts_with("ERROR ") {
319 println!("Authentication failed: {}", &response[6..]);
320 println!("Try again or press Enter to cancel.");
321 } else {
322 println!("{}", response);
323 }
737 324
738 println!("Winnings: {} PLN", parts[2]); 325 if self.stream.is_none() {
739 println!("New Balance: {} PLN", parts[4]); 326 break;
740 stdout().flush().unwrap();
741 } 327 }
742 } else if response.starts_with("ERROR") {
743 println!("\n{}", response);
744 } 328 }
745 } 329 }
746}
747 330
748fn restore_terminal(cleanup_done: &Arc<Mutex<bool>>) { 331 fn do_register(&mut self) {
749 let mut done = cleanup_done.lock().unwrap(); 332 let username = read_line("Username: ");
750 if !*done { 333 if username.is_empty() {
751 let _ = disable_raw_mode(); 334 return;
752 let _ = execute!(stdout(), Show); 335 }
753 *done = true; 336 let password = read_password("Password: ");
337 if password.is_empty() {
338 return;
339 }
340
341 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
342 let message = format!("REGISTER {} {}", username, hashed);
343 let response = self.send_and_read(&message);
344 let response = response.trim();
345
346 if response == format!("REGISTERED {}", username) {
347 println!("Successfully registered as {}", username);
348 } else if response.starts_with("ERROR ") {
349 println!("Registration failed: {}", &response[6..]);
350 } else {
351 println!("{}", response);
352 }
754 } 353 }
755}
756 354
757fn get_commands() -> Vec<String> { 355 fn do_setcred(&mut self) {
758 vec![ 356 if self.addr.is_none() {
759 "connect".to_string(), 357 println!("No host connected. Use 'connect <ip>:<port>' first.");
760 "send".to_string(), 358 return;
761 "disconnect".to_string(), 359 }
762 "ping".to_string(), 360 let username = read_line("Username: ");
763 "login".to_string(), 361 if username.is_empty() {
764 "register".to_string(), 362 return;
765 "setcred".to_string(), 363 }
766 "logout".to_string(), 364 let password = read_password("Password: ");
767 "info".to_string(), 365 if password.is_empty() {
768 "play".to_string(), 366 return;
769 "help".to_string(), 367 }
770 "quit".to_string(), 368 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
771 "forget-host".to_string(), 369 if let Some(addr) = &self.addr {
772 "hosts".to_string(), 370 match database::save_credentials(&self.conn, addr, &username, &hashed) {
773 ] 371 Ok(_) => println!("Credentials saved for {}", addr),
774} 372 Err(error) => println!("Failed to save credentials: {}", error),
373 }
374 }
375 }
775 376
776fn help() { 377 fn list_hosts(&mut self) {
777 println!( 378 let hosts = database::get_all_hosts(&self.conn);
778 "\nAvailable commands:\r 379 if hosts.is_empty() {
779 connect <ip>:<port> - connect to the server\r 380 println!("No remembered hosts.");
780 send <message> - send a message to the server\r 381 return;
781 disconnect - disconnect from the server\r 382 }
782 ping - ping the server\r 383 println!("Remembered hosts:");
783 login - login to the server\r 384 for (host, fingerprint) in hosts {
784 register - register a new user\r 385 match fingerprint {
785 setcred - set credentials for a host\r 386 Some(fingerprint) => println!(" {} - {}", host, fingerprint),
786 logout - logout from the server\r 387 None => println!(" {} - (no fingerprint)", host),
787 info - show login info\r 388 }
788 play <game> [bet] - start a game (e.g., play slots 50)\r 389 }
789 help - show this message\r 390 }
790 forget-host [ip:port] - clear saved trust fingerprint and credentials\r
791 quit - quit the program"
792 );
793} 391}
794 392
795fn redraw_game(game_output: &str) { 393fn map_key(key: crossterm::event::KeyEvent) -> Option<Key> {
796 // After Enter, \r moves cursor to col 0 of the "> command" line. 394 if key.modifiers.contains(KeyModifiers::CONTROL) {
797 // Clear that line and everything below, then draw game UI. 395 match key.code {
798 execute!( 396 KeyCode::Char('c') | KeyCode::Char('d') => return Some(Key::CtrlC),
799 stdout(), 397 _ => {}
800 Clear(ClearType::CurrentLine), 398 }
801 Clear(ClearType::FromCursorDown), 399 }
802 MoveToColumn(0)
803 )
804 .unwrap();
805 print!("{}", game_output);
806 stdout().flush().unwrap();
807}
808 400
809fn redraw_game_lines(game_output: &str, lines: usize) { 401 match key.code {
810 use crossterm::cursor::MoveUp; 402 KeyCode::Char(c) => Some(Key::Char(c)),
811 if lines > 0 { 403 KeyCode::Enter => Some(Key::Enter),
812 execute!(stdout(), MoveUp(lines as u16)).unwrap(); 404 KeyCode::Backspace => Some(Key::Backspace),
405 KeyCode::Up => Some(Key::Up),
406 KeyCode::Down => Some(Key::Down),
407 KeyCode::Tab => Some(Key::Tab),
408 KeyCode::Esc => Some(Key::Esc),
409 _ => None,
813 } 410 }
814 execute!(
815 stdout(),
816 Clear(ClearType::CurrentLine),
817 Clear(ClearType::FromCursorDown),
818 MoveToColumn(0)
819 )
820 .unwrap();
821 print!("{}", game_output);
822 stdout().flush().unwrap();
823} 411}
824 412
825fn redraw(current_input: &str) { 413fn restore_terminal(cleanup_done: &Arc<Mutex<bool>>) {
826 execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap(); 414 let mut done = cleanup_done.lock().unwrap();
827 print!("> {}", current_input); 415 if !*done {
828 stdout().flush().unwrap(); 416 let _ = disable_raw_mode();
829 execute!(stdout(), MoveToColumn((2 + current_input.len()) as u16)).unwrap(); 417 let _ = execute!(stdout(), Show);
418 *done = true;
419 }
830} 420}
831 421
832fn read_line(prompt: &str) -> String { 422fn read_line(prompt: &str) -> String {
@@ -840,7 +430,7 @@ fn read_line(prompt: &str) -> String {
840 loop { 430 loop {
841 match read().unwrap() { 431 match read().unwrap() {
842 Event::Key(key) => { 432 Event::Key(key) => {
843 if key.kind == crossterm::event::KeyEventKind::Press { 433 if key.kind == KeyEventKind::Press {
844 match key.code { 434 match key.code {
845 KeyCode::Enter => { 435 KeyCode::Enter => {
846 println!(); 436 println!();
@@ -849,10 +439,12 @@ fn read_line(prompt: &str) -> String {
849 KeyCode::Backspace => { 439 KeyCode::Backspace => {
850 if !input.is_empty() { 440 if !input.is_empty() {
851 input.pop(); 441 input.pop();
852 // Move cursor back and clear from current position to end of line
853 execute!( 442 execute!(
854 stdout(), 443 stdout(),
855 MoveToColumn((prompt_len + input.len() + 1) as u16), 444 MoveToColumn(input_cursor_column(
445 prompt_len,
446 input.len()
447 )),
856 Clear(ClearType::UntilNewLine) 448 Clear(ClearType::UntilNewLine)
857 ) 449 )
858 .unwrap(); 450 .unwrap();
@@ -885,7 +477,7 @@ fn read_password(prompt: &str) -> String {
885 loop { 477 loop {
886 match read().unwrap() { 478 match read().unwrap() {
887 Event::Key(key) => { 479 Event::Key(key) => {
888 if key.kind == crossterm::event::KeyEventKind::Press { 480 if key.kind == KeyEventKind::Press {
889 match key.code { 481 match key.code {
890 KeyCode::Enter => { 482 KeyCode::Enter => {
891 println!(); 483 println!();
@@ -894,10 +486,12 @@ fn read_password(prompt: &str) -> String {
894 KeyCode::Backspace => { 486 KeyCode::Backspace => {
895 if !input.is_empty() { 487 if !input.is_empty() {
896 input.pop(); 488 input.pop();
897 // Move cursor back and clear from current position to end of line
898 execute!( 489 execute!(
899 stdout(), 490 stdout(),
900 MoveToColumn((prompt_len + input.len() + 1) as u16), 491 MoveToColumn(input_cursor_column(
492 prompt_len,
493 input.len()
494 )),
901 Clear(ClearType::UntilNewLine) 495 Clear(ClearType::UntilNewLine)
902 ) 496 )
903 .unwrap(); 497 .unwrap();
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}
diff --git a/tests/client_core.rs b/tests/client_core.rs
new file mode 100644
index 0000000..3dbbfe5
--- /dev/null
+++ b/tests/client_core.rs
@@ -0,0 +1,216 @@
1//! Behaviour tests for the client core.
2//!
3//! These tests never touch a terminal or a socket. `Screen` applies the
4//! logical `Effect`s that `ClientCore` emits, so assertions are about what a
5//! player would see, not about ANSI sequences.
6
7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
8
9#[derive(Default)]
10struct Screen {
11 lines: Vec<String>,
12 region_start: Option<usize>,
13 region_len: usize,
14 prompt: String,
15}
16
17impl Screen {
18 fn apply(
19 &mut self,
20 effects: &[Effect],
21 sent: &mut Vec<String>,
22 interactive: &mut Vec<InteractiveCommand>,
23 ) {
24 for effect in effects {
25 match effect {
26 Effect::Print(lines) => self.lines.extend(lines.iter().cloned()),
27 Effect::ReplaceRegion(lines) => {
28 if let Some(start) = self.region_start {
29 self.lines.truncate(start);
30 } else {
31 self.region_start = Some(self.lines.len());
32 }
33 self.lines.extend(lines.iter().cloned());
34 self.region_len = lines.len();
35 }
36 Effect::ClearRegion => {
37 if let Some(start) = self.region_start {
38 self.lines.truncate(start);
39 }
40 self.region_start = None;
41 self.region_len = 0;
42 }
43 Effect::SetPrompt { text, .. } => self.prompt = text.clone(),
44 Effect::SendLine(line) => sent.push(line.clone()),
45 Effect::Interactive(command) => interactive.push(command.clone()),
46 Effect::Quit
47 | Effect::Disconnect
48 | Effect::ListHosts
49 | Effect::ForgetHost(_) => {}
50 }
51 }
52 }
53
54 fn board(&self) -> &[String] {
55 match self.region_start {
56 Some(start) => &self.lines[start..start + self.region_len],
57 None => &[],
58 }
59 }
60}
61
62struct Harness {
63 core: ClientCore,
64 screen: Screen,
65 sent: Vec<String>,
66 interactive: Vec<InteractiveCommand>,
67}
68
69impl Harness {
70 fn new() -> Self {
71 let mut core = ClientCore::new();
72 core.set_connected(true);
73 core.set_logged_in(Some("alice".to_string()));
74 Self {
75 core,
76 screen: Screen::default(),
77 sent: Vec::new(),
78 interactive: Vec::new(),
79 }
80 }
81
82 fn apply(&mut self, effects: Vec<Effect>) {
83 self.screen
84 .apply(&effects, &mut self.sent, &mut self.interactive);
85 }
86
87 fn key(&mut self, key: Key) {
88 let effects = self.core.handle_key(key);
89 self.apply(effects);
90 if let Some(prompt) = self.core.prompt_effect() {
91 self.apply(vec![prompt]);
92 }
93 }
94
95 fn line(&mut self, line: &str) {
96 for c in line.chars() {
97 self.key(Key::Char(c));
98 }
99 self.key(Key::Enter);
100 }
101
102 fn reply(&mut self, reply: &str) {
103 let effects = self.core.handle_reply(reply);
104 self.apply(effects);
105 if let Some(prompt) = self.core.prompt_effect() {
106 self.apply(vec![prompt]);
107 }
108 }
109
110 fn board(&self) -> &[String] {
111 self.screen.board()
112 }
113
114 fn enter_slots(&mut self, bet: &str) {
115 self.line(&format!("play slots {bet}"));
116 self.reply("INFO alice 1 1000");
117 }
118}
119
120#[test]
121fn play_shows_a_seven_line_board_and_fetches_the_balance() {
122 let mut h = Harness::new();
123 h.line("play slots 50");
124
125 assert_eq!(h.board().len(), 7);
126 assert!(h.board()[1].contains("Bet: 50 PLN"));
127 assert!(h.sent.contains(&"INFO".to_string()));
128
129 h.reply("INFO alice 1 1000");
130 assert_eq!(h.board().len(), 7);
131 assert!(h.board()[1].contains("Balance: 1000 PLN"));
132}
133
134#[test]
135fn spin_replaces_the_board_and_keeps_the_controls_line() {
136 let mut h = Harness::new();
137 h.enter_slots("50");
138 let lines_before_spin = h.screen.lines.len();
139
140 h.key(Key::Char(' '));
141 assert_eq!(h.board().len(), 7);
142 assert!(h.board()[4].contains("Spinning..."));
143 assert!(h.board()[6].contains("[Q] Quit"));
144 assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50"));
145 assert_eq!(h.screen.lines.len(), lines_before_spin);
146
147 h.reply("GAME_RESULT session-1 60 50 1010 OSS two");
148 assert_eq!(h.board().len(), 7);
149 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]"));
150 assert!(h.board()[4].contains("Two of a kind! Winnings: 60 PLN"));
151 assert!(h.board()[1].contains("Balance: 1010 PLN"));
152 assert!(h.board()[6].contains("[Q] Quit"));
153 assert_eq!(h.screen.lines.len(), lines_before_spin);
154}
155
156#[test]
157fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() {
158 let mut h = Harness::new();
159 h.enter_slots("50");
160 h.key(Key::Char(' '));
161 h.reply("GAME_RESULT session-1 60 50 1010 OSS two");
162 let lines_before_bet = h.screen.lines.len();
163
164 h.key(Key::Char('+'));
165
166 assert_eq!(h.board().len(), 7);
167 assert!(h.board()[1].contains("Bet: 60 PLN"));
168 assert!(h.board()[2].contains("[ O ] [ S ] [ S ]"));
169 assert_eq!(h.screen.lines.len(), lines_before_bet);
170 assert_eq!(
171 h.screen
172 .lines
173 .iter()
174 .filter(|line| line.contains("=== SLOTS ==="))
175 .count(),
176 1
177 );
178}
179
180#[test]
181fn quitting_clears_the_board_and_returns_the_prompt() {
182 let mut h = Harness::new();
183 h.enter_slots("50");
184
185 h.key(Key::Char('q'));
186
187 assert!(h.board().is_empty());
188 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
189 assert_eq!(h.screen.prompt, "");
190}
191
192#[test]
193fn a_server_error_replaces_the_status_line() {
194 let mut h = Harness::new();
195 h.enter_slots("50");
196 h.key(Key::Char(' '));
197
198 h.reply("ERROR Insufficient balance");
199
200 assert_eq!(h.board().len(), 7);
201 assert!(h.board()[4].contains("ERROR: Insufficient balance"));
202 assert!(h.board()[2].contains("[ ? ]"));
203 assert!(!h.board().iter().any(|line| line.contains("Spinning")));
204}
205
206#[test]
207fn an_empty_reply_is_an_error_not_a_silent_spin() {
208 let mut h = Harness::new();
209 h.enter_slots("50");
210 h.key(Key::Char(' '));
211
212 h.reply("");
213
214 assert_eq!(h.board().len(), 7);
215 assert!(h.board()[4].contains("No response from server"));
216}