summaryrefslogtreecommitdiffstats
path: root/src/client/core.rs
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/client/core.rs
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/client/core.rs')
-rw-r--r--src/client/core.rs494
1 files changed, 494 insertions, 0 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 @@
1use crate::client::games::manager::GameManager;
2use crate::client::games::response::parse_spin_response;
3use 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)]
8pub 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)]
20pub 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)]
30pub 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)]
54enum Pending {
55 Spin,
56 Ping,
57 Echo,
58 Info,
59}
60
61pub 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
74impl 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
470fn print_line(message: impl Into<String>) -> Vec<Effect> {
471 vec![Effect::Print(vec![message.into()])]
472}
473
474fn 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
483fn 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}