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/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/client')
| -rw-r--r-- | src/client/core.rs | 494 | ||||
| -rw-r--r-- | src/client/games/manager.rs | 140 | ||||
| -rw-r--r-- | src/client/games/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/games/response.rs | 60 | ||||
| -rw-r--r-- | src/client/games/slots.rs | 58 | ||||
| -rw-r--r-- | src/client/mod.rs | 2 | ||||
| -rw-r--r-- | src/client/ui.rs | 143 |
7 files changed, 831 insertions, 67 deletions
diff --git a/src/client/core.rs b/src/client/core.rs new file mode 100644 index 0000000..0e54fba --- /dev/null +++ b/src/client/core.rs | |||
| @@ -0,0 +1,494 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | ||
| 2 | use crate::client::games::response::parse_spin_response; | ||
| 3 | use crate::client::ui::{ | ||
| 4 | command_names, help_lines, input_cursor_column, parse_bet_arg, record_history, | ||
| 5 | }; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 8 | pub enum Key { | ||
| 9 | Char(char), | ||
| 10 | Enter, | ||
| 11 | Backspace, | ||
| 12 | Up, | ||
| 13 | Down, | ||
| 14 | Tab, | ||
| 15 | Esc, | ||
| 16 | CtrlC, | ||
| 17 | } | ||
| 18 | |||
| 19 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 20 | pub enum InteractiveCommand { | ||
| 21 | Login, | ||
| 22 | Register, | ||
| 23 | SetCred, | ||
| 24 | Connect(Option<String>), | ||
| 25 | } | ||
| 26 | |||
| 27 | /// High-level actions produced by the client core. The terminal adapter is | ||
| 28 | /// responsible for turning these into crossterm calls and network writes. | ||
| 29 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 30 | pub enum Effect { | ||
| 31 | /// Append lines to the scrolling transcript. | ||
| 32 | Print(Vec<String>), | ||
| 33 | /// Replace the game board region in place. | ||
| 34 | ReplaceRegion(Vec<String>), | ||
| 35 | /// Remove the game board region. | ||
| 36 | ClearRegion, | ||
| 37 | /// Redraw the command prompt. `text` is the input without the `> ` prefix. | ||
| 38 | SetPrompt { text: String, cursor_col: u16 }, | ||
| 39 | /// Send one protocol line and feed the reply to `handle_reply`. | ||
| 40 | SendLine(String), | ||
| 41 | /// Delegate an interactive prompt flow to the terminal adapter. | ||
| 42 | Interactive(InteractiveCommand), | ||
| 43 | /// Drop the connection. | ||
| 44 | Disconnect, | ||
| 45 | /// Let the adapter read the host database. | ||
| 46 | ListHosts, | ||
| 47 | /// Let the adapter delete a host from the database. | ||
| 48 | ForgetHost(String), | ||
| 49 | /// Leave the program. | ||
| 50 | Quit, | ||
| 51 | } | ||
| 52 | |||
| 53 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 54 | enum Pending { | ||
| 55 | Spin, | ||
| 56 | Ping, | ||
| 57 | Echo, | ||
| 58 | Info, | ||
| 59 | } | ||
| 60 | |||
| 61 | pub struct ClientCore { | ||
| 62 | input: String, | ||
| 63 | history: Vec<String>, | ||
| 64 | history_index: usize, | ||
| 65 | game: GameManager, | ||
| 66 | connected: bool, | ||
| 67 | logged_in: Option<String>, | ||
| 68 | pending: Option<Pending>, | ||
| 69 | balance: Option<i64>, | ||
| 70 | host: Option<String>, | ||
| 71 | fingerprint: Option<String>, | ||
| 72 | } | ||
| 73 | |||
| 74 | impl ClientCore { | ||
| 75 | pub fn new() -> Self { | ||
| 76 | Self { | ||
| 77 | input: String::new(), | ||
| 78 | history: Vec::new(), | ||
| 79 | history_index: 0, | ||
| 80 | game: GameManager::new(), | ||
| 81 | connected: false, | ||
| 82 | logged_in: None, | ||
| 83 | pending: None, | ||
| 84 | balance: None, | ||
| 85 | host: None, | ||
| 86 | fingerprint: None, | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn set_connected(&mut self, connected: bool) { | ||
| 91 | self.connected = connected; | ||
| 92 | if !connected { | ||
| 93 | self.logged_in = None; | ||
| 94 | self.pending = None; | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | pub fn set_logged_in(&mut self, user: Option<String>) { | ||
| 99 | self.logged_in = user; | ||
| 100 | } | ||
| 101 | |||
| 102 | pub fn set_balance(&mut self, balance: i64) { | ||
| 103 | self.balance = Some(balance); | ||
| 104 | self.game.set_balance(balance); | ||
| 105 | } | ||
| 106 | |||
| 107 | pub fn set_host_info(&mut self, host: Option<String>, fingerprint: Option<String>) { | ||
| 108 | self.host = host; | ||
| 109 | self.fingerprint = fingerprint; | ||
| 110 | } | ||
| 111 | |||
| 112 | pub fn is_in_game(&self) -> bool { | ||
| 113 | self.game.is_in_game() | ||
| 114 | } | ||
| 115 | |||
| 116 | pub fn prompt_effect(&self) -> Option<Effect> { | ||
| 117 | if self.is_in_game() { | ||
| 118 | return None; | ||
| 119 | } | ||
| 120 | Some(Effect::SetPrompt { | ||
| 121 | text: self.input.clone(), | ||
| 122 | cursor_col: input_cursor_column(2, self.input.chars().count()), | ||
| 123 | }) | ||
| 124 | } | ||
| 125 | |||
| 126 | pub fn handle_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 127 | if self.game.is_in_game() { | ||
| 128 | return self.handle_game_key(key); | ||
| 129 | } | ||
| 130 | |||
| 131 | match key { | ||
| 132 | Key::CtrlC => vec![Effect::Quit], | ||
| 133 | Key::Enter => self.submit(), | ||
| 134 | Key::Backspace => { | ||
| 135 | self.input.pop(); | ||
| 136 | self.prompt_effects() | ||
| 137 | } | ||
| 138 | Key::Char(c) => { | ||
| 139 | self.input.push(c); | ||
| 140 | self.prompt_effects() | ||
| 141 | } | ||
| 142 | Key::Up => self.history_previous(), | ||
| 143 | Key::Down => self.history_next(), | ||
| 144 | Key::Tab => self.complete(), | ||
| 145 | Key::Esc => Vec::new(), | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { | ||
| 150 | match self.pending.take() { | ||
| 151 | Some(Pending::Spin) => match parse_spin_response(reply) { | ||
| 152 | Ok(result) => { | ||
| 153 | self.balance = Some(result.balance); | ||
| 154 | self.game.apply_result(result); | ||
| 155 | self.board_effects() | ||
| 156 | } | ||
| 157 | Err(message) => { | ||
| 158 | self.game.set_error(message); | ||
| 159 | self.board_effects() | ||
| 160 | } | ||
| 161 | }, | ||
| 162 | Some(Pending::Ping) => { | ||
| 163 | if reply.trim() == "PONG" { | ||
| 164 | print_line("Pong") | ||
| 165 | } else { | ||
| 166 | self.connected = false; | ||
| 167 | self.logged_in = None; | ||
| 168 | vec![ | ||
| 169 | Effect::Disconnect, | ||
| 170 | Effect::Print(vec![ | ||
| 171 | "Connection lost".to_string(), | ||
| 172 | ]), | ||
| 173 | ] | ||
| 174 | } | ||
| 175 | } | ||
| 176 | Some(Pending::Echo) => print_line(format!("Echo: {}", reply.trim())), | ||
| 177 | Some(Pending::Info) => { | ||
| 178 | if self.game.is_in_game() { | ||
| 179 | // Balance fetch while entering a game. A failure is not | ||
| 180 | // fatal: the board keeps showing `?`. | ||
| 181 | if let Some(balance) = info_balance(reply) { | ||
| 182 | self.balance = Some(balance); | ||
| 183 | self.game.set_balance(balance); | ||
| 184 | return self.board_effects(); | ||
| 185 | } | ||
| 186 | Vec::new() | ||
| 187 | } else { | ||
| 188 | vec![Effect::Print(info_lines(reply))] | ||
| 189 | } | ||
| 190 | } | ||
| 191 | None => print_line(reply.trim()), | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | fn handle_game_key(&mut self, key: Key) -> Vec<Effect> { | ||
| 196 | match key { | ||
| 197 | Key::CtrlC => vec![Effect::Quit], | ||
| 198 | Key::Esc | Key::Char('q') => { | ||
| 199 | self.game.end_game(); | ||
| 200 | vec![ | ||
| 201 | Effect::ClearRegion, | ||
| 202 | Effect::Print(vec!["Game exited.".to_string()]), | ||
| 203 | ] | ||
| 204 | } | ||
| 205 | Key::Char(' ') => self.spin(), | ||
| 206 | Key::Char('+') => { | ||
| 207 | let _ = self.game.adjust_bet(10); | ||
| 208 | self.board_effects() | ||
| 209 | } | ||
| 210 | Key::Char('-') => { | ||
| 211 | let _ = self.game.adjust_bet(-10); | ||
| 212 | self.board_effects() | ||
| 213 | } | ||
| 214 | _ => Vec::new(), | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | fn spin(&mut self) -> Vec<Effect> { | ||
| 219 | let Some(request) = self.game.spin_request() else { | ||
| 220 | return Vec::new(); | ||
| 221 | }; | ||
| 222 | if self.game.begin_spin().is_err() { | ||
| 223 | return Vec::new(); | ||
| 224 | } | ||
| 225 | self.pending = Some(Pending::Spin); | ||
| 226 | |||
| 227 | let mut effects = self.board_effects(); | ||
| 228 | effects.push(Effect::SendLine(request)); | ||
| 229 | effects | ||
| 230 | } | ||
| 231 | |||
| 232 | fn submit(&mut self) -> Vec<Effect> { | ||
| 233 | let line = self.input.trim().to_string(); | ||
| 234 | self.input.clear(); | ||
| 235 | if line.is_empty() { | ||
| 236 | return Vec::new(); | ||
| 237 | } | ||
| 238 | |||
| 239 | record_history(&mut self.history, &line); | ||
| 240 | self.history_index = self.history.len(); | ||
| 241 | |||
| 242 | let parts: Vec<&str> = line.splitn(3, ' ').collect(); | ||
| 243 | match parts[0] { | ||
| 244 | "play" => self.cmd_play(&parts), | ||
| 245 | "games" => self.available_games(None), | ||
| 246 | "help" => vec![Effect::Print(help_lines())], | ||
| 247 | "connect" => { | ||
| 248 | if self.connected { | ||
| 249 | print_line("Already connected. Disconnect first.") | ||
| 250 | } else { | ||
| 251 | let target = parts.get(1).map(|value| value.to_string()); | ||
| 252 | vec![Effect::Interactive(InteractiveCommand::Connect(target))] | ||
| 253 | } | ||
| 254 | } | ||
| 255 | "login" => { | ||
| 256 | if !self.connected { | ||
| 257 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 258 | } else if self.logged_in.is_some() { | ||
| 259 | print_line(format!( | ||
| 260 | "Already logged in as {}", | ||
| 261 | self.logged_in.as_deref().unwrap_or("?") | ||
| 262 | )) | ||
| 263 | } else { | ||
| 264 | vec![Effect::Interactive(InteractiveCommand::Login)] | ||
| 265 | } | ||
| 266 | } | ||
| 267 | "register" => { | ||
| 268 | if !self.connected { | ||
| 269 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 270 | } else { | ||
| 271 | vec![Effect::Interactive(InteractiveCommand::Register)] | ||
| 272 | } | ||
| 273 | } | ||
| 274 | "setcred" => { | ||
| 275 | if self.host.is_none() { | ||
| 276 | print_line("No host connected. Use 'connect <ip>:<port>' first.") | ||
| 277 | } else { | ||
| 278 | vec![Effect::Interactive(InteractiveCommand::SetCred)] | ||
| 279 | } | ||
| 280 | } | ||
| 281 | "logout" => { | ||
| 282 | self.logged_in = None; | ||
| 283 | print_line("Logged out") | ||
| 284 | } | ||
| 285 | "disconnect" => { | ||
| 286 | self.connected = false; | ||
| 287 | self.logged_in = None; | ||
| 288 | self.pending = None; | ||
| 289 | self.game.end_game(); | ||
| 290 | vec![ | ||
| 291 | Effect::ClearRegion, | ||
| 292 | Effect::Disconnect, | ||
| 293 | Effect::Print(vec!["Disconnected".to_string()]), | ||
| 294 | ] | ||
| 295 | } | ||
| 296 | "ping" => { | ||
| 297 | if !self.connected { | ||
| 298 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 299 | } else { | ||
| 300 | self.pending = Some(Pending::Ping); | ||
| 301 | vec![Effect::SendLine("PING".to_string())] | ||
| 302 | } | ||
| 303 | } | ||
| 304 | "send" => { | ||
| 305 | if !self.connected { | ||
| 306 | print_line("Not connected. Use 'connect <ip>:<port>' first.") | ||
| 307 | } else if parts.len() < 2 { | ||
| 308 | print_line("Usage: send <message>") | ||
| 309 | } else { | ||
| 310 | let message = parts[1..].join(" "); | ||
| 311 | self.pending = Some(Pending::Echo); | ||
| 312 | vec![Effect::SendLine(message)] | ||
| 313 | } | ||
| 314 | } | ||
| 315 | "info" => self.cmd_info(), | ||
| 316 | "hosts" => vec![Effect::ListHosts], | ||
| 317 | "forget-host" => { | ||
| 318 | let target = parts | ||
| 319 | .get(1) | ||
| 320 | .map(|value| value.to_string()) | ||
| 321 | .or_else(|| self.host.clone()); | ||
| 322 | match target { | ||
| 323 | Some(target) => vec![Effect::ForgetHost(target)], | ||
| 324 | None => print_line("Usage: forget-host <ip>:<port> (or connect first)"), | ||
| 325 | } | ||
| 326 | } | ||
| 327 | "quit" => vec![Effect::Quit], | ||
| 328 | _ => vec![Effect::Print(help_lines())], | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { | ||
| 333 | if !self.connected { | ||
| 334 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 335 | } | ||
| 336 | if self.logged_in.is_none() { | ||
| 337 | return print_line("Not logged in. Use 'login' first."); | ||
| 338 | } | ||
| 339 | if parts.len() < 2 { | ||
| 340 | return self.available_games(Some("Usage: play <game> [bet]")); | ||
| 341 | } | ||
| 342 | |||
| 343 | let bet = match parts.get(2) { | ||
| 344 | Some(raw) => match parse_bet_arg(raw) { | ||
| 345 | Ok(bet) => Some(bet), | ||
| 346 | Err(message) => return print_line(message), | ||
| 347 | }, | ||
| 348 | None => None, | ||
| 349 | }; | ||
| 350 | |||
| 351 | if self.game.is_in_game() { | ||
| 352 | return print_line("Already in a game. Use 'quit' to exit current game."); | ||
| 353 | } | ||
| 354 | |||
| 355 | match self.game.enter_game(parts[1]) { | ||
| 356 | Ok(()) => { | ||
| 357 | if let Some(bet) = bet { | ||
| 358 | let _ = self.game.set_bet(bet); | ||
| 359 | } | ||
| 360 | if let Some(balance) = self.balance { | ||
| 361 | self.game.set_balance(balance); | ||
| 362 | } | ||
| 363 | |||
| 364 | let mut effects = self.board_effects(); | ||
| 365 | self.pending = Some(Pending::Info); | ||
| 366 | effects.push(Effect::SendLine("INFO".to_string())); | ||
| 367 | effects | ||
| 368 | } | ||
| 369 | Err(_) => print_line("Failed to enter game"), | ||
| 370 | } | ||
| 371 | } | ||
| 372 | |||
| 373 | fn available_games(&self, usage: Option<&str>) -> Vec<Effect> { | ||
| 374 | let mut lines = vec!["Available games:".to_string()]; | ||
| 375 | for (game_type, name) in self.game.get_available_games() { | ||
| 376 | lines.push(format!(" - {} ({})", game_type, name)); | ||
| 377 | } | ||
| 378 | if let Some(usage) = usage { | ||
| 379 | lines.push(String::new()); | ||
| 380 | lines.push(usage.to_string()); | ||
| 381 | } | ||
| 382 | vec![Effect::Print(lines)] | ||
| 383 | } | ||
| 384 | |||
| 385 | fn cmd_info(&mut self) -> Vec<Effect> { | ||
| 386 | if !self.connected { | ||
| 387 | return print_line("Not connected to any host."); | ||
| 388 | } | ||
| 389 | |||
| 390 | let mut effects = Vec::new(); | ||
| 391 | match &self.host { | ||
| 392 | Some(host) => effects.push(Effect::Print(vec![format!("Connected to: {host}")])), | ||
| 393 | None => effects.push(Effect::Print(vec!["Connected to: (unknown)".to_string()])), | ||
| 394 | } | ||
| 395 | match &self.fingerprint { | ||
| 396 | Some(fingerprint) => { | ||
| 397 | effects.push(Effect::Print(vec![format!("Fingerprint: {fingerprint}")])) | ||
| 398 | } | ||
| 399 | None => effects.push(Effect::Print(vec![ | ||
| 400 | "Fingerprint: (not trusted)".to_string(), | ||
| 401 | ])), | ||
| 402 | } | ||
| 403 | |||
| 404 | self.pending = Some(Pending::Info); | ||
| 405 | effects.push(Effect::SendLine("INFO".to_string())); | ||
| 406 | effects | ||
| 407 | } | ||
| 408 | |||
| 409 | fn history_previous(&mut self) -> Vec<Effect> { | ||
| 410 | if self.history_index == 0 { | ||
| 411 | return Vec::new(); | ||
| 412 | } | ||
| 413 | self.history_index -= 1; | ||
| 414 | self.input = self.history[self.history_index].clone(); | ||
| 415 | self.prompt_effects() | ||
| 416 | } | ||
| 417 | |||
| 418 | fn history_next(&mut self) -> Vec<Effect> { | ||
| 419 | if self.history_index >= self.history.len() { | ||
| 420 | return Vec::new(); | ||
| 421 | } | ||
| 422 | self.history_index += 1; | ||
| 423 | self.input = if self.history_index == self.history.len() { | ||
| 424 | String::new() | ||
| 425 | } else { | ||
| 426 | self.history[self.history_index].clone() | ||
| 427 | }; | ||
| 428 | self.prompt_effects() | ||
| 429 | } | ||
| 430 | |||
| 431 | fn complete(&mut self) -> Vec<Effect> { | ||
| 432 | let prefix = self.input.trim(); | ||
| 433 | let matches: Vec<&'static str> = command_names() | ||
| 434 | .into_iter() | ||
| 435 | .filter(|command| command.starts_with(prefix)) | ||
| 436 | .collect(); | ||
| 437 | |||
| 438 | let mut effects = Vec::new(); | ||
| 439 | if matches.len() == 1 { | ||
| 440 | self.input = matches[0].to_string(); | ||
| 441 | } else if matches.len() > 1 { | ||
| 442 | let mut lines = vec!["Possible completions:".to_string()]; | ||
| 443 | lines.push(matches.join(" ")); | ||
| 444 | effects.push(Effect::Print(lines)); | ||
| 445 | } | ||
| 446 | if let Some(prompt) = self.prompt_effect() { | ||
| 447 | effects.push(prompt); | ||
| 448 | } | ||
| 449 | effects | ||
| 450 | } | ||
| 451 | |||
| 452 | fn board_lines(&self) -> Option<Vec<String>> { | ||
| 453 | let game = self.game.get_current_game()?; | ||
| 454 | let state = self.game.get_current_state()?; | ||
| 455 | Some(game.render(state).lines().map(str::to_string).collect()) | ||
| 456 | } | ||
| 457 | |||
| 458 | fn board_effects(&self) -> Vec<Effect> { | ||
| 459 | self.board_lines() | ||
| 460 | .map(Effect::ReplaceRegion) | ||
| 461 | .into_iter() | ||
| 462 | .collect() | ||
| 463 | } | ||
| 464 | |||
| 465 | fn prompt_effects(&self) -> Vec<Effect> { | ||
| 466 | self.prompt_effect().into_iter().collect() | ||
| 467 | } | ||
| 468 | } | ||
| 469 | |||
| 470 | fn print_line(message: impl Into<String>) -> Vec<Effect> { | ||
| 471 | vec![Effect::Print(vec![message.into()])] | ||
| 472 | } | ||
| 473 | |||
| 474 | fn info_balance(reply: &str) -> Option<i64> { | ||
| 475 | let parts: Vec<&str> = reply.split_whitespace().collect(); | ||
| 476 | if parts.first() == Some(&"INFO") && parts.len() == 4 { | ||
| 477 | parts[3].parse().ok() | ||
| 478 | } else { | ||
| 479 | None | ||
| 480 | } | ||
| 481 | } | ||
| 482 | |||
| 483 | fn info_lines(reply: &str) -> Vec<String> { | ||
| 484 | let parts: Vec<&str> = reply.split_whitespace().collect(); | ||
| 485 | if parts.first() == Some(&"INFO") && parts.len() == 4 { | ||
| 486 | vec![ | ||
| 487 | format!("User: {}", parts[1]), | ||
| 488 | format!("Account: {}", parts[2]), | ||
| 489 | format!("Balance: {} PLN", parts[3]), | ||
| 490 | ] | ||
| 491 | } else { | ||
| 492 | vec![reply.trim().to_string()] | ||
| 493 | } | ||
| 494 | } | ||
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs index be96b1a..71cd28c 100644 --- a/src/client/games/manager.rs +++ b/src/client/games/manager.rs | |||
| @@ -1,11 +1,10 @@ | |||
| 1 | use super::response::SpinResult; | ||
| 1 | use super::slots::SlotsGame; | 2 | use super::slots::SlotsGame; |
| 2 | use super::traits::{Game, GameError, GamePhase, GameState}; | 3 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 3 | use std::collections::HashMap; | 4 | use std::collections::HashMap; |
| 4 | use std::io::Write; | ||
| 5 | 5 | ||
| 6 | pub struct GameManager { | 6 | pub struct GameManager { |
| 7 | current_session: Option<GameState>, | 7 | current_session: Option<GameState>, |
| 8 | pending_game_type: Option<String>, | ||
| 9 | games: HashMap<String, Box<dyn Game>>, | 8 | games: HashMap<String, Box<dyn Game>>, |
| 10 | } | 9 | } |
| 11 | 10 | ||
| @@ -16,16 +15,18 @@ impl GameManager { | |||
| 16 | 15 | ||
| 17 | Self { | 16 | Self { |
| 18 | current_session: None, | 17 | current_session: None, |
| 19 | pending_game_type: None, | ||
| 20 | games, | 18 | games, |
| 21 | } | 19 | } |
| 22 | } | 20 | } |
| 23 | 21 | ||
| 24 | pub fn get_available_games(&self) -> Vec<(&str, &str)> { | 22 | pub fn get_available_games(&self) -> Vec<(&str, &str)> { |
| 25 | self.games | 23 | let mut games: Vec<(&str, &str)> = self |
| 24 | .games | ||
| 26 | .iter() | 25 | .iter() |
| 27 | .map(|(k, v)| (k.as_str(), v.get_name())) | 26 | .map(|(key, game)| (key.as_str(), game.get_name())) |
| 28 | .collect() | 27 | .collect(); |
| 28 | games.sort_unstable_by_key(|(key, _)| *key); | ||
| 29 | games | ||
| 29 | } | 30 | } |
| 30 | 31 | ||
| 31 | pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { | 32 | pub fn enter_game(&mut self, game_type: &str) -> Result<(), GameError> { |
| @@ -37,8 +38,6 @@ impl GameManager { | |||
| 37 | return Err(GameError::InvalidAction); | 38 | return Err(GameError::InvalidAction); |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 40 | self.pending_game_type = Some(game_type.to_string()); | ||
| 41 | |||
| 42 | self.current_session = Some(GameState { | 41 | self.current_session = Some(GameState { |
| 43 | game_type: game_type.to_string(), | 42 | game_type: game_type.to_string(), |
| 44 | bet_amount: 10, | 43 | bet_amount: 10, |
| @@ -49,28 +48,75 @@ impl GameManager { | |||
| 49 | Ok(()) | 48 | Ok(()) |
| 50 | } | 49 | } |
| 51 | 50 | ||
| 52 | pub fn start_game<W: Write>(&mut self, stream: &mut W) -> Result<(), GameError> { | 51 | /// The wire request for a spin, if the current phase allows one. |
| 53 | let game_type = self | 52 | pub fn spin_request(&self) -> Option<String> { |
| 54 | .pending_game_type | 53 | let session = self.current_session.as_ref()?; |
| 55 | .as_ref() | 54 | if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { |
| 56 | .ok_or(GameError::InvalidAction)?; | 55 | return None; |
| 56 | } | ||
| 57 | Some(format!( | ||
| 58 | "PLAY_START {} {}", | ||
| 59 | session.game_type, session.bet_amount | ||
| 60 | )) | ||
| 61 | } | ||
| 57 | 62 | ||
| 58 | let state = self | 63 | /// Move the board into its spinning state and drop the previous result |
| 64 | /// so stale reels/winnings cannot leak into the new spin. | ||
| 65 | pub fn begin_spin(&mut self) -> Result<(), GameError> { | ||
| 66 | let session = self | ||
| 59 | .current_session | 67 | .current_session |
| 60 | .as_mut() | 68 | .as_mut() |
| 61 | .ok_or(GameError::InvalidAction)?; | 69 | .ok_or(GameError::InvalidAction)?; |
| 62 | 70 | if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { | |
| 63 | if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Finished { | ||
| 64 | return Err(GameError::InvalidAction); | 71 | return Err(GameError::InvalidAction); |
| 65 | } | 72 | } |
| 66 | 73 | ||
| 67 | let bet_amount = state.bet_amount; | 74 | session.phase = GamePhase::Playing; |
| 75 | session.data.remove("error"); | ||
| 76 | session.data.remove("reels"); | ||
| 77 | session.data.remove("win_type"); | ||
| 78 | session.data.remove("winnings"); | ||
| 79 | Ok(()) | ||
| 80 | } | ||
| 68 | 81 | ||
| 69 | let writer = stream; | 82 | /// Store the settled spin so `render()` can draw it in place. |
| 70 | writeln!(writer, "PLAY_START {} {}", game_type, bet_amount) | 83 | pub fn apply_result(&mut self, result: SpinResult) { |
| 71 | .map_err(|_| GameError::NetworkError)?; | 84 | let Some(session) = self.current_session.as_mut() else { |
| 85 | return; | ||
| 86 | }; | ||
| 87 | session.phase = GamePhase::Finished; | ||
| 88 | session.bet_amount = result.bet_amount; | ||
| 89 | session.data.remove("error"); | ||
| 90 | session.data.insert("reels".to_string(), result.reels); | ||
| 91 | session | ||
| 92 | .data | ||
| 93 | .insert("win_type".to_string(), result.win_type); | ||
| 94 | session | ||
| 95 | .data | ||
| 96 | .insert("winnings".to_string(), result.winnings.to_string()); | ||
| 97 | session | ||
| 98 | .data | ||
| 99 | .insert("balance".to_string(), result.balance.to_string()); | ||
| 100 | } | ||
| 72 | 101 | ||
| 73 | Ok(()) | 102 | /// Show a failed spin without leaving the player stuck in `Playing`. |
| 103 | pub fn set_error(&mut self, message: impl Into<String>) { | ||
| 104 | let Some(session) = self.current_session.as_mut() else { | ||
| 105 | return; | ||
| 106 | }; | ||
| 107 | session.phase = GamePhase::WaitingToStart; | ||
| 108 | session.data.remove("reels"); | ||
| 109 | session.data.remove("win_type"); | ||
| 110 | session.data.remove("winnings"); | ||
| 111 | session.data.insert("error".to_string(), message.into()); | ||
| 112 | } | ||
| 113 | |||
| 114 | pub fn set_balance(&mut self, balance: i64) { | ||
| 115 | if let Some(session) = self.current_session.as_mut() { | ||
| 116 | session | ||
| 117 | .data | ||
| 118 | .insert("balance".to_string(), balance.to_string()); | ||
| 119 | } | ||
| 74 | } | 120 | } |
| 75 | 121 | ||
| 76 | pub fn get_current_game(&self) -> Option<&dyn Game> { | 122 | pub fn get_current_game(&self) -> Option<&dyn Game> { |
| @@ -83,49 +129,37 @@ impl GameManager { | |||
| 83 | self.current_session.as_ref() | 129 | self.current_session.as_ref() |
| 84 | } | 130 | } |
| 85 | 131 | ||
| 86 | pub fn get_current_render_lines(&self) -> Option<usize> { | ||
| 87 | let session = self.current_session.as_ref()?; | ||
| 88 | let game = self.games.get(&session.game_type)?; | ||
| 89 | Some(game.render_lines(session)) | ||
| 90 | } | ||
| 91 | |||
| 92 | pub fn is_in_game(&self) -> bool { | 132 | pub fn is_in_game(&self) -> bool { |
| 93 | self.current_session.is_some() | 133 | self.current_session.is_some() |
| 94 | } | 134 | } |
| 95 | 135 | ||
| 96 | pub fn is_game_started(&self) -> bool { | 136 | pub fn end_game(&mut self) { |
| 97 | self.current_session | 137 | self.current_session = None; |
| 98 | .as_ref() | ||
| 99 | .map(|s| s.phase != GamePhase::WaitingToStart) | ||
| 100 | .unwrap_or(false) | ||
| 101 | } | 138 | } |
| 102 | 139 | ||
| 103 | pub fn reset_phase_to_waiting(&mut self) { | 140 | pub fn set_bet(&mut self, bet: u64) -> Result<u64, GameError> { |
| 104 | if let Some(ref mut session) = self.current_session { | 141 | let session = self |
| 105 | session.phase = GamePhase::WaitingToStart; | 142 | .current_session |
| 106 | session.data.clear(); | 143 | .as_mut() |
| 144 | .ok_or(GameError::InvalidAction)?; | ||
| 145 | if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { | ||
| 146 | return Err(GameError::InvalidAction); | ||
| 107 | } | 147 | } |
| 108 | } | 148 | session.bet_amount = bet.max(10); |
| 109 | 149 | Ok(session.bet_amount) | |
| 110 | pub fn end_game(&mut self) { | ||
| 111 | self.current_session = None; | ||
| 112 | self.pending_game_type = None; | ||
| 113 | } | 150 | } |
| 114 | 151 | ||
| 115 | pub fn adjust_bet(&mut self, delta: i64) -> Result<u64, GameError> { | 152 | pub fn adjust_bet(&mut self, delta: i64) -> Result<u64, GameError> { |
| 116 | if let Some(ref session) = self.current_session { | 153 | let session = self |
| 117 | if session.phase != GamePhase::WaitingToStart { | 154 | .current_session |
| 118 | return Err(GameError::InvalidAction); | 155 | .as_mut() |
| 119 | } | 156 | .ok_or(GameError::InvalidAction)?; |
| 157 | if session.phase != GamePhase::WaitingToStart && session.phase != GamePhase::Finished { | ||
| 158 | return Err(GameError::InvalidAction); | ||
| 120 | } | 159 | } |
| 121 | 160 | ||
| 122 | if let Some(ref mut session) = self.current_session { | 161 | let current_bet = session.bet_amount as i64; |
| 123 | let current_bet = session.bet_amount as i64; | 162 | session.bet_amount = (current_bet + delta).max(10) as u64; |
| 124 | let new_bet = (current_bet + delta).max(10) as u64; | 163 | Ok(session.bet_amount) |
| 125 | session.bet_amount = new_bet; | ||
| 126 | Ok(new_bet) | ||
| 127 | } else { | ||
| 128 | Err(GameError::InvalidAction) | ||
| 129 | } | ||
| 130 | } | 164 | } |
| 131 | } | 165 | } |
diff --git a/src/client/games/mod.rs b/src/client/games/mod.rs index aa74574..63cc579 100644 --- a/src/client/games/mod.rs +++ b/src/client/games/mod.rs | |||
| @@ -1,3 +1,4 @@ | |||
| 1 | pub mod manager; | 1 | pub mod manager; |
| 2 | pub mod response; | ||
| 2 | pub mod slots; | 3 | pub mod slots; |
| 3 | pub mod traits; | 4 | pub mod traits; |
diff --git a/src/client/games/response.rs b/src/client/games/response.rs new file mode 100644 index 0000000..6948805 --- /dev/null +++ b/src/client/games/response.rs | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 2 | pub struct SpinResult { | ||
| 3 | pub session_id: String, | ||
| 4 | pub winnings: u64, | ||
| 5 | pub bet_amount: u64, | ||
| 6 | pub balance: i64, | ||
| 7 | pub reels: String, | ||
| 8 | pub win_type: String, | ||
| 9 | } | ||
| 10 | |||
| 11 | /// Parse one server reply to `PLAY_START`. | ||
| 12 | /// | ||
| 13 | /// `Ok` carries the settled spin; `Err` carries a message that is safe to | ||
| 14 | /// show to the player (server errors are reported without the `ERROR ` prefix). | ||
| 15 | pub fn parse_spin_response(response: &str) -> Result<SpinResult, String> { | ||
| 16 | let response = response.trim(); | ||
| 17 | if response.is_empty() { | ||
| 18 | return Err("No response from server".to_string()); | ||
| 19 | } | ||
| 20 | |||
| 21 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 22 | let message = message.trim(); | ||
| 23 | return Err(if message.is_empty() { | ||
| 24 | "Server error".to_string() | ||
| 25 | } else { | ||
| 26 | message.to_string() | ||
| 27 | }); | ||
| 28 | } | ||
| 29 | |||
| 30 | let parts: Vec<&str> = response.split_whitespace().collect(); | ||
| 31 | if parts.first() != Some(&"GAME_RESULT") { | ||
| 32 | return Err(format!("Unexpected server response: {response}")); | ||
| 33 | } | ||
| 34 | if parts.len() < 7 { | ||
| 35 | return Err("Malformed GAME_RESULT from server".to_string()); | ||
| 36 | } | ||
| 37 | |||
| 38 | let winnings = parts[2] | ||
| 39 | .parse::<u64>() | ||
| 40 | .map_err(|_| format!("Invalid winnings from server: {}", parts[2]))?; | ||
| 41 | let bet_amount = parts[3] | ||
| 42 | .parse::<u64>() | ||
| 43 | .map_err(|_| format!("Invalid bet from server: {}", parts[3]))?; | ||
| 44 | let balance = parts[4] | ||
| 45 | .parse::<i64>() | ||
| 46 | .map_err(|_| format!("Invalid balance from server: {}", parts[4]))?; | ||
| 47 | |||
| 48 | if parts[5].chars().count() != 3 { | ||
| 49 | return Err(format!("Invalid reels from server: {}", parts[5])); | ||
| 50 | } | ||
| 51 | |||
| 52 | Ok(SpinResult { | ||
| 53 | session_id: parts[1].to_string(), | ||
| 54 | winnings, | ||
| 55 | bet_amount, | ||
| 56 | balance, | ||
| 57 | reels: parts[5].to_string(), | ||
| 58 | win_type: parts[6].to_string(), | ||
| 59 | }) | ||
| 60 | } | ||
diff --git a/src/client/games/slots.rs b/src/client/games/slots.rs index 6e2ebbe..5eed34d 100644 --- a/src/client/games/slots.rs +++ b/src/client/games/slots.rs | |||
| @@ -1,4 +1,8 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GamePhase, GameState}; |
| 2 | |||
| 3 | /// Height of the game board. It must never depend on the spin result, or an | ||
| 4 | /// in-place redraw would move to the wrong line. | ||
| 5 | pub const SLOT_BOARD_LINES: usize = 7; | ||
| 2 | 6 | ||
| 3 | pub struct SlotsGame; | 7 | pub struct SlotsGame; |
| 4 | 8 | ||
| @@ -8,17 +12,36 @@ impl SlotsGame { | |||
| 8 | } | 12 | } |
| 9 | 13 | ||
| 10 | fn render_reels(&self, reels: &str) -> String { | 14 | fn render_reels(&self, reels: &str) -> String { |
| 11 | if reels.is_empty() { | 15 | let mut chars = reels.chars(); |
| 12 | "[ ? ] [ ? ] [ ? ]".to_string() | 16 | format!( |
| 13 | } else { | 17 | "[ {} ] [ {} ] [ {} ]", |
| 14 | let chars: Vec<char> = reels.chars().collect(); | 18 | chars.next().unwrap_or('?'), |
| 15 | format!( | 19 | chars.next().unwrap_or('?'), |
| 16 | "[ {} ] [ {} ] [ {} ]\r", | 20 | chars.next().unwrap_or('?') |
| 17 | chars.get(0).unwrap_or(&'?'), | 21 | ) |
| 18 | chars.get(1).unwrap_or(&'?'), | 22 | } |
| 19 | chars.get(2).unwrap_or(&'?') | 23 | |
| 20 | ) | 24 | /// The fifth board line: current status, result, or error. |
| 25 | fn render_status(&self, state: &GameState) -> String { | ||
| 26 | if let Some(error) = state.data.get("error") { | ||
| 27 | return format!("ERROR: {error}"); | ||
| 28 | } | ||
| 29 | if state.phase == GamePhase::Playing { | ||
| 30 | return "Spinning...".to_string(); | ||
| 31 | } | ||
| 32 | if state.phase == GamePhase::Finished { | ||
| 33 | let winnings = state | ||
| 34 | .data | ||
| 35 | .get("winnings") | ||
| 36 | .map(String::as_str) | ||
| 37 | .unwrap_or("0"); | ||
| 38 | return match state.data.get("win_type").map(String::as_str) { | ||
| 39 | Some("jackpot") => format!("JACKPOT! Winnings: {winnings} PLN"), | ||
| 40 | Some("two") => format!("Two of a kind! Winnings: {winnings} PLN"), | ||
| 41 | _ => "No win.".to_string(), | ||
| 42 | }; | ||
| 21 | } | 43 | } |
| 44 | "Press [SPACE] to spin!".to_string() | ||
| 22 | } | 45 | } |
| 23 | } | 46 | } |
| 24 | 47 | ||
| @@ -29,16 +52,23 @@ impl Game for SlotsGame { | |||
| 29 | 52 | ||
| 30 | fn render(&self, state: &GameState) -> String { | 53 | fn render(&self, state: &GameState) -> String { |
| 31 | let reels = state.data.get("reels").cloned().unwrap_or_default(); | 54 | let reels = state.data.get("reels").cloned().unwrap_or_default(); |
| 55 | let balance = state | ||
| 56 | .data | ||
| 57 | .get("balance") | ||
| 58 | .map(String::as_str) | ||
| 59 | .unwrap_or("?"); | ||
| 32 | 60 | ||
| 33 | format!( | 61 | format!( |
| 34 | "=== SLOTS ===\r\nBet: {} PLN\r\n{}\r\n\r\nPress [SPACE] to spin!\r\n\r\nControls: [+/-] Adjust Bet [Q] Quit", | 62 | "=== SLOTS ===\r\nBet: {} PLN Balance: {} PLN\r\n{}\r\n\r\n{}\r\n\r\n[+/-] Adjust Bet [Q] Quit", |
| 35 | state.bet_amount, | 63 | state.bet_amount, |
| 36 | self.render_reels(&reels) | 64 | balance, |
| 65 | self.render_reels(&reels), | ||
| 66 | self.render_status(state) | ||
| 37 | ) | 67 | ) |
| 38 | } | 68 | } |
| 39 | 69 | ||
| 40 | fn render_lines(&self, _state: &GameState) -> usize { | 70 | fn render_lines(&self, _state: &GameState) -> usize { |
| 41 | 6 | 71 | SLOT_BOARD_LINES |
| 42 | } | 72 | } |
| 43 | 73 | ||
| 44 | fn get_help(&self) -> String { | 74 | fn get_help(&self) -> String { |
diff --git a/src/client/mod.rs b/src/client/mod.rs index 5b38158..dc8423b 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs | |||
| @@ -1,2 +1,4 @@ | |||
| 1 | pub mod core; | ||
| 1 | pub mod database; | 2 | pub mod database; |
| 2 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod ui; | ||
diff --git a/src/client/ui.rs b/src/client/ui.rs new file mode 100644 index 0000000..036c36a --- /dev/null +++ b/src/client/ui.rs | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | //! Pure text/history helpers for the client TUI. | ||
| 2 | //! | ||
| 3 | //! Nothing here touches the terminal, so it is unit-tested from `tests/` | ||
| 4 | //! without a PTY. | ||
| 5 | |||
| 6 | struct CommandHelp { | ||
| 7 | name: &'static str, | ||
| 8 | usage: &'static str, | ||
| 9 | description: &'static str, | ||
| 10 | } | ||
| 11 | |||
| 12 | const COMMANDS: &[CommandHelp] = &[ | ||
| 13 | CommandHelp { | ||
| 14 | name: "connect", | ||
| 15 | usage: "connect <ip>:<port>", | ||
| 16 | description: "connect to the server", | ||
| 17 | }, | ||
| 18 | CommandHelp { | ||
| 19 | name: "send", | ||
| 20 | usage: "send <message>", | ||
| 21 | description: "send a message to the server", | ||
| 22 | }, | ||
| 23 | CommandHelp { | ||
| 24 | name: "disconnect", | ||
| 25 | usage: "disconnect", | ||
| 26 | description: "disconnect from the server", | ||
| 27 | }, | ||
| 28 | CommandHelp { | ||
| 29 | name: "ping", | ||
| 30 | usage: "ping", | ||
| 31 | description: "ping the server", | ||
| 32 | }, | ||
| 33 | CommandHelp { | ||
| 34 | name: "login", | ||
| 35 | usage: "login", | ||
| 36 | description: "login to the server", | ||
| 37 | }, | ||
| 38 | CommandHelp { | ||
| 39 | name: "register", | ||
| 40 | usage: "register", | ||
| 41 | description: "register a new user", | ||
| 42 | }, | ||
| 43 | CommandHelp { | ||
| 44 | name: "setcred", | ||
| 45 | usage: "setcred", | ||
| 46 | description: "set credentials for a host", | ||
| 47 | }, | ||
| 48 | CommandHelp { | ||
| 49 | name: "logout", | ||
| 50 | usage: "logout", | ||
| 51 | description: "logout from the server", | ||
| 52 | }, | ||
| 53 | CommandHelp { | ||
| 54 | name: "info", | ||
| 55 | usage: "info", | ||
| 56 | description: "show account and connection info", | ||
| 57 | }, | ||
| 58 | CommandHelp { | ||
| 59 | name: "play", | ||
| 60 | usage: "play <game> [bet]", | ||
| 61 | description: "start a game (e.g. play slots 50)", | ||
| 62 | }, | ||
| 63 | CommandHelp { | ||
| 64 | name: "games", | ||
| 65 | usage: "games", | ||
| 66 | description: "list available games", | ||
| 67 | }, | ||
| 68 | CommandHelp { | ||
| 69 | name: "help", | ||
| 70 | usage: "help", | ||
| 71 | description: "show this message", | ||
| 72 | }, | ||
| 73 | CommandHelp { | ||
| 74 | name: "forget-host", | ||
| 75 | usage: "forget-host [ip:port]", | ||
| 76 | description: "clear saved trust fingerprint and credentials", | ||
| 77 | }, | ||
| 78 | CommandHelp { | ||
| 79 | name: "hosts", | ||
| 80 | usage: "hosts", | ||
| 81 | description: "list remembered hosts", | ||
| 82 | }, | ||
| 83 | CommandHelp { | ||
| 84 | name: "quit", | ||
| 85 | usage: "quit", | ||
| 86 | description: "quit the program", | ||
| 87 | }, | ||
| 88 | ]; | ||
| 89 | |||
| 90 | pub fn command_names() -> Vec<&'static str> { | ||
| 91 | COMMANDS.iter().map(|command| command.name).collect() | ||
| 92 | } | ||
| 93 | |||
| 94 | pub fn help_lines() -> Vec<String> { | ||
| 95 | let mut lines = vec!["Available commands:".to_string()]; | ||
| 96 | for command in COMMANDS { | ||
| 97 | lines.push(format!( | ||
| 98 | " {:<28} - {}", | ||
| 99 | command.usage, command.description | ||
| 100 | )); | ||
| 101 | } | ||
| 102 | lines | ||
| 103 | } | ||
| 104 | |||
| 105 | /// Add a command to the history, skipping blanks and consecutive duplicates. | ||
| 106 | pub fn record_history(history: &mut Vec<String>, line: &str) { | ||
| 107 | let line = line.trim(); | ||
| 108 | if line.is_empty() { | ||
| 109 | return; | ||
| 110 | } | ||
| 111 | if history.last().is_some_and(|last| last == line) { | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | history.push(line.to_string()); | ||
| 115 | } | ||
| 116 | |||
| 117 | /// Parse the optional bet argument of `play <game> [bet]`. | ||
| 118 | pub fn parse_bet_arg(raw: &str) -> Result<u64, String> { | ||
| 119 | let bet = raw | ||
| 120 | .trim() | ||
| 121 | .parse::<u64>() | ||
| 122 | .map_err(|_| format!("Invalid bet: {raw}"))?; | ||
| 123 | if bet < 10 { | ||
| 124 | return Err("Bet must be at least 10 PLN".to_string()); | ||
| 125 | } | ||
| 126 | Ok(bet) | ||
| 127 | } | ||
| 128 | |||
| 129 | /// Lines to move the cursor up to reach the first line of a region that ends | ||
| 130 | /// on the current line. `render()` has no trailing newline, so a 7-line board | ||
| 131 | /// needs `MoveUp(6)`. | ||
| 132 | pub fn lines_to_move_up(previous_lines: usize) -> u16 { | ||
| 133 | previous_lines | ||
| 134 | .saturating_sub(1) | ||
| 135 | .min(u16::MAX as usize) as u16 | ||
| 136 | } | ||
| 137 | |||
| 138 | /// Column (0-based) to move to after the given prompt and input text. | ||
| 139 | pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { | ||
| 140 | prompt_len | ||
| 141 | .saturating_add(input_len) | ||
| 142 | .min(u16::MAX as usize) as u16 | ||
| 143 | } | ||
