use rusqlite::Connection; use std::io::{Write, stdout}; use std::sync::{Arc, Mutex}; use std::time::Instant; use crossterm::{ cursor::{MoveToColumn, MoveUp, Show}, event::{Event, KeyCode, KeyEventKind, KeyModifiers, read}, execute, style::Stylize, terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode}, }; use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect}; use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; use zagan_bet::client::ui::{ display_width, format_pong, help_lines, host_lines, input_cursor_column, lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, }; use zagan_bet::protocol::io_failure_message; use zagan_bet::roulette::{WHEEL, color as roulette_color}; use crate::connection::TlsStream; pub struct ClientState { pub stream: Option, pub conn: Connection, pub addr: Option, pub default_port: u16, core: ClientCore, editor: Editor, cleanup_done: Arc>, /// Number of visual 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, /// Last known terminal size, updated from resize events. terminal_size: (u16, u16), /// Size we already warned about, so a resize does not spam the transcript. size_warned: Option<(u16, u16)>, /// Last roulette result the wheel animation ran for. last_roulette: Option, } impl ClientState { pub fn new(db_path: &std::path::Path) -> Result { let conn = rusqlite::Connection::open(db_path) .map_err(|error| format!("cannot open {}: {error}", db_path.display()))?; database::init_db(&conn); let default_ip = "127.0.0.1".to_string(); let default_port = 11525; let last_host = database::get_last_host(&conn); let default_host = format!("{}:{}", default_ip, default_port); let addr = last_host.or(Some(default_host)); Ok(Self { stream: None, conn, addr, default_port, core: ClientCore::new(), editor: Editor::new(), cleanup_done: Arc::new(Mutex::new(false)), game_lines: 0, prompt_text: String::new(), terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), size_warned: None, last_roulette: None, }) } pub fn connect_to(&mut self, addr: &str) { self.addr = Some(addr.to_string()); match crate::connection::connect(addr, &self.conn) { 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) => { self.core.set_connected(false); println!("\nFailed to connect: {}", e); } } } pub fn run(&mut self) { let cleanup_done = self.cleanup_done.clone(); let panic_cleanup = cleanup_done.clone(); std::panic::set_hook(Box::new(move |_| { restore_terminal(&panic_cleanup); })); let ctrlc_cleanup = cleanup_done.clone(); if let Err(e) = ctrlc::set_handler(move || { restore_terminal(&ctrlc_cleanup); std::process::exit(0); }) { eprintln!("Warning: Could not set Ctrl+C handler: {}", e); } enable_raw_mode().unwrap(); self.apply_prompt(); loop { let mut quit = false; if crossterm::event::poll(std::time::Duration::from_millis(25)).unwrap_or(false) { match read().unwrap() { Event::Key(key) => { if key.kind == KeyEventKind::Press { if let Some(key) = map_key(key) { let effects = if self.core.is_in_game() { match game_input( self.core.current_game_type().unwrap_or(""), key, ) { GameInput::Quit => vec![Effect::Quit], GameInput::Command(command) => { self.core.command(command) } GameInput::None => Vec::new(), } } else { match self.editor.handle(key) { Input::Quit => vec![Effect::Quit], Input::Submit(line) => self.submit_line(&line), Input::Print(lines) => vec![Effect::Print(lines)], Input::Prompt | Input::None => Vec::new(), } }; quit = self.apply_effects(effects); if !quit { self.apply_prompt(); } } } } Event::Resize(columns, rows) => { self.terminal_size = (columns, rows); if self.core.is_in_game() { let effects = self.core.refresh_board(); self.apply_effects(effects); } else { self.apply_prompt(); } } _ => {} } } if !quit && self.core.expects_push() { quit = self.pump_async(); } if !quit { let effects = self.core.tick(); if !effects.is_empty() { quit = self.apply_effects(effects); } } if !quit { quit = self.warn_about_size(); } if quit { break; } } restore_terminal(&self.cleanup_done); } /// Read any server-initiated lines that are already waiting and apply them. /// Returns true when an effect asked the program to quit. fn pump_async(&mut self) -> bool { loop { let Some(mut stream) = self.stream.take() else { return false; }; let line = match stream.read_available_line() { Ok(line) => { self.stream = Some(stream); line } Err(_) => { self.core.set_connected(false); self.stream = None; return false; } }; let Some(line) = line else { return false; }; let effects = match self.core.handle_async_line(&line) { Some(effects) => effects, None => self.core.handle_reply(&line), }; if self.apply_effects(effects) { return true; } } } 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 { let rendered = if line.starts_with("ERROR") || line.contains("failed") { line.as_str().red().to_string() } else if line.starts_with("Logged in") || line.starts_with("Successfully") { line.as_str().green().to_string() } else if line.starts_with("Available commands:") || line.starts_with("Available games:") || line.starts_with("Remembered hosts:") { line.as_str().bold().cyan().to_string() } else { line }; println!("{}", rendered); } } Effect::Render(view) => { if let View::Roulette(roulette) = &view { if roulette.phase == RoulettePhase::Result { if let Some(number) = roulette.number { if self.last_roulette != Some(number) { self.animate_wheel(number); self.last_roulette = Some(number); while crossterm::event::poll(std::time::Duration::ZERO) .unwrap_or(false) { let _ = crossterm::event::read(); } } } } else { self.last_roulette = None; } } let lines = zagan_bet::client::tui::render_view(&view); self.draw_region(&lines); } Effect::ClearRegion => self.clear_region(), Effect::SendLine(line) => { let is_ping = line == "PING"; let started = Instant::now(); let reply = self.send_and_read(&line); let elapsed = started.elapsed(); let mut more = self.core.handle_reply(&reply); if is_ping && reply.trim() == "PONG" { for effect in &mut more { if let Effect::Print(lines) = effect { for text in lines { if text == "Pong" { *text = format_pong(elapsed); } } } } } if self.stream.is_none() { self.core.set_connected(false); } if self.apply_effects(more) { return true; } } Effect::Disconnect => { self.stream = None; self.core.reset_after_disconnect(); self.clear_region(); } Effect::Quit => { let had_board = self.game_lines > 0; self.clear_region(); self.commit_prompt_line(); if had_board { println!(); } return true; } } } false } fn apply_prompt(&mut self) { if self.core.is_in_game() { return; } let text = self.editor.input().to_string(); let column = self.editor.cursor_column(); self.draw_prompt(&text, column); } /// 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(); } /// Warn once per size when the terminal is too small and no round is live. /// Inside a game the warning is appended to the board instead, and during a /// live round it is suppressed until the round settles. fn warn_about_size(&mut self) -> bool { let size = self.terminal_size; let Some(message) = terminal_size_warning(size.0, size.1) else { self.size_warned = None; return false; }; if self.core.is_in_game() || self.size_warned == Some(size) { return false; } self.size_warned = Some(size); self.apply_effects(vec![Effect::Print(vec![message])]); false } fn draw_region(&mut self, lines: &[String]) { let warning = if self.core.is_live_game() { None } else { terminal_size_warning(self.terminal_size.0, self.terminal_size.1) }; let owned; let lines: &[String] = match warning { Some(warning) => { owned = { let mut lines = lines.to_vec(); lines.push(warning); lines }; &owned } None => lines, }; 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(); let roulette = lines .first() .is_some_and(|first| first.contains("ROULETTE")); let crash = lines.first().is_some_and(|first| first.contains("CRASH")); let rendered: Vec = lines .iter() .map(|line| { if line.starts_with("===") { line.as_str().bold().cyan().to_string() } else if line.starts_with("WARNING:") { line.as_str().bold().red().to_string() } else if roulette { if line.starts_with("Won ") { line.as_str().bold().green().to_string() } else if line.starts_with("ERROR:") { line.as_str().red().to_string() } else { style_roulette_numbers(line) } } else if crash { if line.contains("CRASHED") { line.as_str().bold().red().to_string() } else if line.starts_with("Cashed out") { line.as_str().bold().green().to_string() } else if line.starts_with("Multiplier:") { line.as_str().yellow().to_string() } else if line.starts_with("ERROR:") { line.as_str().red().to_string() } else { line.clone() } } else if line.contains('{') { style_winning_cells(line) } else if line.contains("WIN!") || line.starts_with("Won ") { line.as_str().bold().green().to_string() } else if line.starts_with("Winning lines:") { line.as_str().yellow().to_string() } else if line.starts_with("ERROR:") { line.as_str().red().to_string() } else { line.clone() } }) .collect(); print!("{}", rendered.join("\r\n")); stdout().flush().unwrap(); let width = crossterm::terminal::size() .map(|(columns, _)| columns as usize) .unwrap_or(80); self.game_lines = visual_line_count(lines, width).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; } /// CS:GO-style wheel strip: the ball marker is fixed and the numbers move /// left-to-right under it, ending with the winning number in the center. fn animate_wheel(&mut self, winning: u8) { use std::thread; use std::time::Duration; let target = WHEEL .iter() .position(|number| *number == winning) .unwrap_or(0) as f64; let wheel_len = WHEEL.len() as f64; let start = target - 4.0 * wheel_len - 7.0; let frames = 32; for frame in 0..frames { let t = frame as f64 / (frames - 1) as f64; let ease = 1.0 - (1.0 - t).powi(3); let center = (start + (target - start) * ease).round() as isize; let strip = (0..9) .map(|offset| { let index = (center - 4 + offset).rem_euclid(WHEEL.len() as isize) as usize; let number = WHEEL[index]; let cell = format!("[ {:>2} ]", number); match roulette_color(number) { "red" => cell.red().to_string(), "green" => cell.green().to_string(), _ => cell, } }) .collect::>() .join(" "); let marker = format!("{}▼", " ".repeat(4 * 7 + 3)); let lines = vec![ "=== ROULETTE ===".to_string(), strip, marker, "Spinning...".to_string(), ]; self.draw_region(&lines); thread::sleep(Duration::from_millis(35)); } // Let the winning number sit under the static ball before the result // board replaces the wheel. thread::sleep(Duration::from_millis(450)); } fn draw_prompt(&mut self, text: &str, cursor_col: u16) { execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); print!("{}", "> ".cyan()); 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(); }; // A crash event can be pushed while a request is in flight. Route those // lines as they arrive and keep waiting for the actual reply. let mut pushed: Vec = Vec::new(); let result = (|| -> std::io::Result { stream.write_line(line)?; loop { 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", )); } let response = response.trim_end().to_string(); if self.core.is_async(&response) { pushed.push(response); continue; } return Ok(response); } })(); match result { Ok(response) => { self.stream = Some(stream); for line in pushed { if let Some(effects) = self.core.handle_async_line(&line) { self.apply_effects(effects); } } response } Err(error) => { self.stream = None; io_failure_message(&error).to_string() } } } /// Run one command line from the prompt. fn submit_line(&mut self, line: &str) -> Vec { match shell::parse(line) { Shell::Run(command) => self.core.command(command), Shell::Login => { self.do_login(); Vec::new() } Shell::Register => { self.do_register(); Vec::new() } Shell::SetCred => { self.do_setcred(); Vec::new() } Shell::Connect(target) => { self.do_connect(target); Vec::new() } Shell::Disconnect => { self.stream = None; self.core.reset_after_disconnect(); vec![ Effect::ClearRegion, Effect::Print(vec!["Disconnected".to_string()]), ] } Shell::Hosts => { self.list_hosts(); Vec::new() } Shell::ForgetHost(target) => { self.forget_host(target); Vec::new() } Shell::ForgetLogin(target) => { self.forget_login(target); Vec::new() } Shell::Help => vec![Effect::Print(help_lines())], Shell::Quit => vec![Effect::Quit], Shell::Empty => Vec::new(), Shell::Usage(message) => vec![Effect::Print(vec![message])], } } fn do_connect(&mut self, target: Option) { 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); } /// Drop the stored login for one server, keeping the pinned key. fn forget_login(&mut self, target: Option) { let Some(host) = target.or_else(|| self.addr.clone()) else { println!("Usage: forget-login [ip:port] (or connect first)"); return; }; let Some(identity) = database::get_fingerprint(&self.conn, &host) else { println!("No trusted server is remembered as {}.", host); return; }; match database::forget_credentials(&self.conn, &identity) { Ok(true) => println!( "Forgot the saved login for server {} ({}). Trust is unchanged.", short_fingerprint(&identity), host ), Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), Err(error) => println!("Failed to forget the saved login: {}", error), } } fn forget_host(&mut self, target: Option) { let Some(target) = target.or_else(|| self.addr.clone()) else { println!("Usage: forget-host : (or connect first)"); return; }; match database::forget_host(&self.conn, &target) { Ok(outcome) if outcome.credentials_removed => println!( "Cleared trust and stored credentials for {}.", target ), Ok(outcome) => { println!("Cleared trust for {}.", target); if !outcome.siblings.is_empty() { println!( "Stored credentials kept: this server is still reachable as {}.", outcome.siblings.join(", ") ); } } Err(error) => println!("Failed to forget host: {}", error), } } 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: ", &self.cleanup_done); if username.is_empty() { break; } let password = read_password("Password: ", &self.cleanup_done); 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") { match self.trusted_identity() { Some(identity) => { if let Err(error) = database::save_credentials(&self.conn, &identity, &username, &hashed) { println!("Failed to save credentials: {}", error); } } None => println!( "Credentials not saved: no verified certificate identity for this host." ), } 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); } if self.stream.is_none() { break; } } } fn do_register(&mut self) { let username = read_line("Username: ", &self.cleanup_done); if username.is_empty() { return; } let password = read_password("Password: ", &self.cleanup_done); 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 do_setcred(&mut self) { if self.addr.is_none() { println!("No host connected. Use 'connect :' first."); return; } let username = read_line("Username: ", &self.cleanup_done); if username.is_empty() { return; } let password = read_password("Password: ", &self.cleanup_done); if password.is_empty() { return; } let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let Some(identity) = self.trusted_identity() else { println!( "No verified certificate for this host. Connect first so its key can be trusted." ); return; }; match database::save_credentials(&self.conn, &identity, &username, &hashed) { Ok(_) => println!( "Credentials saved for server {} (every address with this key).", short_fingerprint(&identity) ), Err(error) => println!("Failed to save credentials: {}", error), } } /// Certificate identity of the current address, once it has been verified. fn trusted_identity(&self) -> Option { let addr = self.addr.as_deref()?; database::get_fingerprint(&self.conn, addr) } fn list_hosts(&mut self) { let hosts = database::get_all_hosts(&self.conn); for line in host_lines(&hosts) { println!("{}", line); } } } /// First few hex characters of a fingerprint, enough to tell servers apart. fn short_fingerprint(fingerprint: &str) -> String { fingerprint.chars().take(16).collect() } fn style_roulette_number(text: &str, number: u8) -> String { match roulette_color(number) { "red" => text.red().to_string(), "green" => text.green().to_string(), _ => text.to_string(), } } fn style_roulette_numbers(line: &str) -> String { if line.starts_with("Bets:") { let mut out = String::new(); let mut rest = line; while let Some(index) = rest.find('#') { out.push_str(&rest[..=index]); let after = &rest[index + 1..]; let digits: String = after .chars() .take_while(|character| character.is_ascii_digit()) .collect(); if let Ok(number) = digits.parse::() { if number <= 36 { out.push_str(&style_roulette_number(&digits, number)); } else { out.push_str(&digits); } } rest = &after[digits.len()..]; } out.push_str(rest); return out; } for label in ["Winning: ", "Number: "] { if let Some(index) = line.find(label) { let start = index + label.len(); let digits: String = line[start..] .chars() .take_while(|character| character.is_ascii_digit()) .collect(); if let Ok(number) = digits.parse::() { if number <= 36 { return format!( "{}{}{}", &line[..start], style_roulette_number(&digits, number), &line[start + digits.len()..] ); } } } } line.to_string() } fn style_winning_cells(line: &str) -> String { let chars: Vec = line.chars().collect(); let mut out = String::new(); let mut index = 0; while index < chars.len() { if chars[index] == '{' { let start = index; while index < chars.len() && chars[index] != '}' { index += 1; } if index < chars.len() { index += 1; } let cell: String = chars[start..index].iter().collect(); out.push_str(&cell.as_str().bold().yellow().to_string()); } else { out.push(chars[index]); index += 1; } } out } 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), _ => {} } } 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::Left => Some(Key::Left), KeyCode::Right => Some(Key::Right), KeyCode::Tab => Some(Key::Tab), KeyCode::Esc => Some(Key::Esc), _ => None, } } 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, cleanup_done: &Arc>) -> String { execute!(stdout(), Show).unwrap(); printsame!("{}", prompt); stdout().flush().unwrap(); let mut input = String::new(); loop { match read().unwrap() { Event::Key(key) => { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Enter => { println!(); break; } KeyCode::Backspace => { if !input.is_empty() { input.pop(); execute!( stdout(), MoveToColumn(input_cursor_column( display_width(prompt), display_width(&input) )), Clear(ClearType::UntilNewLine) ) .unwrap(); stdout().flush().unwrap(); } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { println!(); restore_terminal(cleanup_done); std::process::exit(0); } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { println!(); restore_terminal(cleanup_done); std::process::exit(0); } KeyCode::Esc => { println!(); return String::new(); } KeyCode::Char(c) => { input.push(c); print!("{}", c); stdout().flush().unwrap(); } _ => {} } } } _ => {} } } input.trim().to_string() } fn read_password(prompt: &str, cleanup_done: &Arc>) -> String { execute!(stdout(), Show).unwrap(); print!("{}", prompt); stdout().flush().unwrap(); let mut input = String::new(); loop { match read().unwrap() { Event::Key(key) => { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Enter => { println!(); break; } KeyCode::Backspace => { if !input.is_empty() { input.pop(); execute!( stdout(), MoveToColumn(input_cursor_column( display_width(prompt), display_width(&input) )), Clear(ClearType::UntilNewLine) ) .unwrap(); stdout().flush().unwrap(); } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { println!(); restore_terminal(cleanup_done); std::process::exit(0); } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { println!(); restore_terminal(cleanup_done); std::process::exit(0); } KeyCode::Esc => { println!(); return String::new(); } KeyCode::Char(c) => { input.push(c); print!("*"); stdout().flush().unwrap(); } _ => {} } } } _ => {} } } input.trim().to_string() }