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