summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 18:51:05 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 18:51:05 +0100
commit8c93f69081f0012e9a97ffc59adb7129bc1593ee (patch)
tree4dfe458ca2e76e4d9d2ad8f52c2684d5c83178c9
parentv4.0.4: move the client transport and TOFU into the library (diff)
downloadzagan-bet-8c93f69081f0012e9a97ffc59adb7129bc1593ee.tar.gz
zagan-bet-8c93f69081f0012e9a97ffc59adb7129bc1593ee.zip
v4.0.5: login and registration as core commands
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md8
-rw-r--r--src/client/core.rs71
-rw-r--r--src/client/view.rs5
-rw-r--r--tests/client_core.rs76
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 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.4" 3version = "4.0.5"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "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.
30core builds a typed view, frontends send `Command`, and the TUI renders it. 30core builds a typed view, frontends send `Command`, and the TUI renders it.
31Done so far: every game plus `Lobby` and `History` are structured views; the 31Done so far: every game plus `Lobby` and `History` are structured views; the
32client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in 32client 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
34screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) and removing `Key` from 34`Command`s, so a GUI form never needs a terminal prompt. Remaining: the
35the core. 35`Connect` and `Trust` screens (adapter-owned; the transport callback is ready)
36and removing `Key` from the core. The TUI's interactive login still uses its
37own 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)]
70enum Pending { 70enum 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
110impl ClientCore { 114impl 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
1134fn 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
1145fn 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
1089fn print_line(message: impl Into<String>) -> Vec<Effect> { 1156fn 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)]
272pub enum Command { 274pub 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]
1492fn 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]
1517fn 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]
1534fn 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]
1554fn 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}