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