From 86d5f832c8452aad2313489b82eb8fd514215b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:28:20 +0100 Subject: v0.2.3: add ClientCore and fix the game board rendering --- src/bin/client/repl.rs | 1074 +++++++++++++----------------------------- src/client/core.rs | 494 +++++++++++++++++++ src/client/games/manager.rs | 140 +++--- src/client/games/mod.rs | 1 + src/client/games/response.rs | 60 +++ src/client/games/slots.rs | 58 ++- src/client/mod.rs | 2 + src/client/ui.rs | 143 ++++++ 8 files changed, 1165 insertions(+), 807 deletions(-) create mode 100644 src/client/core.rs create mode 100644 src/client/games/response.rs create mode 100644 src/client/ui.rs (limited to 'src') 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 @@ use rusqlite::Connection; -use std::io::{stdout, BufRead, BufReader, Write}; +use std::io::{stdout, Write}; use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use crate::connection::TlsStream; -use zagan_bet::client::database; -use zagan_bet::client::games::manager::GameManager; -use zagan_bet::client::games::traits::GamePhase; use crossterm::{ - cursor::{Hide, MoveToColumn, Show}, - event::{read, Event, KeyCode, KeyModifiers}, + cursor::{MoveToColumn, MoveUp, Show}, + event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, }; use sha2::{Digest, Sha256}; +use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; +use zagan_bet::client::database; +use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up}; + +use crate::connection::TlsStream; + pub struct ClientState { pub stream: Option, pub conn: Connection, pub addr: Option, pub default_port: u16, - pub history: Vec, - pub history_index: usize, - pub logged_in_user: Option, - pub game_manager: GameManager, + core: ClientCore, cleanup_done: Arc>, - in_game_mode: bool, - spinning: bool, + /// Number of terminal lines currently occupied by the game board. + game_lines: usize, + /// Text last drawn after the `> ` prompt, used to keep the command echo + /// in the transcript before printing command output. + prompt_text: String, } impl ClientState { @@ -49,128 +49,40 @@ impl ClientState { conn, addr, default_port, - history: vec![], - history_index: 0, - logged_in_user: None, - game_manager: GameManager::new(), + core: ClientCore::new(), cleanup_done: Arc::new(Mutex::new(false)), - in_game_mode: false, - spinning: false, - } - } - - pub fn enter_game_mode(&mut self) { - self.in_game_mode = true; - } - - pub fn exit_game_mode(&mut self) { - self.in_game_mode = false; - self.game_manager.end_game(); - } - - pub fn handle_game_key(&mut self, key_code: KeyCode) -> bool { - if !self.game_manager.is_in_game() { - return false; - } - - match key_code { - KeyCode::Esc | KeyCode::Char('q') => { - self.exit_game_mode(); - println!("\nGame exited."); - return true; - } - KeyCode::Char(' ') => { - if self.spinning { - return true; // Discard keypress while busy - } - let can_spin = self.game_manager.get_current_state() - .map(|s| s.phase == GamePhase::WaitingToStart || s.phase == GamePhase::Finished) - .unwrap_or(false); - if can_spin { - if let Some(mut stream) = self.stream.take() { - self.spinning = true; - match self.game_manager.start_game(&mut stream) { - Ok(()) => { - let response = self.read_server_response(&mut stream); - if response.trim().starts_with("ERROR") { - println!("\n{}", response.trim()); - } else { - self.play_spin_animation(&response); - } - self.game_manager.reset_phase_to_waiting(); - } - Err(e) => println!("\nError starting game: {:?}", e), - } - self.stream = Some(stream); - // Drain buffered key events BEFORE clearing spinning - // to prevent queued spins from triggering immediately - while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { - let _ = crossterm::event::read(); - } - self.spinning = false; - } - } - return true; - } - KeyCode::Char('+') => { - // Increase bet - match self.game_manager.adjust_bet(10) { - Ok(_new_bet) => { - if let Some(state) = self.game_manager.get_current_state() { - if let Some(game) = self.game_manager.get_current_game() { - let lines = - self.game_manager.get_current_render_lines().unwrap_or(8); - redraw_game_lines(&game.render(state), lines); - } - } - } - Err(e) => println!("\nError: {:?}", e), - } - return true; - } - KeyCode::Char('-') => { - // Decrease bet - match self.game_manager.adjust_bet(-10) { - Ok(_new_bet) => { - if let Some(state) = self.game_manager.get_current_state() { - if let Some(game) = self.game_manager.get_current_game() { - let lines = - self.game_manager.get_current_render_lines().unwrap_or(8); - redraw_game_lines(&game.render(state), lines); - } - } - } - Err(e) => println!("\nError: {:?}", e), - } - return true; - } - _ => return true, // Lock into game mode: ignore unknown keys + game_lines: 0, + prompt_text: String::new(), } } pub fn connect_to(&mut self, addr: &str) { self.addr = Some(addr.to_string()); match crate::connection::connect(addr, &self.conn) { - Ok((s, authed_user)) => { - self.stream = Some(s); - self.logged_in_user = authed_user; + Ok((stream, authed_user)) => { + self.stream = Some(stream); + self.core.set_connected(true); + self.core.set_logged_in(authed_user); + let fingerprint = database::get_fingerprint(&self.conn, addr); + self.core + .set_host_info(Some(addr.to_string()), fingerprint); database::save_host(&self.conn, addr); } - Err(e) => println!("\nFailed to connect: {}", e), + Err(e) => { + self.core.set_connected(false); + println!("\nFailed to connect: {}", e); + } } } pub fn run(&mut self) { - // Set up cleanup handlers let cleanup_done = self.cleanup_done.clone(); - // Set up panic handler let panic_cleanup = cleanup_done.clone(); std::panic::set_hook(Box::new(move |_| { restore_terminal(&panic_cleanup); })); - // Set up Ctrl+C handler let ctrlc_cleanup = cleanup_done.clone(); if let Err(e) = ctrlc::set_handler(move || { restore_terminal(&ctrlc_cleanup); @@ -180,653 +92,331 @@ impl ClientState { } enable_raw_mode().unwrap(); - 'outer: loop { - let mut current_input = String::new(); - self.history_index = self.history.len(); - redraw(""); - loop { - let event = read().unwrap(); - match event { - Event::Key(key) => { - // Only handle key press events, not release events - if key.kind == crossterm::event::KeyEventKind::Press { - // Handle game mode keys first - if self.game_manager.is_in_game() && self.handle_game_key(key.code) { - // Game key was handled, continue loop - if !self.game_manager.is_in_game() { - // Game ended, redraw command prompt - redraw(""); - } - continue; - } + self.apply_prompt(); - // Handle normal command mode keys - match key.code { - KeyCode::Up => { - if self.history_index > 0 { - self.history_index -= 1; - current_input = self.history[self.history_index].clone(); - redraw(¤t_input); - } - } - KeyCode::Down => { - if self.history_index < self.history.len() { - self.history_index += 1; - current_input = if self.history_index == self.history.len() - { - "".to_string() - } else { - self.history[self.history_index].clone() - }; - redraw(¤t_input); - } - } - KeyCode::Enter => { - let line = current_input.trim().to_string(); - if !line.is_empty() { - self.history.push(line.clone()); - } - let line_trimmed = current_input.trim().to_string(); - if !line_trimmed.is_empty() { - self.history.push(line_trimmed.clone()); - } - - // Process normal commands - let parts: Vec<&str> = line_trimmed.splitn(3, ' ').collect(); - match parts[0] { - "play" => { - if self.stream.is_none() { - println!("\nNot connected. Use 'connect :' first."); - continue 'outer; - } - if self.logged_in_user.is_none() { - println!("\nNot logged in. Use 'login' first."); - continue 'outer; - } - if parts.len() < 2 { - let games = self.game_manager.get_available_games(); - println!("\nAvailable games:"); - for (gt, name) in games { - println!(" - {} ({})", gt, name); - } - println!("\nUsage: play "); - continue 'outer; - } - let game_type = parts[1]; - - if self.game_manager.is_in_game() { - println!("\nAlready in a game. Use 'quit' to exit current game."); - continue 'outer; - } - - match self.game_manager.enter_game(game_type) { - Ok(()) => { - self.enter_game_mode(); - if let Some(game) = - self.game_manager.get_current_game() - { - if let Some(state) = - self.game_manager.get_current_state() - { - redraw_game(&game.render(state)); - } - } - } - Err(e) => { - println!("\nFailed to enter game: {:?}", e); - } - } - } - "games" => { - let games = self.game_manager.get_available_games(); - println!("\nAvailable games:"); - for (gt, name) in games { - println!(" - {} ({})", gt, name); - } - } - "connect" => { - if self.stream.is_some() { - println!("\nAlready connected. Disconnect first."); - continue 'outer; - } - let addr_str = if parts.len() < 2 { - if let Some(h) = &self.addr { - h.clone() - } else { - println!("\nNo host specified. Usage: connect :"); - continue 'outer; - } - } else { - let mut a = parts[1].to_string(); - if a == "localhost" { - a = "127.0.0.1".to_string(); - } - if !a.contains(':') { - a = format!("{}:{}", a, self.default_port); - } - a - }; - self.connect_to(&addr_str); - } - "send" => { - if self.stream.is_none() { - println!("\nNot connected. Use 'connect :' first."); - continue 'outer; - } - if parts.len() < 2 { - println!("\nUsage: send "); - continue 'outer; - } - let msg = parts[1]; - let s = self.stream.as_mut().unwrap(); - let writer_stream = s.try_clone().unwrap(); - let mut writer = writer_stream; - let response_reader_stream = s.try_clone().unwrap(); - let mut response_reader = - BufReader::new(response_reader_stream); - if let Err(_) = writeln!(writer, "{}", msg) { - self.stream = None; - println!("\nConnection lost"); - continue 'outer; - } - let mut response = String::new(); - if let Err(_) = response_reader.read_line(&mut response) - { - self.stream = None; - println!("\nConnection lost"); - } else { - println!("\nEcho: {}", response.trim()); - } - } - "disconnect" => { - self.stream = None; - self.logged_in_user = None; - println!("\nDisconnected"); - } - "ping" => { - if self.stream.is_none() { - println!("\nNot connected. Use 'connect :' first."); - continue 'outer; - } - let s = self.stream.as_mut().unwrap(); - let writer_stream = s.try_clone().unwrap(); - let mut writer = writer_stream; - let start = Instant::now(); - let response_reader_stream = s.try_clone().unwrap(); - let mut response_reader = - BufReader::new(response_reader_stream); - if let Err(_) = writeln!(writer, "PING") { - self.stream = None; - println!("\nConnection lost"); - continue 'outer; - } - let mut response = String::new(); - if let Err(_) = response_reader.read_line(&mut response) - { - self.stream = None; - println!("\nConnection lost"); - } else { - if response.trim() == "PONG" { - let elapsed = start.elapsed(); - println!("\nPong ({:?})", elapsed); - } else { - self.stream = None; - println!("\nConnection lost"); - } - } - } - "register" => { - if self.stream.is_none() { - println!("\nNot connected. Use 'connect :' first."); - continue 'outer; - } - let username = read_line("\nUsername: "); - if username.is_empty() { - continue 'outer; - } - let password = read_password("Password: "); - if password.is_empty() { - continue 'outer; - } - let hashed = format!( - "{:x}", - Sha256::digest(password.as_bytes()) - ); - let msg = format!("REGISTER {} {}", username, hashed); - let s = self.stream.as_mut().unwrap(); - let writer_stream = s.try_clone().unwrap(); - let mut writer = writer_stream; - let response_reader_stream = s.try_clone().unwrap(); - let mut response_reader = - BufReader::new(response_reader_stream); - if let Err(_) = writeln!(writer, "{}", msg) { - self.stream = None; - println!("\nConnection lost"); - continue 'outer; - } - let mut response = String::new(); - if let Err(_) = response_reader.read_line(&mut response) - { - self.stream = None; - println!("\nConnection lost"); - } else { - let resp = response.trim(); - if resp == format!("REGISTERED {}", username) { - println!( - "\nSuccessfully registered as {}", - username - ); - } else if resp.starts_with("ERROR ") { - println!( - "\nRegistration failed: {}", - &resp[6..] - ); - } else { - println!("\n{}", resp); - } - } - } - "login" => { - if self.stream.is_none() { - println!("\nNot connected. Use 'connect :' first."); - continue 'outer; - } - if self.logged_in_user.is_some() { - println!( - "\nAlready logged in as {}", - self.logged_in_user.as_ref().unwrap() - ); - continue 'outer; - } - loop { - let username = read_line("\nUsername: "); - if username.is_empty() { - break; - } - let password = read_password("Password: "); - if password.is_empty() { - break; - } - let hashed = format!( - "{:x}", - Sha256::digest(password.as_bytes()) - ); - let msg = format!("AUTH {} {}", username, hashed); - let s = self.stream.as_mut().unwrap(); - let writer_stream = s.try_clone().unwrap(); - let mut writer = writer_stream; - let response_reader_stream = s.try_clone().unwrap(); - let mut response_reader = - BufReader::new(response_reader_stream); - if let Err(_) = writeln!(writer, "{}", msg) { - self.stream = None; - println!("\nConnection lost"); - break; - } - let mut response = String::new(); - if let Err(_) = - response_reader.read_line(&mut response) - { - self.stream = None; - println!("\nConnection lost"); - break; - } else { - let resp = response.trim(); - if resp.starts_with("OK") { - // Save credentials - if let Some(addr) = &self.addr { - if let Err(e) = - database::save_credentials( - &self.conn, addr, &username, - &hashed, - ) - { - println!("Failed to save credentials: {}", e); - } - } - self.logged_in_user = - Some(username.clone()); - println!("\nLogged in as {}", username); - break; - } else if resp.starts_with("ERROR ") { - println!( - "\nAuthentication failed: {}", - &resp[6..] - ); - println!( - "Try again or press Enter to cancel." - ); - } else { - println!("\n{}", resp); - } - } - } - } - "setcred" => { - if self.addr.is_none() { - println!("\nNo host connected. Use 'connect :' first."); - continue 'outer; - } - let username = read_line("\nUsername: "); - if username.is_empty() { - continue 'outer; - } - let password = read_password("Password: "); - if password.is_empty() { - continue 'outer; - } - let hashed = format!( - "{:x}", - Sha256::digest(password.as_bytes()) - ); - if let Some(addr) = &self.addr { - if let Err(e) = database::save_credentials( - &self.conn, addr, &username, &hashed, - ) { - println!("Failed to save credentials: {}", e); - } else { - println!("Credentials saved for {}", addr); - } - } - } - "logout" => { - self.logged_in_user = None; - println!("\nLogged out"); - } - "info" => { - if let Some(addr) = &self.addr { - let fp = database::get_fingerprint(&self.conn, addr); - println!("\nConnected to: {}", addr); - match fp { - Some(fp) => println!("Fingerprint: {}", fp), - None => println!("Fingerprint: (not trusted)"), - } - } else { - println!("\nNot connected to any host."); - } - if let Some(s) = &self.stream { - let mut s = s.try_clone().unwrap(); - let _ = writeln!(s, "INFO"); - let mut reader = BufReader::new(s); - let mut response = String::new(); - let _ = reader.read_line(&mut response); - if !response.is_empty() { - println!("{}", response.trim()); - } - } - } - "help" => { - if self.game_manager.is_in_game() { - if let Some(game) = - self.game_manager.get_current_game() - { - println!("\n{}", game.get_help()); - } - } else { - help(); - } - } - "quit" => { - if self.game_manager.is_in_game() { - self.game_manager.end_game(); - println!("\nGame exited."); - } else { - restore_terminal(&self.cleanup_done); - break 'outer; - } - } - "forget-host" => { - let target = if parts.len() > 1 { - parts[1].to_string() - } else if let Some(addr) = &self.addr { - addr.clone() - } else { - println!( - "\nUsage: forget-host : (or connect first)" - ); - continue 'outer; - }; - match database::forget_host(&self.conn, &target) { - Ok(_) => println!( - "\nCleared trust and stored credentials for {}.", - target - ), - Err(e) => println!("\nFailed to forget host: {}", e), - } - } - "hosts" => { - let hosts = database::get_all_hosts(&self.conn); - if hosts.is_empty() { - println!("\nNo remembered hosts."); - } else { - println!("\nRemembered hosts:"); - for (host, fp) in &hosts { - match fp { - Some(fp) => println!(" {} - {}", host, fp), - None => println!(" {} - (no fingerprint)", host), - } - } - } - } - _ => help(), - } - break; - } - KeyCode::Backspace => { - if !current_input.is_empty() { - current_input.pop(); - execute!( - stdout(), - MoveToColumn(2), - Clear(ClearType::UntilNewLine) - ) - .unwrap(); - print!("{}", current_input); - stdout().flush().unwrap(); - } - } - KeyCode::Char(c) => { - if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' { - restore_terminal(&self.cleanup_done); - std::process::exit(0); - } else if key.modifiers.contains(KeyModifiers::CONTROL) - && c == 'd' - { - restore_terminal(&self.cleanup_done); - std::process::exit(0); - } else if c == '\t' { - self.handle_tab_completion(&mut current_input); - } else { - current_input.push(c); - print!("{}", c); - stdout().flush().unwrap(); - } - } - KeyCode::Tab => { - self.handle_tab_completion(&mut current_input); - } - _ => {} - } - } - } - _ => {} + loop { + let event = read().unwrap(); + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + continue; + } + let Some(key) = map_key(key) else { + continue; + }; + let effects = self.core.handle_key(key); + if self.apply_effects(effects) { + break; } + self.apply_prompt(); } } + restore_terminal(&self.cleanup_done); } - fn handle_tab_completion(&mut self, current_input: &mut String) { - let prefix = current_input.trim(); - let commands = get_commands(); - let matches: Vec<_> = commands.iter().filter(|c| c.starts_with(prefix)).collect(); - if matches.len() == 1 { - *current_input = matches[0].clone(); - redraw(current_input); - } else if matches.len() > 1 { - execute!(stdout(), Show).unwrap(); - println!("\nPossible completions:"); - for m in &matches { - print!("{} ", m); + fn apply_effects(&mut self, effects: Vec) -> bool { + for effect in effects { + match effect { + Effect::Print(lines) => { + self.commit_prompt_line(); + for line in lines { + println!("{}", line); + } + } + Effect::ReplaceRegion(lines) => self.draw_region(&lines), + Effect::ClearRegion => self.clear_region(), + Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), + Effect::SendLine(line) => { + let reply = self.send_and_read(&line); + let more = self.core.handle_reply(&reply); + if self.stream.is_none() { + self.core.set_connected(false); + } + if self.apply_effects(more) { + return true; + } + } + Effect::Interactive(command) => { + self.commit_prompt_line(); + self.run_interactive(command); + } + Effect::Disconnect => { + self.stream = None; + self.clear_region(); + } + Effect::ListHosts => { + self.commit_prompt_line(); + self.list_hosts(); + } + Effect::ForgetHost(target) => { + self.commit_prompt_line(); + match database::forget_host(&self.conn, &target) { + Ok(_) => println!( + "Cleared trust and stored credentials for {}.", + target + ), + Err(error) => println!("Failed to forget host: {}", error), + } + } + Effect::Quit => { + let had_board = self.game_lines > 0; + self.clear_region(); + self.commit_prompt_line(); + if had_board { + println!(); + } + return true; + } } - println!(); - execute!(stdout(), Hide).unwrap(); - redraw(current_input); } - // else do nothing + false } - /// Read the server response after PLAY_START. - fn read_server_response(&self, stream: &mut TlsStream) -> String { - let mut response = String::new(); - let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); - let _ = reader.read_line(&mut response); - response + fn apply_prompt(&mut self) { + if let Some(prompt) = self.core.prompt_effect() { + let _ = self.apply_effects(vec![prompt]); + } } - /// Run the spinning animation and display the final result. - fn play_spin_animation(&mut self, response: &str) { - use std::io::Write; - use std::thread; - use std::time::Duration; - - let symbols = ['C', 'L', 'O', 'G', 'D', 'S']; - let frames = 20; - let delay = Duration::from_millis(50); - - for _ in 0..frames { - let reels: String = (0..3) - .map(|_| symbols[rand::random::() % symbols.len()]) - .collect(); - - execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap(); - print!( - "[ {} ] [ {} ] [ {} ]", - reels.chars().nth(0).unwrap(), - reels.chars().nth(1).unwrap(), - reels.chars().nth(2).unwrap() - ); - stdout().flush().unwrap(); - - thread::sleep(delay); + /// Leave the command currently on the prompt line visible in the + /// transcript before printing output below it. + fn commit_prompt_line(&mut self) { + if self.prompt_text.is_empty() { + return; } + print!("\r\n"); + stdout().flush().unwrap(); + self.prompt_text.clear(); + } - let response = response.trim(); - if response.starts_with("GAME_RESULT") { - let parts: Vec<&str> = response.split_whitespace().collect(); - if parts.len() >= 7 { - let reels = parts[5]; - let win_type = parts[6]; - - execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap(); - print!( - "[ {} ] [ {} ] [ {} ]", - reels.chars().nth(0).unwrap_or('?'), - reels.chars().nth(1).unwrap_or('?'), - reels.chars().nth(2).unwrap_or('?') - ); - println!(); - - if win_type == "jackpot" { - println!("\n🎉 JACKPOT! 🎉"); - } else if win_type == "two" { - println!("\n✨ Two of a kind! ✨"); - } else { - println!("\nNo win."); + fn draw_region(&mut self, lines: &[String]) { + if self.game_lines == 0 { + self.commit_prompt_line(); + } else { + execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); + } + execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); + print!("{}", lines.join("\r\n")); + stdout().flush().unwrap(); + self.game_lines = lines.len().max(1); + } + + fn clear_region(&mut self) { + if self.game_lines == 0 { + return; + } + execute!( + stdout(), + MoveToColumn(0), + MoveUp(lines_to_move_up(self.game_lines)), + Clear(ClearType::FromCursorDown) + ) + .unwrap(); + self.game_lines = 0; + } + + fn draw_prompt(&mut self, text: &str, cursor_col: u16) { + execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); + print!("> {}", text); + stdout().flush().unwrap(); + execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); + self.prompt_text = text.to_string(); + } + + fn send_and_read(&mut self, line: &str) -> String { + let Some(mut stream) = self.stream.take() else { + return "ERROR Not connected".to_string(); + }; + + let result = (|| -> std::io::Result { + stream.write_line(line)?; + let mut response = String::new(); + let read = stream.read_line(&mut response)?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "connection closed", + )); + } + Ok(response) + })(); + + match result { + Ok(response) => { + self.stream = Some(stream); + response + } + Err(_) => { + self.stream = None; + "ERROR Connection lost".to_string() + } + } + } + + fn run_interactive(&mut self, command: InteractiveCommand) { + match command { + InteractiveCommand::Login => self.do_login(), + InteractiveCommand::Register => self.do_register(), + InteractiveCommand::SetCred => self.do_setcred(), + InteractiveCommand::Connect(target) => { + if self.stream.is_some() { + println!("Already connected. Disconnect first."); + return; + } + let addr = match target { + Some(target) => self.normalize_addr(target), + None => match &self.addr { + Some(addr) => addr.clone(), + None => { + println!("No host specified. Usage: connect :"); + return; + } + }, + }; + self.connect_to(&addr); + } + } + } + + fn normalize_addr(&self, mut addr: String) -> String { + if addr == "localhost" { + addr = "127.0.0.1".to_string(); + } + if !addr.contains(':') { + addr = format!("{}:{}", addr, self.default_port); + } + addr + } + + fn do_login(&mut self) { + loop { + let username = read_line("Username: "); + if username.is_empty() { + break; + } + let password = read_password("Password: "); + if password.is_empty() { + break; + } + + let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); + let message = format!("AUTH {} {}", username, hashed); + let response = self.send_and_read(&message); + let response = response.trim(); + + if response.starts_with("OK") { + if let Some(addr) = &self.addr { + if let Err(error) = + database::save_credentials(&self.conn, addr, &username, &hashed) + { + println!("Failed to save credentials: {}", error); + } } + self.core.set_logged_in(Some(username.clone())); + println!("Logged in as {}", username); + break; + } else if response.starts_with("ERROR ") { + println!("Authentication failed: {}", &response[6..]); + println!("Try again or press Enter to cancel."); + } else { + println!("{}", response); + } - println!("Winnings: {} PLN", parts[2]); - println!("New Balance: {} PLN", parts[4]); - stdout().flush().unwrap(); + if self.stream.is_none() { + break; } - } else if response.starts_with("ERROR") { - println!("\n{}", response); } } -} -fn restore_terminal(cleanup_done: &Arc>) { - let mut done = cleanup_done.lock().unwrap(); - if !*done { - let _ = disable_raw_mode(); - let _ = execute!(stdout(), Show); - *done = true; + fn do_register(&mut self) { + let username = read_line("Username: "); + if username.is_empty() { + return; + } + let password = read_password("Password: "); + if password.is_empty() { + return; + } + + let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); + let message = format!("REGISTER {} {}", username, hashed); + let response = self.send_and_read(&message); + let response = response.trim(); + + if response == format!("REGISTERED {}", username) { + println!("Successfully registered as {}", username); + } else if response.starts_with("ERROR ") { + println!("Registration failed: {}", &response[6..]); + } else { + println!("{}", response); + } } -} -fn get_commands() -> Vec { - vec![ - "connect".to_string(), - "send".to_string(), - "disconnect".to_string(), - "ping".to_string(), - "login".to_string(), - "register".to_string(), - "setcred".to_string(), - "logout".to_string(), - "info".to_string(), - "play".to_string(), - "help".to_string(), - "quit".to_string(), - "forget-host".to_string(), - "hosts".to_string(), - ] -} + fn do_setcred(&mut self) { + if self.addr.is_none() { + println!("No host connected. Use 'connect :' first."); + return; + } + let username = read_line("Username: "); + if username.is_empty() { + return; + } + let password = read_password("Password: "); + if password.is_empty() { + return; + } + let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); + if let Some(addr) = &self.addr { + match database::save_credentials(&self.conn, addr, &username, &hashed) { + Ok(_) => println!("Credentials saved for {}", addr), + Err(error) => println!("Failed to save credentials: {}", error), + } + } + } -fn help() { - println!( - "\nAvailable commands:\r - connect : - connect to the server\r - send - send a message to the server\r - disconnect - disconnect from the server\r - ping - ping the server\r - login - login to the server\r - register - register a new user\r - setcred - set credentials for a host\r - logout - logout from the server\r - info - show login info\r - play [bet] - start a game (e.g., play slots 50)\r - help - show this message\r - forget-host [ip:port] - clear saved trust fingerprint and credentials\r - quit - quit the program" - ); + fn list_hosts(&mut self) { + let hosts = database::get_all_hosts(&self.conn); + if hosts.is_empty() { + println!("No remembered hosts."); + return; + } + println!("Remembered hosts:"); + for (host, fingerprint) in hosts { + match fingerprint { + Some(fingerprint) => println!(" {} - {}", host, fingerprint), + None => println!(" {} - (no fingerprint)", host), + } + } + } } -fn redraw_game(game_output: &str) { - // After Enter, \r moves cursor to col 0 of the "> command" line. - // Clear that line and everything below, then draw game UI. - execute!( - stdout(), - Clear(ClearType::CurrentLine), - Clear(ClearType::FromCursorDown), - MoveToColumn(0) - ) - .unwrap(); - print!("{}", game_output); - stdout().flush().unwrap(); -} +fn map_key(key: crossterm::event::KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('c') | KeyCode::Char('d') => return Some(Key::CtrlC), + _ => {} + } + } -fn redraw_game_lines(game_output: &str, lines: usize) { - use crossterm::cursor::MoveUp; - if lines > 0 { - execute!(stdout(), MoveUp(lines as u16)).unwrap(); + match key.code { + KeyCode::Char(c) => Some(Key::Char(c)), + KeyCode::Enter => Some(Key::Enter), + KeyCode::Backspace => Some(Key::Backspace), + KeyCode::Up => Some(Key::Up), + KeyCode::Down => Some(Key::Down), + KeyCode::Tab => Some(Key::Tab), + KeyCode::Esc => Some(Key::Esc), + _ => None, } - execute!( - stdout(), - Clear(ClearType::CurrentLine), - Clear(ClearType::FromCursorDown), - MoveToColumn(0) - ) - .unwrap(); - print!("{}", game_output); - stdout().flush().unwrap(); } -fn redraw(current_input: &str) { - execute!(stdout(), Clear(ClearType::CurrentLine), MoveToColumn(0)).unwrap(); - print!("> {}", current_input); - stdout().flush().unwrap(); - execute!(stdout(), MoveToColumn((2 + current_input.len()) as u16)).unwrap(); +fn restore_terminal(cleanup_done: &Arc>) { + let mut done = cleanup_done.lock().unwrap(); + if !*done { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), Show); + *done = true; + } } fn read_line(prompt: &str) -> String { @@ -840,7 +430,7 @@ fn read_line(prompt: &str) -> String { loop { match read().unwrap() { Event::Key(key) => { - if key.kind == crossterm::event::KeyEventKind::Press { + if key.kind == KeyEventKind::Press { match key.code { KeyCode::Enter => { println!(); @@ -849,10 +439,12 @@ fn read_line(prompt: &str) -> String { KeyCode::Backspace => { if !input.is_empty() { input.pop(); - // Move cursor back and clear from current position to end of line execute!( stdout(), - MoveToColumn((prompt_len + input.len() + 1) as u16), + MoveToColumn(input_cursor_column( + prompt_len, + input.len() + )), Clear(ClearType::UntilNewLine) ) .unwrap(); @@ -885,7 +477,7 @@ fn read_password(prompt: &str) -> String { loop { match read().unwrap() { Event::Key(key) => { - if key.kind == crossterm::event::KeyEventKind::Press { + if key.kind == KeyEventKind::Press { match key.code { KeyCode::Enter => { println!(); @@ -894,10 +486,12 @@ fn read_password(prompt: &str) -> String { KeyCode::Backspace => { if !input.is_empty() { input.pop(); - // Move cursor back and clear from current position to end of line execute!( stdout(), - MoveToColumn((prompt_len + input.len() + 1) as u16), + MoveToColumn(input_cursor_column( + prompt_len, + input.len() + )), Clear(ClearType::UntilNewLine) ) .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 @@ +use crate::client::games::manager::GameManager; +use crate::client::games::response::parse_spin_response; +use crate::client::ui::{ + command_names, help_lines, input_cursor_column, parse_bet_arg, record_history, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Char(char), + Enter, + Backspace, + Up, + Down, + Tab, + Esc, + CtrlC, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InteractiveCommand { + Login, + Register, + SetCred, + Connect(Option), +} + +/// High-level actions produced by the client core. The terminal adapter is +/// responsible for turning these into crossterm calls and network writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Effect { + /// Append lines to the scrolling transcript. + Print(Vec), + /// Replace the game board region in place. + ReplaceRegion(Vec), + /// Remove the game board region. + ClearRegion, + /// Redraw the command prompt. `text` is the input without the `> ` prefix. + SetPrompt { text: String, cursor_col: u16 }, + /// Send one protocol line and feed the reply to `handle_reply`. + SendLine(String), + /// Delegate an interactive prompt flow to the terminal adapter. + Interactive(InteractiveCommand), + /// Drop the connection. + Disconnect, + /// Let the adapter read the host database. + ListHosts, + /// Let the adapter delete a host from the database. + ForgetHost(String), + /// Leave the program. + Quit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pending { + Spin, + Ping, + Echo, + Info, +} + +pub struct ClientCore { + input: String, + history: Vec, + history_index: usize, + game: GameManager, + connected: bool, + logged_in: Option, + pending: Option, + balance: Option, + host: Option, + fingerprint: Option, +} + +impl ClientCore { + pub fn new() -> Self { + Self { + input: String::new(), + history: Vec::new(), + history_index: 0, + game: GameManager::new(), + connected: false, + logged_in: None, + pending: None, + balance: None, + host: None, + fingerprint: None, + } + } + + pub fn set_connected(&mut self, connected: bool) { + self.connected = connected; + if !connected { + self.logged_in = None; + self.pending = None; + } + } + + pub fn set_logged_in(&mut self, user: Option) { + self.logged_in = user; + } + + pub fn set_balance(&mut self, balance: i64) { + self.balance = Some(balance); + self.game.set_balance(balance); + } + + pub fn set_host_info(&mut self, host: Option, fingerprint: Option) { + self.host = host; + self.fingerprint = fingerprint; + } + + pub fn is_in_game(&self) -> bool { + self.game.is_in_game() + } + + pub fn prompt_effect(&self) -> Option { + if self.is_in_game() { + return None; + } + Some(Effect::SetPrompt { + text: self.input.clone(), + cursor_col: input_cursor_column(2, self.input.chars().count()), + }) + } + + pub fn handle_key(&mut self, key: Key) -> Vec { + if self.game.is_in_game() { + return self.handle_game_key(key); + } + + match key { + Key::CtrlC => vec![Effect::Quit], + Key::Enter => self.submit(), + Key::Backspace => { + self.input.pop(); + self.prompt_effects() + } + Key::Char(c) => { + self.input.push(c); + self.prompt_effects() + } + Key::Up => self.history_previous(), + Key::Down => self.history_next(), + Key::Tab => self.complete(), + Key::Esc => Vec::new(), + } + } + + pub fn handle_reply(&mut self, reply: &str) -> Vec { + match self.pending.take() { + Some(Pending::Spin) => match parse_spin_response(reply) { + Ok(result) => { + self.balance = Some(result.balance); + self.game.apply_result(result); + self.board_effects() + } + Err(message) => { + self.game.set_error(message); + self.board_effects() + } + }, + Some(Pending::Ping) => { + if reply.trim() == "PONG" { + print_line("Pong") + } else { + self.connected = false; + self.logged_in = None; + vec![ + Effect::Disconnect, + Effect::Print(vec![ + "Connection lost".to_string(), + ]), + ] + } + } + Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), + Some(Pending::Info) => { + if self.game.is_in_game() { + // Balance fetch while entering a game. A failure is not + // fatal: the board keeps showing `?`. + if let Some(balance) = info_balance(reply) { + self.balance = Some(balance); + self.game.set_balance(balance); + return self.board_effects(); + } + Vec::new() + } else { + vec![Effect::Print(info_lines(reply))] + } + } + None => print_line(reply.trim()), + } + } + + fn handle_game_key(&mut self, key: Key) -> Vec { + match key { + Key::CtrlC => vec![Effect::Quit], + Key::Esc | Key::Char('q') => { + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Game exited.".to_string()]), + ] + } + Key::Char(' ') => self.spin(), + Key::Char('+') => { + let _ = self.game.adjust_bet(10); + self.board_effects() + } + Key::Char('-') => { + let _ = self.game.adjust_bet(-10); + self.board_effects() + } + _ => Vec::new(), + } + } + + fn spin(&mut self) -> Vec { + let Some(request) = self.game.spin_request() else { + return Vec::new(); + }; + if self.game.begin_spin().is_err() { + return Vec::new(); + } + self.pending = Some(Pending::Spin); + + let mut effects = self.board_effects(); + effects.push(Effect::SendLine(request)); + effects + } + + fn submit(&mut self) -> Vec { + let line = self.input.trim().to_string(); + self.input.clear(); + if line.is_empty() { + return Vec::new(); + } + + record_history(&mut self.history, &line); + self.history_index = self.history.len(); + + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + match parts[0] { + "play" => self.cmd_play(&parts), + "games" => self.available_games(None), + "help" => vec![Effect::Print(help_lines())], + "connect" => { + if self.connected { + print_line("Already connected. Disconnect first.") + } else { + let target = parts.get(1).map(|value| value.to_string()); + vec![Effect::Interactive(InteractiveCommand::Connect(target))] + } + } + "login" => { + if !self.connected { + print_line("Not connected. Use 'connect :' first.") + } else if self.logged_in.is_some() { + print_line(format!( + "Already logged in as {}", + self.logged_in.as_deref().unwrap_or("?") + )) + } else { + vec![Effect::Interactive(InteractiveCommand::Login)] + } + } + "register" => { + if !self.connected { + print_line("Not connected. Use 'connect :' first.") + } else { + vec![Effect::Interactive(InteractiveCommand::Register)] + } + } + "setcred" => { + if self.host.is_none() { + print_line("No host connected. Use 'connect :' first.") + } else { + vec![Effect::Interactive(InteractiveCommand::SetCred)] + } + } + "logout" => { + self.logged_in = None; + print_line("Logged out") + } + "disconnect" => { + self.connected = false; + self.logged_in = None; + self.pending = None; + self.game.end_game(); + vec![ + Effect::ClearRegion, + Effect::Disconnect, + Effect::Print(vec!["Disconnected".to_string()]), + ] + } + "ping" => { + if !self.connected { + print_line("Not connected. Use 'connect :' first.") + } else { + self.pending = Some(Pending::Ping); + vec![Effect::SendLine("PING".to_string())] + } + } + "send" => { + if !self.connected { + print_line("Not connected. Use 'connect :' first.") + } else if parts.len() < 2 { + print_line("Usage: send ") + } else { + let message = parts[1..].join(" "); + self.pending = Some(Pending::Echo); + vec![Effect::SendLine(message)] + } + } + "info" => self.cmd_info(), + "hosts" => vec![Effect::ListHosts], + "forget-host" => { + let target = parts + .get(1) + .map(|value| value.to_string()) + .or_else(|| self.host.clone()); + match target { + Some(target) => vec![Effect::ForgetHost(target)], + None => print_line("Usage: forget-host : (or connect first)"), + } + } + "quit" => vec![Effect::Quit], + _ => vec![Effect::Print(help_lines())], + } + } + + fn cmd_play(&mut self, parts: &[&str]) -> Vec { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + if self.logged_in.is_none() { + return print_line("Not logged in. Use 'login' first."); + } + if parts.len() < 2 { + return self.available_games(Some("Usage: play [bet]")); + } + + let bet = match parts.get(2) { + Some(raw) => match parse_bet_arg(raw) { + Ok(bet) => Some(bet), + Err(message) => return print_line(message), + }, + None => None, + }; + + if self.game.is_in_game() { + return print_line("Already in a game. Use 'quit' to exit current game."); + } + + match self.game.enter_game(parts[1]) { + Ok(()) => { + if let Some(bet) = bet { + let _ = self.game.set_bet(bet); + } + if let Some(balance) = self.balance { + self.game.set_balance(balance); + } + + let mut effects = self.board_effects(); + self.pending = Some(Pending::Info); + effects.push(Effect::SendLine("INFO".to_string())); + effects + } + Err(_) => print_line("Failed to enter game"), + } + } + + fn available_games(&self, usage: Option<&str>) -> Vec { + let mut lines = vec!["Available games:".to_string()]; + for (game_type, name) in self.game.get_available_games() { + lines.push(format!(" - {} ({})", game_type, name)); + } + if let Some(usage) = usage { + lines.push(String::new()); + lines.push(usage.to_string()); + } + vec![Effect::Print(lines)] + } + + fn cmd_info(&mut self) -> Vec { + if !self.connected { + return print_line("Not connected to any host."); + } + + let mut effects = Vec::new(); + match &self.host { + Some(host) => effects.push(Effect::Print(vec![format!("Connected to: {host}")])), + None => effects.push(Effect::Print(vec!["Connected to: (unknown)".to_string()])), + } + match &self.fingerprint { + Some(fingerprint) => { + effects.push(Effect::Print(vec![format!("Fingerprint: {fingerprint}")])) + } + None => effects.push(Effect::Print(vec![ + "Fingerprint: (not trusted)".to_string(), + ])), + } + + self.pending = Some(Pending::Info); + effects.push(Effect::SendLine("INFO".to_string())); + effects + } + + fn history_previous(&mut self) -> Vec { + if self.history_index == 0 { + return Vec::new(); + } + self.history_index -= 1; + self.input = self.history[self.history_index].clone(); + self.prompt_effects() + } + + fn history_next(&mut self) -> Vec { + if self.history_index >= self.history.len() { + return Vec::new(); + } + self.history_index += 1; + self.input = if self.history_index == self.history.len() { + String::new() + } else { + self.history[self.history_index].clone() + }; + self.prompt_effects() + } + + fn complete(&mut self) -> Vec { + let prefix = self.input.trim(); + let matches: Vec<&'static str> = command_names() + .into_iter() + .filter(|command| command.starts_with(prefix)) + .collect(); + + let mut effects = Vec::new(); + if matches.len() == 1 { + self.input = matches[0].to_string(); + } else if matches.len() > 1 { + let mut lines = vec!["Possible completions:".to_string()]; + lines.push(matches.join(" ")); + effects.push(Effect::Print(lines)); + } + if let Some(prompt) = self.prompt_effect() { + effects.push(prompt); + } + effects + } + + fn board_lines(&self) -> Option> { + let game = self.game.get_current_game()?; + let state = self.game.get_current_state()?; + Some(game.render(state).lines().map(str::to_string).collect()) + } + + fn board_effects(&self) -> Vec { + self.board_lines() + .map(Effect::ReplaceRegion) + .into_iter() + .collect() + } + + fn prompt_effects(&self) -> Vec { + self.prompt_effect().into_iter().collect() + } +} + +fn print_line(message: impl Into) -> Vec { + vec![Effect::Print(vec![message.into()])] +} + +fn info_balance(reply: &str) -> Option { + let parts: Vec<&str> = reply.split_whitespace().collect(); + if parts.first() == Some(&"INFO") && parts.len() == 4 { + parts[3].parse().ok() + } else { + None + } +} + +fn info_lines(reply: &str) -> Vec { + let parts: Vec<&str> = reply.split_whitespace().collect(); + if parts.first() == Some(&"INFO") && parts.len() == 4 { + vec![ + format!("User: {}", parts[1]), + format!("Account: {}", parts[2]), + format!("Balance: {} PLN", parts[3]), + ] + } else { + vec![reply.trim().to_string()] + } +} 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 @@ +use super::response::SpinResult; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; use std::collections::HashMap; -use std::io::Write; pub struct GameManager { current_session: Option, - pending_game_type: Option, games: HashMap>, } @@ -16,16 +15,18 @@ impl GameManager { Self { current_session: None, - pending_game_type: None, games, } } pub fn get_available_games(&self) -> Vec<(&str, &str)> { - self.games + let mut games: Vec<(&str, &str)> = self + .games .iter() - .map(|(k, v)| (k.as_str(), v.get_name())) - .collect() + .map(|(key, game)| (key.as_str(), game.get_name())) + .collect(); + games.sort_unstable_by_key(|(key, _)| *key); + games } pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { @@ -37,8 +38,6 @@ impl GameManager { return Err(GameError::InvalidAction); } - self.pending_game_type = Some(game_type.to_string()); - self.current_session = Some(GameState { game_type: game_type.to_string(), bet_amount: 10, @@ -49,28 +48,75 @@ impl GameManager { Ok(()) } - pub fn start_game(&mut self, stream: &mut W) -> Result<(), GameError> { - let game_type = self - .pending_game_type - .as_ref() - .ok_or(GameError::InvalidAction)?; + /// The wire request for a spin, if the current phase allows one. + pub fn spin_request(&self) -> Option { + let session = self.current_session.as_ref()?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return None; + } + Some(format!( + "PLAY_START {} {}", + session.game_type, session.bet_amount + )) + } - let state = self + /// Move the board into its spinning state and drop the previous result + /// so stale reels/winnings cannot leak into the new spin. + pub fn begin_spin(&mut self) -> Result<(), GameError> { + let session = self .current_session .as_mut() .ok_or(GameError::InvalidAction)?; - - if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { return Err(GameError::InvalidAction); } - let bet_amount = state.bet_amount; + session.phase = GamePhase::Playing; + session.data.remove("error"); + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + Ok(()) + } - let writer = stream; - writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) - .map_err(|_| GameError::NetworkError)?; + /// Store the settled spin so `render()` can draw it in place. + pub fn apply_result(&mut self, result: SpinResult) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::Finished; + session.bet_amount = result.bet_amount; + session.data.remove("error"); + session.data.insert("reels".to_string(), result.reels); + session + .data + .insert("win_type".to_string(), result.win_type); + session + .data + .insert("winnings".to_string(), result.winnings.to_string()); + session + .data + .insert("balance".to_string(), result.balance.to_string()); + } - Ok(()) + /// Show a failed spin without leaving the player stuck in `Playing`. + pub fn set_error(&mut self, message: impl Into) { + let Some(session) = self.current_session.as_mut() else { + return; + }; + session.phase = GamePhase::WaitingToStart; + session.data.remove("reels"); + session.data.remove("win_type"); + session.data.remove("winnings"); + session.data.insert("error".to_string(), message.into()); + } + + pub fn set_balance(&mut self, balance: i64) { + if let Some(session) = self.current_session.as_mut() { + session + .data + .insert("balance".to_string(), balance.to_string()); + } } pub fn get_current_game(&self) -> Option<&dyn Game> { @@ -83,49 +129,37 @@ impl GameManager { self.current_session.as_ref() } - pub fn get_current_render_lines(&self) -> Option { - let session = self.current_session.as_ref()?; - let game = self.games.get(&session.game_type)?; - Some(game.render_lines(session)) - } - pub fn is_in_game(&self) -> bool { self.current_session.is_some() } - pub fn is_game_started(&self) -> bool { - self.current_session - .as_ref() - .map(|s| s.phase != GamePhase::WaitingToStart) - .unwrap_or(false) + pub fn end_game(&mut self) { + self.current_session = None; } - pub fn reset_phase_to_waiting(&mut self) { - if let Some(ref mut session) = self.current_session { - session.phase = GamePhase::WaitingToStart; - session.data.clear(); + pub fn set_bet(&mut self, bet: u64) -> Result { + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); } - } - - pub fn end_game(&mut self) { - self.current_session = None; - self.pending_game_type = None; + session.bet_amount = bet.max(10); + Ok(session.bet_amount) } pub fn adjust_bet(&mut self, delta: i64) -> Result { - if let Some(ref session) = self.current_session { - if session.phase != GamePhase::WaitingToStart { - return Err(GameError::InvalidAction); - } + let session = self + .current_session + .as_mut() + .ok_or(GameError::InvalidAction)?; + if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { + return Err(GameError::InvalidAction); } - if let Some(ref mut session) = self.current_session { - let current_bet = session.bet_amount as i64; - let new_bet = (current_bet + delta).max(10) as u64; - session.bet_amount = new_bet; - Ok(new_bet) - } else { - Err(GameError::InvalidAction) - } + let current_bet = session.bet_amount as i64; + session.bet_amount = (current_bet + delta).max(10) as u64; + Ok(session.bet_amount) } } 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 @@ pub mod manager; +pub mod response; pub mod slots; pub 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 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpinResult { + pub session_id: String, + pub winnings: u64, + pub bet_amount: u64, + pub balance: i64, + pub reels: String, + pub win_type: String, +} + +/// Parse one server reply to `PLAY_START`. +/// +/// `Ok` carries the settled spin; `Err` carries a message that is safe to +/// show to the player (server errors are reported without the `ERROR ` prefix). +pub fn parse_spin_response(response: &str) -> Result { + let response = response.trim(); + if response.is_empty() { + return Err("No response from server".to_string()); + } + + if let Some(message) = response.strip_prefix("ERROR") { + let message = message.trim(); + return Err(if message.is_empty() { + "Server error".to_string() + } else { + message.to_string() + }); + } + + let parts: Vec<&str> = response.split_whitespace().collect(); + if parts.first() != Some(&"GAME_RESULT") { + return Err(format!("Unexpected server response: {response}")); + } + if parts.len() < 7 { + return Err("Malformed GAME_RESULT from server".to_string()); + } + + let winnings = parts[2] + .parse::() + .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?; + let bet_amount = parts[3] + .parse::() + .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?; + let balance = parts[4] + .parse::() + .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; + + if parts[5].chars().count() != 3 { + return Err(format!("Invalid reels from server: {}", parts[5])); + } + + Ok(SpinResult { + session_id: parts[1].to_string(), + winnings, + bet_amount, + balance, + reels: parts[5].to_string(), + win_type: parts[6].to_string(), + }) +} 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 @@ -use super::traits::{Game, GameState}; +use super::traits::{Game, GamePhase, GameState}; + +/// Height of the game board. It must never depend on the spin result, or an +/// in-place redraw would move to the wrong line. +pub const SLOT_BOARD_LINES: usize = 7; pub struct SlotsGame; @@ -8,17 +12,36 @@ impl SlotsGame { } fn render_reels(&self, reels: &str) -> String { - if reels.is_empty() { - "[ ? ] [ ? ] [ ? ]".to_string() - } else { - let chars: Vec = reels.chars().collect(); - format!( - "[ {} ] [ {} ] [ {} ]\r", - chars.get(0).unwrap_or(&'?'), - chars.get(1).unwrap_or(&'?'), - chars.get(2).unwrap_or(&'?') - ) + let mut chars = reels.chars(); + format!( + "[ {} ] [ {} ] [ {} ]", + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?'), + chars.next().unwrap_or('?') + ) + } + + /// The fifth board line: current status, result, or error. + fn render_status(&self, state: &GameState) -> String { + if let Some(error) = state.data.get("error") { + return format!("ERROR: {error}"); + } + if state.phase == GamePhase::Playing { + return "Spinning...".to_string(); + } + if state.phase == GamePhase::Finished { + let winnings = state + .data + .get("winnings") + .map(String::as_str) + .unwrap_or("0"); + return match state.data.get("win_type").map(String::as_str) { + Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), + Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), + _ => "No win.".to_string(), + }; } + "Press [SPACE] to spin!".to_string() } } @@ -29,16 +52,23 @@ impl Game for SlotsGame { fn render(&self, state: &GameState) -> String { let reels = state.data.get("reels").cloned().unwrap_or_default(); + let balance = state + .data + .get("balance") + .map(String::as_str) + .unwrap_or("?"); format!( - "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", + "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", state.bet_amount, - self.render_reels(&reels) + balance, + self.render_reels(&reels), + self.render_status(state) ) } fn render_lines(&self, _state: &GameState) -> usize { - 6 + SLOT_BOARD_LINES } 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 @@ +pub mod core; pub mod database; pub mod games; +pub 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 @@ +//! Pure text/history helpers for the client TUI. +//! +//! Nothing here touches the terminal, so it is unit-tested from `tests/` +//! without a PTY. + +struct CommandHelp { + name: &'static str, + usage: &'static str, + description: &'static str, +} + +const COMMANDS: &[CommandHelp] = &[ + CommandHelp { + name: "connect", + usage: "connect :", + description: "connect to the server", + }, + CommandHelp { + name: "send", + usage: "send ", + description: "send a message to the server", + }, + CommandHelp { + name: "disconnect", + usage: "disconnect", + description: "disconnect from the server", + }, + CommandHelp { + name: "ping", + usage: "ping", + description: "ping the server", + }, + CommandHelp { + name: "login", + usage: "login", + description: "login to the server", + }, + CommandHelp { + name: "register", + usage: "register", + description: "register a new user", + }, + CommandHelp { + name: "setcred", + usage: "setcred", + description: "set credentials for a host", + }, + CommandHelp { + name: "logout", + usage: "logout", + description: "logout from the server", + }, + CommandHelp { + name: "info", + usage: "info", + description: "show account and connection info", + }, + CommandHelp { + name: "play", + usage: "play [bet]", + description: "start a game (e.g. play slots 50)", + }, + CommandHelp { + name: "games", + usage: "games", + description: "list available games", + }, + CommandHelp { + name: "help", + usage: "help", + description: "show this message", + }, + CommandHelp { + name: "forget-host", + usage: "forget-host [ip:port]", + description: "clear saved trust fingerprint and credentials", + }, + CommandHelp { + name: "hosts", + usage: "hosts", + description: "list remembered hosts", + }, + CommandHelp { + name: "quit", + usage: "quit", + description: "quit the program", + }, +]; + +pub fn command_names() -> Vec<&'static str> { + COMMANDS.iter().map(|command| command.name).collect() +} + +pub fn help_lines() -> Vec { + let mut lines = vec!["Available commands:".to_string()]; + for command in COMMANDS { + lines.push(format!( + " {:<28} - {}", + command.usage, command.description + )); + } + lines +} + +/// Add a command to the history, skipping blanks and consecutive duplicates. +pub fn record_history(history: &mut Vec, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + if history.last().is_some_and(|last| last == line) { + return; + } + history.push(line.to_string()); +} + +/// Parse the optional bet argument of `play [bet]`. +pub fn parse_bet_arg(raw: &str) -> Result { + let bet = raw + .trim() + .parse::() + .map_err(|_| format!("Invalid bet: {raw}"))?; + if bet < 10 { + return Err("Bet must be at least 10 PLN".to_string()); + } + Ok(bet) +} + +/// Lines to move the cursor up to reach the first line of a region that ends +/// on the current line. `render()` has no trailing newline, so a 7-line board +/// needs `MoveUp(6)`. +pub fn lines_to_move_up(previous_lines: usize) -> u16 { + previous_lines + .saturating_sub(1) + .min(u16::MAX as usize) as u16 +} + +/// Column (0-based) to move to after the given prompt and input text. +pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { + prompt_len + .saturating_add(input_len) + .min(u16::MAX as usize) as u16 +} -- cgit v1.2.3