From 8c93f69081f0012e9a97ffc59adb7129bc1593ee Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 18:51:05 +0100 Subject: v4.0.5: login and registration as core commands --- Cargo.toml | 2 +- docs/GUI-PLAN.md | 8 +++--- src/client/core.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++-- src/client/view.rs | 5 ++++ tests/client_core.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 076ffd6..bb7bfc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "4.0.4" +version = "4.0.5" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index eb0fd8e..f8ac3d5 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md @@ -30,9 +30,11 @@ existing game flows unchanged, new tests for version parsing and gating. core builds a typed view, frontends send `Command`, and the TUI renders it. Done so far: every game plus `Lobby` and `History` are structured views; the client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in -`client::transport` behind a trust callback. Remaining: the connection-facing -screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from -the core. +`client::transport` behind a trust callback; login and registration are +`Command`s, so a GUI form never needs a terminal prompt. Remaining: the +`Connect` and `Trust` screens (adapter-owned; the transport callback is ready) +and removing `Key` from the core. The TUI's interactive login still uses its +own prompt and should move to `Command::Login` when that prompt moves out. - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, wheel animation, interactive prompts) into the TUI adapter. diff --git a/src/client/core.rs b/src/client/core.rs index 37ba916..98f109f 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -66,7 +66,7 @@ pub enum Effect { Quit, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum Pending { Spin, RouletteSpin, @@ -77,6 +77,8 @@ enum Pending { CrashCashout, CrashForfeit, History, + Login { user: String }, + Register, Ping, Echo, Info, @@ -105,6 +107,8 @@ pub struct ClientCore { screen: Screen, /// Last plays fetched with `history`. play_history: Vec, + /// Why the last login or registration attempt failed. + login_error: Option, } impl ClientCore { @@ -123,6 +127,7 @@ impl ClientCore { crash_started: None, screen: Screen::Lobby, play_history: Vec::new(), + login_error: None, } } @@ -137,6 +142,9 @@ impl ClientCore { } pub fn set_logged_in(&mut self, user: Option) { + if user.is_some() { + self.login_error = None; + } self.logged_in = user; } @@ -312,6 +320,25 @@ impl ClientCore { } Err(message) => print_line(message), }, + Some(Pending::Login { user }) => match auth_result(reply) { + Ok(()) => { + self.logged_in = Some(user.clone()); + self.login_error = None; + self.screen = Screen::Lobby; + print_line(format!("Logged in as {user}")) + } + Err(message) => { + self.login_error = Some(message.clone()); + print_line(format!("Authentication failed: {message}")) + } + }, + Some(Pending::Register) => match register_result(reply) { + Ok(()) => print_line("Account created. Log in to play."), + Err(message) => { + self.login_error = Some(message.clone()); + print_line(format!("Registration failed: {message}")) + } + }, Some(Pending::Ping) => { if reply.trim() == "PONG" { print_line("Pong") @@ -404,7 +431,23 @@ impl ClientCore { let game = self.current_game_type().map(str::to_string); match command { - Command::Play { game, bet } => self.play_game(&game, bet), + Command::Login { user, credential } => { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + self.login_error = None; + self.pending = Some(Pending::Login { user: user.clone() }); + vec![Effect::SendLine(format!("AUTH {user} {credential}"))] + } + Command::Register { user, credential } => { + if !self.connected { + return print_line("Not connected. Use 'connect :' first."); + } + self.login_error = None; + self.pending = Some(Pending::Register); + vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] + } + Command::Play { game, bet } => self.play_game(&game, bet), Command::History { limit } => self.load_history(limit), Command::ShowLobby => { self.screen = Screen::Lobby; @@ -412,6 +455,7 @@ impl ClientCore { } Command::Logout => { self.logged_in = None; + self.login_error = None; self.screen = Screen::Lobby; print_line("Logged out") } @@ -1070,6 +1114,7 @@ impl ClientCore { .into_iter() .map(|(game, name)| (game.to_string(), name.to_string())) .collect(), + login_error: self.login_error.clone(), }), }) } @@ -1086,6 +1131,28 @@ impl ClientCore { } } +fn auth_result(reply: &str) -> Result<(), String> { + let reply = reply.trim(); + if reply.starts_with("OK") { + return Ok(()); + } + Err(reply + .strip_prefix("ERROR") + .map(|message| message.trim().to_string()) + .unwrap_or_else(|| reply.to_string())) +} + +fn register_result(reply: &str) -> Result<(), String> { + let reply = reply.trim(); + if reply.starts_with("REGISTERED") { + return Ok(()); + } + Err(reply + .strip_prefix("ERROR") + .map(|message| message.trim().to_string()) + .unwrap_or_else(|| reply.to_string())) +} + fn print_line(message: impl Into) -> Vec { vec![Effect::Print(vec![message.into()])] } diff --git a/src/client/view.rs b/src/client/view.rs index 903403c..9443726 100644 --- a/src/client/view.rs +++ b/src/client/view.rs @@ -248,6 +248,8 @@ pub struct LobbyView { pub user: Option, pub balance: Option, pub games: Vec<(String, String)>, + /// Why the last login or registration attempt failed. + pub login_error: Option, } /// The play log, already parsed. @@ -270,6 +272,9 @@ pub enum View { /// Semantic input. Frontends turn keys and clicks into these. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { + /// The credential is already hashed by the frontend. + Login { user: String, credential: String }, + Register { user: String, credential: String }, Play { game: String, bet: Option }, History { limit: u32 }, ShowLobby, diff --git a/tests/client_core.rs b/tests/client_core.rs index d070a5b..53a024c 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -1487,3 +1487,79 @@ fn logging_out_and_showing_the_lobby_switch_the_screen() { }; assert_eq!(lobby.user, None); } + +#[test] +fn a_login_command_sends_auth_and_sets_the_user() { + let mut h = Harness::new(); + h.core.set_logged_in(None); + + let effects = h.core.command(Command::Login { + user: "alice".to_string(), + credential: "hash".to_string(), + }); + h.apply(effects); + assert_eq!(h.sent.last().map(String::as_str), Some("AUTH alice hash")); + + h.reply("OK alice"); + let Some(View::Lobby(lobby)) = h.core.view() else { + panic!("expected the lobby"); + }; + assert_eq!(lobby.user.as_deref(), Some("alice")); + assert!(lobby.login_error.is_none()); + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Logged in as alice")); +} + +#[test] +fn a_rejected_login_is_shown_on_the_lobby() { + let mut h = Harness::new(); + h.core.set_logged_in(None); + h.core.command(Command::Login { + user: "alice".to_string(), + credential: "bad".to_string(), + }); + h.reply("ERROR Invalid credentials"); + + let Some(View::Lobby(lobby)) = h.core.view() else { + panic!("expected the lobby"); + }; + assert_eq!(lobby.user, None); + assert_eq!(lobby.login_error.as_deref(), Some("Invalid credentials")); +} + +#[test] +fn a_register_command_sends_register_and_reports_the_result() { + let mut h = Harness::new(); + h.core.set_logged_in(None); + + let effects = h.core.command(Command::Register { + user: "bob".to_string(), + credential: "hash".to_string(), + }); + h.apply(effects); + assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); + + h.reply("REGISTERED bob"); + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Account created. Log in to play.")); +} + +#[test] +fn login_requires_a_connection() { + let mut h = Harness::new(); + h.core.set_connected(false); + + let effects = h.core.command(Command::Login { + user: "alice".to_string(), + credential: "hash".to_string(), + }); + h.apply(effects); + + assert!(!h.sent.iter().any(|line| line.starts_with("AUTH"))); +} -- cgit v1.2.3