diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 18:51:05 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 18:51:05 +0100 |
| commit | 8c93f69081f0012e9a97ffc59adb7129bc1593ee (patch) | |
| tree | 4dfe458ca2e76e4d9d2ad8f52c2684d5c83178c9 | |
| parent | v4.0.4: move the client transport and TOFU into the library (diff) | |
| download | zagan-bet-8c93f69081f0012e9a97ffc59adb7129bc1593ee.tar.gz zagan-bet-8c93f69081f0012e9a97ffc59adb7129bc1593ee.zip | |
v4.0.5: login and registration as core commands
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 8 | ||||
| -rw-r--r-- | src/client/core.rs | 71 | ||||
| -rw-r--r-- | src/client/view.rs | 5 | ||||
| -rw-r--r-- | tests/client_core.rs | 76 |
5 files changed, 156 insertions, 6 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.4" | 3 | version = "4.0.5" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
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. | |||
| 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. | 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. |
| 31 | Done so far: every game plus `Lobby` and `History` are structured views; the | 31 | Done so far: every game plus `Lobby` and `History` are structured views; the |
| 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in | 32 | client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in |
| 33 | `client::transport` behind a trust callback. Remaining: the connection-facing | 33 | `client::transport` behind a trust callback; login and registration are |
| 34 | screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from | 34 | `Command`s, so a GUI form never needs a terminal prompt. Remaining: the |
| 35 | the core. | 35 | `Connect` and `Trust` screens (adapter-owned; the transport callback is ready) |
| 36 | and removing `Key` from the core. The TUI's interactive login still uses its | ||
| 37 | own prompt and should move to `Command::Login` when that prompt moves out. | ||
| 36 | 38 | ||
| 37 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 39 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 38 | wheel animation, interactive prompts) into the TUI adapter. | 40 | 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 { | |||
| 66 | Quit, | 66 | Quit, |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 69 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 70 | enum Pending { | 70 | enum Pending { |
| 71 | Spin, | 71 | Spin, |
| 72 | RouletteSpin, | 72 | RouletteSpin, |
| @@ -77,6 +77,8 @@ enum Pending { | |||
| 77 | CrashCashout, | 77 | CrashCashout, |
| 78 | CrashForfeit, | 78 | CrashForfeit, |
| 79 | History, | 79 | History, |
| 80 | Login { user: String }, | ||
| 81 | Register, | ||
| 80 | Ping, | 82 | Ping, |
| 81 | Echo, | 83 | Echo, |
| 82 | Info, | 84 | Info, |
| @@ -105,6 +107,8 @@ pub struct ClientCore { | |||
| 105 | screen: Screen, | 107 | screen: Screen, |
| 106 | /// Last plays fetched with `history`. | 108 | /// Last plays fetched with `history`. |
| 107 | play_history: Vec<PlayRecord>, | 109 | play_history: Vec<PlayRecord>, |
| 110 | /// Why the last login or registration attempt failed. | ||
| 111 | login_error: Option<String>, | ||
| 108 | } | 112 | } |
| 109 | 113 | ||
| 110 | impl ClientCore { | 114 | impl ClientCore { |
| @@ -123,6 +127,7 @@ impl ClientCore { | |||
| 123 | crash_started: None, | 127 | crash_started: None, |
| 124 | screen: Screen::Lobby, | 128 | screen: Screen::Lobby, |
| 125 | play_history: Vec::new(), | 129 | play_history: Vec::new(), |
| 130 | login_error: None, | ||
| 126 | } | 131 | } |
| 127 | } | 132 | } |
| 128 | 133 | ||
| @@ -137,6 +142,9 @@ impl ClientCore { | |||
| 137 | } | 142 | } |
| 138 | 143 | ||
| 139 | pub fn set_logged_in(&mut self, user: Option<String>) { | 144 | pub fn set_logged_in(&mut self, user: Option<String>) { |
| 145 | if user.is_some() { | ||
| 146 | self.login_error = None; | ||
| 147 | } | ||
| 140 | self.logged_in = user; | 148 | self.logged_in = user; |
| 141 | } | 149 | } |
| 142 | 150 | ||
| @@ -312,6 +320,25 @@ impl ClientCore { | |||
| 312 | } | 320 | } |
| 313 | Err(message) => print_line(message), | 321 | Err(message) => print_line(message), |
| 314 | }, | 322 | }, |
| 323 | Some(Pending::Login { user }) => match auth_result(reply) { | ||
| 324 | Ok(()) => { | ||
| 325 | self.logged_in = Some(user.clone()); | ||
| 326 | self.login_error = None; | ||
| 327 | self.screen = Screen::Lobby; | ||
| 328 | print_line(format!("Logged in as {user}")) | ||
| 329 | } | ||
| 330 | Err(message) => { | ||
| 331 | self.login_error = Some(message.clone()); | ||
| 332 | print_line(format!("Authentication failed: {message}")) | ||
| 333 | } | ||
| 334 | }, | ||
| 335 | Some(Pending::Register) => match register_result(reply) { | ||
| 336 | Ok(()) => print_line("Account created. Log in to play."), | ||
| 337 | Err(message) => { | ||
| 338 | self.login_error = Some(message.clone()); | ||
| 339 | print_line(format!("Registration failed: {message}")) | ||
| 340 | } | ||
| 341 | }, | ||
| 315 | Some(Pending::Ping) => { | 342 | Some(Pending::Ping) => { |
| 316 | if reply.trim() == "PONG" { | 343 | if reply.trim() == "PONG" { |
| 317 | print_line("Pong") | 344 | print_line("Pong") |
| @@ -404,7 +431,23 @@ impl ClientCore { | |||
| 404 | let game = self.current_game_type().map(str::to_string); | 431 | let game = self.current_game_type().map(str::to_string); |
| 405 | 432 | ||
| 406 | match command { | 433 | match command { |
| 407 | Command::Play { game, bet } => self.play_game(&game, bet), | 434 | Command::Login { user, credential } => { |
| 435 | if !self.connected { | ||
| 436 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 437 | } | ||
| 438 | self.login_error = None; | ||
| 439 | self.pending = Some(Pending::Login { user: user.clone() }); | ||
| 440 | vec![Effect::SendLine(format!("AUTH {user} {credential}"))] | ||
| 441 | } | ||
| 442 | Command::Register { user, credential } => { | ||
| 443 | if !self.connected { | ||
| 444 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 445 | } | ||
| 446 | self.login_error = None; | ||
| 447 | self.pending = Some(Pending::Register); | ||
| 448 | vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] | ||
| 449 | } | ||
| 450 | Command::Play { game, bet } => self.play_game(&game, bet), | ||
| 408 | Command::History { limit } => self.load_history(limit), | 451 | Command::History { limit } => self.load_history(limit), |
| 409 | Command::ShowLobby => { | 452 | Command::ShowLobby => { |
| 410 | self.screen = Screen::Lobby; | 453 | self.screen = Screen::Lobby; |
| @@ -412,6 +455,7 @@ impl ClientCore { | |||
| 412 | } | 455 | } |
| 413 | Command::Logout => { | 456 | Command::Logout => { |
| 414 | self.logged_in = None; | 457 | self.logged_in = None; |
| 458 | self.login_error = None; | ||
| 415 | self.screen = Screen::Lobby; | 459 | self.screen = Screen::Lobby; |
| 416 | print_line("Logged out") | 460 | print_line("Logged out") |
| 417 | } | 461 | } |
| @@ -1070,6 +1114,7 @@ impl ClientCore { | |||
| 1070 | .into_iter() | 1114 | .into_iter() |
| 1071 | .map(|(game, name)| (game.to_string(), name.to_string())) | 1115 | .map(|(game, name)| (game.to_string(), name.to_string())) |
| 1072 | .collect(), | 1116 | .collect(), |
| 1117 | login_error: self.login_error.clone(), | ||
| 1073 | }), | 1118 | }), |
| 1074 | }) | 1119 | }) |
| 1075 | } | 1120 | } |
| @@ -1086,6 +1131,28 @@ impl ClientCore { | |||
| 1086 | } | 1131 | } |
| 1087 | } | 1132 | } |
| 1088 | 1133 | ||
| 1134 | fn auth_result(reply: &str) -> Result<(), String> { | ||
| 1135 | let reply = reply.trim(); | ||
| 1136 | if reply.starts_with("OK") { | ||
| 1137 | return Ok(()); | ||
| 1138 | } | ||
| 1139 | Err(reply | ||
| 1140 | .strip_prefix("ERROR") | ||
| 1141 | .map(|message| message.trim().to_string()) | ||
| 1142 | .unwrap_or_else(|| reply.to_string())) | ||
| 1143 | } | ||
| 1144 | |||
| 1145 | fn register_result(reply: &str) -> Result<(), String> { | ||
| 1146 | let reply = reply.trim(); | ||
| 1147 | if reply.starts_with("REGISTERED") { | ||
| 1148 | return Ok(()); | ||
| 1149 | } | ||
| 1150 | Err(reply | ||
| 1151 | .strip_prefix("ERROR") | ||
| 1152 | .map(|message| message.trim().to_string()) | ||
| 1153 | .unwrap_or_else(|| reply.to_string())) | ||
| 1154 | } | ||
| 1155 | |||
| 1089 | fn print_line(message: impl Into<String>) -> Vec<Effect> { | 1156 | fn print_line(message: impl Into<String>) -> Vec<Effect> { |
| 1090 | vec![Effect::Print(vec![message.into()])] | 1157 | vec![Effect::Print(vec![message.into()])] |
| 1091 | } | 1158 | } |
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 { | |||
| 248 | pub user: Option<String>, | 248 | pub user: Option<String>, |
| 249 | pub balance: Option<i64>, | 249 | pub balance: Option<i64>, |
| 250 | pub games: Vec<(String, String)>, | 250 | pub games: Vec<(String, String)>, |
| 251 | /// Why the last login or registration attempt failed. | ||
| 252 | pub login_error: Option<String>, | ||
| 251 | } | 253 | } |
| 252 | 254 | ||
| 253 | /// The play log, already parsed. | 255 | /// The play log, already parsed. |
| @@ -270,6 +272,9 @@ pub enum View { | |||
| 270 | /// Semantic input. Frontends turn keys and clicks into these. | 272 | /// Semantic input. Frontends turn keys and clicks into these. |
| 271 | #[derive(Debug, Clone, PartialEq, Eq)] | 273 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 272 | pub enum Command { | 274 | pub enum Command { |
| 275 | /// The credential is already hashed by the frontend. | ||
| 276 | Login { user: String, credential: String }, | ||
| 277 | Register { user: String, credential: String }, | ||
| 273 | Play { game: String, bet: Option<u64> }, | 278 | Play { game: String, bet: Option<u64> }, |
| 274 | History { limit: u32 }, | 279 | History { limit: u32 }, |
| 275 | ShowLobby, | 280 | 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() { | |||
| 1487 | }; | 1487 | }; |
| 1488 | assert_eq!(lobby.user, None); | 1488 | assert_eq!(lobby.user, None); |
| 1489 | } | 1489 | } |
| 1490 | |||
| 1491 | #[test] | ||
| 1492 | fn a_login_command_sends_auth_and_sets_the_user() { | ||
| 1493 | let mut h = Harness::new(); | ||
| 1494 | h.core.set_logged_in(None); | ||
| 1495 | |||
| 1496 | let effects = h.core.command(Command::Login { | ||
| 1497 | user: "alice".to_string(), | ||
| 1498 | credential: "hash".to_string(), | ||
| 1499 | }); | ||
| 1500 | h.apply(effects); | ||
| 1501 | assert_eq!(h.sent.last().map(String::as_str), Some("AUTH alice hash")); | ||
| 1502 | |||
| 1503 | h.reply("OK alice"); | ||
| 1504 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 1505 | panic!("expected the lobby"); | ||
| 1506 | }; | ||
| 1507 | assert_eq!(lobby.user.as_deref(), Some("alice")); | ||
| 1508 | assert!(lobby.login_error.is_none()); | ||
| 1509 | assert!(h | ||
| 1510 | .screen | ||
| 1511 | .lines | ||
| 1512 | .iter() | ||
| 1513 | .any(|line| line == "Logged in as alice")); | ||
| 1514 | } | ||
| 1515 | |||
| 1516 | #[test] | ||
| 1517 | fn a_rejected_login_is_shown_on_the_lobby() { | ||
| 1518 | let mut h = Harness::new(); | ||
| 1519 | h.core.set_logged_in(None); | ||
| 1520 | h.core.command(Command::Login { | ||
| 1521 | user: "alice".to_string(), | ||
| 1522 | credential: "bad".to_string(), | ||
| 1523 | }); | ||
| 1524 | h.reply("ERROR Invalid credentials"); | ||
| 1525 | |||
| 1526 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 1527 | panic!("expected the lobby"); | ||
| 1528 | }; | ||
| 1529 | assert_eq!(lobby.user, None); | ||
| 1530 | assert_eq!(lobby.login_error.as_deref(), Some("Invalid credentials")); | ||
| 1531 | } | ||
| 1532 | |||
| 1533 | #[test] | ||
| 1534 | fn a_register_command_sends_register_and_reports_the_result() { | ||
| 1535 | let mut h = Harness::new(); | ||
| 1536 | h.core.set_logged_in(None); | ||
| 1537 | |||
| 1538 | let effects = h.core.command(Command::Register { | ||
| 1539 | user: "bob".to_string(), | ||
| 1540 | credential: "hash".to_string(), | ||
| 1541 | }); | ||
| 1542 | h.apply(effects); | ||
| 1543 | assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); | ||
| 1544 | |||
| 1545 | h.reply("REGISTERED bob"); | ||
| 1546 | assert!(h | ||
| 1547 | .screen | ||
| 1548 | .lines | ||
| 1549 | .iter() | ||
| 1550 | .any(|line| line == "Account created. Log in to play.")); | ||
| 1551 | } | ||
| 1552 | |||
| 1553 | #[test] | ||
| 1554 | fn login_requires_a_connection() { | ||
| 1555 | let mut h = Harness::new(); | ||
| 1556 | h.core.set_connected(false); | ||
| 1557 | |||
| 1558 | let effects = h.core.command(Command::Login { | ||
| 1559 | user: "alice".to_string(), | ||
| 1560 | credential: "hash".to_string(), | ||
| 1561 | }); | ||
| 1562 | h.apply(effects); | ||
| 1563 | |||
| 1564 | assert!(!h.sent.iter().any(|line| line.starts_with("AUTH"))); | ||
| 1565 | } | ||
