use rusqlite::Connection; use std::io::{stdout, Write}; use std::sync::{Arc, Mutex}; use std::time::Instant; use crossterm::{ cursor::{MoveToColumn, MoveUp, Show}, event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, execute, style::Stylize, 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::{ display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, visual_line_count, }; use zagan_bet::protocol::io_failure_message; use zagan_bet::roulette::WHEEL; use crate::connection::TlsStream; pub struct ClientState { pub stream: Option, pub conn: Connection, pub addr: Option, pub default_port: u16, core: ClientCore, 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, } impl ClientState { pub fn new() -> Self { let conn = rusqlite::Connection::open( std::env::var("CLIENT_DB").unwrap_or_else(|_| "client.db".to_string()), ) .unwrap(); 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)); Self { stream: None, conn, addr, default_port, core: ClientCore::new(), cleanup_done: Arc::new(Mutex::new(false)), 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((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 event = read().unwrap(); match event { Event::Key(key) => { 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(); } Event::Resize(_, _) => self.apply_prompt(), _ => {} } } restore_terminal(&self.cleanup_done); } 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::ReplaceRegion(lines) => self.draw_region(&lines), Effect::AnimateRoulette { winning, final_lines, } => { self.animate_wheel(winning); self.draw_region(&final_lines); while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { let _ = crossterm::event::read(); } } Effect::ClearRegion => self.clear_region(), Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), 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::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; } } } false } fn apply_prompt(&mut self) { if let Some(prompt) = self.core.prompt_effect() { let _ = self.apply_effects(vec![prompt]); } } /// 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(); } 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(); let rendered: Vec = lines .iter() .map(|line| { if line.starts_with("===") { line.as_str().bold().cyan().to_string() } 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; format!("[ {:>2} ]", WHEEL[index]) }) .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(); }; 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(error) => { self.stream = None; io_failure_message(&error).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: ", &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") { 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); } 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())); 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 list_hosts(&mut self) { let hosts = database::get_all_hosts(&self.conn); for line in host_lines(&hosts) { println!("{}", line); } } } 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::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() }