diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/core.rs | 34 | ||||
| -rw-r--r-- | src/client/games/response.rs | 20 | ||||
| -rw-r--r-- | src/client/games/roulette.rs | 141 | ||||
| -rw-r--r-- | src/client/ui.rs | 29 | ||||
| -rw-r--r-- | src/history.rs | 54 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/roulette.rs | 11 | ||||
| -rw-r--r-- | src/server/core.rs | 34 | ||||
| -rw-r--r-- | src/server/database.rs | 27 |
9 files changed, 310 insertions, 41 deletions
diff --git a/src/client/core.rs b/src/client/core.rs index 54a6434..0b2e683 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -1,13 +1,15 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::{ | 2 | use crate::client::games::response::{ |
| 3 | parse_blackjack_response, parse_crash_response, parse_roulette_response, parse_spin_response, | 3 | parse_blackjack_response, parse_crash_response, parse_history_response, |
| 4 | parse_roulette_response, parse_spin_response, | ||
| 4 | }; | 5 | }; |
| 5 | use crate::client::games::roulette::Direction; | 6 | use crate::client::games::roulette::Direction; |
| 6 | use crate::client::ui::{ | 7 | use crate::client::ui::{ |
| 7 | command_names, completion_lines, display_width, help_lines, input_cursor_column, parse_bet_arg, | 8 | command_names, completion_lines, display_width, help_lines, history_lines, |
| 8 | record_history, | 9 | input_cursor_column, parse_bet_arg, record_history, |
| 9 | }; | 10 | }; |
| 10 | use crate::crash::{CrashState, multiplier_at}; | 11 | use crate::crash::{CrashState, multiplier_at}; |
| 12 | use crate::history::MAX_HISTORY; | ||
| 11 | use crate::roulette::Bets; | 13 | use crate::roulette::Bets; |
| 12 | use std::time::{Duration, Instant}; | 14 | use std::time::{Duration, Instant}; |
| 13 | 15 | ||
| @@ -74,6 +76,7 @@ enum Pending { | |||
| 74 | CrashStart, | 76 | CrashStart, |
| 75 | CrashCashout, | 77 | CrashCashout, |
| 76 | CrashForfeit, | 78 | CrashForfeit, |
| 79 | History, | ||
| 77 | Ping, | 80 | Ping, |
| 78 | Echo, | 81 | Echo, |
| 79 | Info, | 82 | Info, |
| @@ -290,6 +293,10 @@ impl ClientCore { | |||
| 290 | self.board_effects() | 293 | self.board_effects() |
| 291 | } | 294 | } |
| 292 | }, | 295 | }, |
| 296 | Some(Pending::History) => match parse_history_response(reply) { | ||
| 297 | Ok(records) => vec![Effect::Print(history_lines(&records))], | ||
| 298 | Err(message) => print_line(message), | ||
| 299 | }, | ||
| 293 | Some(Pending::Ping) => { | 300 | Some(Pending::Ping) => { |
| 294 | if reply.trim() == "PONG" { | 301 | if reply.trim() == "PONG" { |
| 295 | print_line("Pong") | 302 | print_line("Pong") |
| @@ -782,6 +789,7 @@ impl ClientCore { | |||
| 782 | } | 789 | } |
| 783 | } | 790 | } |
| 784 | "info" => self.cmd_info(), | 791 | "info" => self.cmd_info(), |
| 792 | "history" => self.cmd_history(&parts), | ||
| 785 | "hosts" => vec![Effect::ListHosts], | 793 | "hosts" => vec![Effect::ListHosts], |
| 786 | "forget-host" => { | 794 | "forget-host" => { |
| 787 | let target = parts | 795 | let target = parts |
| @@ -851,6 +859,26 @@ impl ClientCore { | |||
| 851 | vec![Effect::Print(lines)] | 859 | vec![Effect::Print(lines)] |
| 852 | } | 860 | } |
| 853 | 861 | ||
| 862 | fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { | ||
| 863 | if !self.connected { | ||
| 864 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 865 | } | ||
| 866 | if self.logged_in.is_none() { | ||
| 867 | return print_line("Not logged in. Use 'login' first."); | ||
| 868 | } | ||
| 869 | |||
| 870 | let request = match parts.get(1) { | ||
| 871 | Some(raw) => match raw.parse::<u32>() { | ||
| 872 | Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), | ||
| 873 | _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), | ||
| 874 | }, | ||
| 875 | None => "HISTORY".to_string(), | ||
| 876 | }; | ||
| 877 | |||
| 878 | self.pending = Some(Pending::History); | ||
| 879 | vec![Effect::SendLine(request)] | ||
| 880 | } | ||
| 881 | |||
| 854 | fn cmd_info(&mut self) -> Vec<Effect> { | 882 | fn cmd_info(&mut self) -> Vec<Effect> { |
| 855 | if !self.connected { | 883 | if !self.connected { |
| 856 | return print_line("Not connected to any host."); | 884 | return print_line("Not connected to any host."); |
diff --git a/src/client/games/response.rs b/src/client/games/response.rs index 88df459..060ad44 100644 --- a/src/client/games/response.rs +++ b/src/client/games/response.rs | |||
| @@ -184,3 +184,23 @@ pub fn parse_crash_response(response: &str) -> Result<crate::crash::CrashState, | |||
| 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | 184 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; |
| 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) | 185 | serde_json::from_str(raw).map_err(|error| format!("Invalid crash state: {error}")) |
| 186 | } | 186 | } |
| 187 | |||
| 188 | pub fn parse_history_response(response: &str) -> Result<Vec<crate::history::PlayRecord>, String> { | ||
| 189 | let response = response.trim(); | ||
| 190 | if response.is_empty() { | ||
| 191 | return Err("No response from server".to_string()); | ||
| 192 | } | ||
| 193 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 194 | let message = message.trim(); | ||
| 195 | return Err(if message.is_empty() { | ||
| 196 | "Server error".to_string() | ||
| 197 | } else { | ||
| 198 | message.to_string() | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | let raw = response | ||
| 203 | .strip_prefix("HISTORY ") | ||
| 204 | .ok_or_else(|| format!("Unexpected server response: {response}"))?; | ||
| 205 | serde_json::from_str(raw).map_err(|error| format!("Invalid history: {error}")) | ||
| 206 | } | ||
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index 96f8791..b3a8d9c 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs | |||
| @@ -30,9 +30,16 @@ pub enum Direction { | |||
| 30 | 30 | ||
| 31 | /// Where the betting cursor is. | 31 | /// Where the betting cursor is. |
| 32 | /// | 32 | /// |
| 33 | /// Over the numbers the cursor sits on a *lattice point*, the way a chip sits | 33 | /// Over the numbers the cursor walks a grid twice as fine as the felt itself: |
| 34 | /// between numbers on a real felt: an inner point covers four numbers (corner), | 34 | /// odd coordinates are the centre of a number cell and even coordinates are the |
| 35 | /// an edge point covers two (split), and an outer corner is a straight number. | 35 | /// line between cells, exactly where a chip sits on a real felt. |
| 36 | /// | ||
| 37 | /// ```text | ||
| 38 | /// odd,odd the number straight | ||
| 39 | /// even,odd line between columns split of the two numbers | ||
| 40 | /// odd,even line between rows split of the two numbers | ||
| 41 | /// even,even corner of four cells corner | ||
| 42 | /// ``` | ||
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 37 | pub enum Cursor { | 44 | pub enum Cursor { |
| 38 | Zero, | 45 | Zero, |
| @@ -42,10 +49,14 @@ pub enum Cursor { | |||
| 42 | Outside(usize), | 49 | Outside(usize), |
| 43 | } | 50 | } |
| 44 | 51 | ||
| 52 | /// Fine-grid bounds: 12 columns and 3 rows of cells, plus the lines between. | ||
| 53 | const FELT_X_MAX: usize = 2 * NUM_COLUMNS - 1; | ||
| 54 | const FELT_Y_MAX: usize = 2 * 3 - 1; | ||
| 55 | |||
| 45 | impl Default for Cursor { | 56 | impl Default for Cursor { |
| 46 | fn default() -> Self { | 57 | fn default() -> Self { |
| 47 | // Bottom-left number (1). | 58 | // Centre of the bottom-left number (1). |
| 48 | Cursor::Lattice { x: 0, y: 3 } | 59 | Cursor::Lattice { x: 1, y: 5 } |
| 49 | } | 60 | } |
| 50 | } | 61 | } |
| 51 | 62 | ||
| @@ -55,7 +66,9 @@ impl Cursor { | |||
| 55 | match parts.as_slice() { | 66 | match parts.as_slice() { |
| 56 | ["zero"] => Cursor::Zero, | 67 | ["zero"] => Cursor::Zero, |
| 57 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { | 68 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { |
| 58 | (Ok(x), Ok(y)) if x <= NUM_COLUMNS && y <= 3 => Cursor::Lattice { x, y }, | 69 | (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { |
| 70 | Cursor::Lattice { x, y } | ||
| 71 | } | ||
| 59 | _ => Cursor::default(), | 72 | _ => Cursor::default(), |
| 60 | }, | 73 | }, |
| 61 | ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), | 74 | ["dozen", index] => parse_index(index).map(Cursor::Dozen).unwrap_or_default(), |
| @@ -81,7 +94,7 @@ impl Cursor { | |||
| 81 | pub fn target(&self) -> Option<BetTarget> { | 94 | pub fn target(&self) -> Option<BetTarget> { |
| 82 | match self { | 95 | match self { |
| 83 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), | 96 | Cursor::Zero => Some(BetTarget::Straight { number: 0 }), |
| 84 | Cursor::Lattice { x, y } => lattice_target(*x, *y), | 97 | Cursor::Lattice { x, y } => felt_target(*x, *y), |
| 85 | Cursor::Dozen(index) => Some(BetTarget::Dozen { | 98 | Cursor::Dozen(index) => Some(BetTarget::Dozen { |
| 86 | index: *index as u8, | 99 | index: *index as u8, |
| 87 | }), | 100 | }), |
| @@ -103,27 +116,29 @@ impl Cursor { | |||
| 103 | 116 | ||
| 104 | pub fn moved(&self, direction: Direction) -> Cursor { | 117 | pub fn moved(&self, direction: Direction) -> Cursor { |
| 105 | match (*self, direction) { | 118 | match (*self, direction) { |
| 106 | (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 0, y: 0 }, | 119 | (Cursor::Zero, Direction::Down) => Cursor::Lattice { x: 1, y: 1 }, |
| 107 | (Cursor::Zero, _) => Cursor::Zero, | 120 | (Cursor::Zero, _) => Cursor::Zero, |
| 108 | 121 | ||
| 109 | (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { | 122 | (Cursor::Lattice { x, y }, Direction::Left) => Cursor::Lattice { |
| 110 | x: x.saturating_sub(1), | 123 | x: if x > 1 { x - 1 } else { x }, |
| 111 | y, | 124 | y, |
| 112 | }, | 125 | }, |
| 113 | (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { | 126 | (Cursor::Lattice { x, y }, Direction::Right) => Cursor::Lattice { |
| 114 | x: (x + 1).min(NUM_COLUMNS), | 127 | x: (x + 1).min(FELT_X_MAX), |
| 115 | y, | 128 | y, |
| 116 | }, | 129 | }, |
| 117 | (Cursor::Lattice { x: _, y: 0 }, Direction::Up) => Cursor::Zero, | 130 | (Cursor::Lattice { x: _, y: 1 }, Direction::Up) => Cursor::Zero, |
| 118 | (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, | 131 | (Cursor::Lattice { x, y }, Direction::Up) => Cursor::Lattice { x, y: y - 1 }, |
| 119 | (Cursor::Lattice { x, y: 3 }, Direction::Down) => Cursor::Dozen((x / 4).min(2)), | 132 | (Cursor::Lattice { x, y: FELT_Y_MAX }, Direction::Down) => { |
| 133 | Cursor::Dozen((x.saturating_sub(1) / 8).min(2)) | ||
| 134 | } | ||
| 120 | (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, | 135 | (Cursor::Lattice { x, y }, Direction::Down) => Cursor::Lattice { x, y: y + 1 }, |
| 121 | 136 | ||
| 122 | (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), | 137 | (Cursor::Dozen(index), Direction::Left) => Cursor::Dozen(index.saturating_sub(1)), |
| 123 | (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), | 138 | (Cursor::Dozen(index), Direction::Right) => Cursor::Dozen((index + 1).min(2)), |
| 124 | (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { | 139 | (Cursor::Dozen(index), Direction::Up) => Cursor::Lattice { |
| 125 | x: 4 * index + 1, | 140 | x: 8 * index + 1, |
| 126 | y: 3, | 141 | y: FELT_Y_MAX, |
| 127 | }, | 142 | }, |
| 128 | (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), | 143 | (Cursor::Dozen(index), Direction::Down) => Cursor::Column(index), |
| 129 | 144 | ||
| @@ -146,30 +161,69 @@ fn parse_index(raw: &str) -> Option<usize> { | |||
| 146 | raw.parse::<usize>().ok().filter(|index| *index < 3) | 161 | raw.parse::<usize>().ok().filter(|index| *index < 3) |
| 147 | } | 162 | } |
| 148 | 163 | ||
| 149 | /// Numbers under a lattice point: 1 (outer corner), 2 (edge) or 4 (inner). | 164 | /// Number at a felt cell (column 0-11, row 0-2 counted from the top). |
| 150 | fn lattice_target(x: usize, y: usize) -> Option<BetTarget> { | 165 | fn cell_number(column: usize, row: usize) -> Option<u8> { |
| 151 | let cell = |column: isize, row: isize| -> Option<u8> { | 166 | if column >= NUM_COLUMNS || row >= 3 { |
| 152 | if !(0..NUM_COLUMNS as isize).contains(&column) || !(0..3isize).contains(&row) { | 167 | return None; |
| 153 | return None; | 168 | } |
| 169 | Some((3 * column + (3 - row)) as u8) | ||
| 170 | } | ||
| 171 | |||
| 172 | /// Bet under a point of the fine grid. | ||
| 173 | fn felt_target(x: usize, y: usize) -> Option<BetTarget> { | ||
| 174 | if !(1..=FELT_X_MAX).contains(&x) || !(1..=FELT_Y_MAX).contains(&y) { | ||
| 175 | return None; | ||
| 176 | } | ||
| 177 | |||
| 178 | let column = (x - 1) / 2; | ||
| 179 | let row = (y - 1) / 2; | ||
| 180 | |||
| 181 | match (x % 2, y % 2) { | ||
| 182 | // Centre of a cell: straight. | ||
| 183 | (1, 1) => cell_number(column, row).map(|number| BetTarget::Straight { number }), | ||
| 184 | // Vertical line: the two cells left and right of it. | ||
| 185 | (0, 1) => { | ||
| 186 | let left = cell_number(x / 2 - 1, row); | ||
| 187 | let right = cell_number(x / 2, row); | ||
| 188 | match (left, right) { | ||
| 189 | (Some(a), Some(b)) => Some(BetTarget::Split { | ||
| 190 | a: a.min(b), | ||
| 191 | b: a.max(b), | ||
| 192 | }), | ||
| 193 | _ => None, | ||
| 194 | } | ||
| 195 | } | ||
| 196 | // Horizontal line: the two cells above and below it. | ||
| 197 | (1, 0) => { | ||
| 198 | let above = cell_number(column, y / 2 - 1); | ||
| 199 | let below = cell_number(column, y / 2); | ||
| 200 | match (above, below) { | ||
| 201 | (Some(a), Some(b)) => Some(BetTarget::Split { | ||
| 202 | a: a.min(b), | ||
| 203 | b: a.max(b), | ||
| 204 | }), | ||
| 205 | _ => None, | ||
| 206 | } | ||
| 207 | } | ||
| 208 | // Crossing: the four cells around it. | ||
| 209 | _ => { | ||
| 210 | let mut numbers: Vec<u8> = [ | ||
| 211 | (x / 2 - 1, y / 2 - 1), | ||
| 212 | (x / 2, y / 2 - 1), | ||
| 213 | (x / 2 - 1, y / 2), | ||
| 214 | (x / 2, y / 2), | ||
| 215 | ] | ||
| 216 | .into_iter() | ||
| 217 | .filter_map(|(column, row)| cell_number(column, row)) | ||
| 218 | .collect(); | ||
| 219 | numbers.sort_unstable(); | ||
| 220 | match numbers.as_slice() { | ||
| 221 | [a, b, c, d] => Some(BetTarget::Corner { | ||
| 222 | numbers: [*a, *b, *c, *d], | ||
| 223 | }), | ||
| 224 | _ => None, | ||
| 225 | } | ||
| 154 | } | 226 | } |
| 155 | Some((3 * column + (3 - row)) as u8) | ||
| 156 | }; | ||
| 157 | |||
| 158 | let x = x as isize; | ||
| 159 | let y = y as isize; | ||
| 160 | let mut numbers: Vec<u8> = [(x - 1, y - 1), (x, y - 1), (x - 1, y), (x, y)] | ||
| 161 | .into_iter() | ||
| 162 | .filter_map(|(column, row)| cell(column, row)) | ||
| 163 | .collect(); | ||
| 164 | numbers.sort_unstable(); | ||
| 165 | |||
| 166 | match numbers.as_slice() { | ||
| 167 | [number] => Some(BetTarget::Straight { number: *number }), | ||
| 168 | [a, b] => Some(BetTarget::Split { a: *a, b: *b }), | ||
| 169 | [a, b, c, d] => Some(BetTarget::Corner { | ||
| 170 | numbers: [*a, *b, *c, *d], | ||
| 171 | }), | ||
| 172 | _ => None, | ||
| 173 | } | 227 | } |
| 174 | } | 228 | } |
| 175 | 229 | ||
| @@ -317,7 +371,18 @@ impl RouletteGame { | |||
| 317 | } | 371 | } |
| 318 | } | 372 | } |
| 319 | Some("spinning") => "Spinning...".to_string(), | 373 | Some("spinning") => "Spinning...".to_string(), |
| 320 | _ => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | 374 | _ => { |
| 375 | let chip = state.data.get("chip").map(String::as_str).unwrap_or("10"); | ||
| 376 | match Self::cursor(state).target() { | ||
| 377 | Some(target) => format!( | ||
| 378 | "Bet: {} · pays {} · ENTER places {} Kr", | ||
| 379 | target.label(), | ||
| 380 | target.return_label(), | ||
| 381 | chip | ||
| 382 | ), | ||
| 383 | None => "Arrows move, ENTER places the chip, SPACE spins.".to_string(), | ||
| 384 | } | ||
| 385 | } | ||
| 321 | } | 386 | } |
| 322 | } | 387 | } |
| 323 | } | 388 | } |
diff --git a/src/client/ui.rs b/src/client/ui.rs index e7f510b..bb5b55f 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs | |||
| @@ -81,6 +81,11 @@ const COMMANDS: &[CommandHelp] = &[ | |||
| 81 | description: "list remembered hosts", | 81 | description: "list remembered hosts", |
| 82 | }, | 82 | }, |
| 83 | CommandHelp { | 83 | CommandHelp { |
| 84 | name: "history", | ||
| 85 | usage: "history [count]", | ||
| 86 | description: "show your recent wins and losses", | ||
| 87 | }, | ||
| 88 | CommandHelp { | ||
| 84 | name: "quit", | 89 | name: "quit", |
| 85 | usage: "quit", | 90 | usage: "quit", |
| 86 | description: "quit the program", | 91 | description: "quit the program", |
| @@ -267,6 +272,30 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option<String> { | |||
| 267 | )) | 272 | )) |
| 268 | } | 273 | } |
| 269 | 274 | ||
| 275 | /// One line per settled play, newest first. | ||
| 276 | pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { | ||
| 277 | if records.is_empty() { | ||
| 278 | return vec!["No plays recorded yet.".to_string()]; | ||
| 279 | } | ||
| 280 | |||
| 281 | let mut lines = vec![ | ||
| 282 | format!("Recent plays ({}):", records.len()), | ||
| 283 | " when game bet won balance result".to_string(), | ||
| 284 | ]; | ||
| 285 | for record in records { | ||
| 286 | lines.push(format!( | ||
| 287 | " {:<16} {:<8} {:>6} {:>6} {:>7} {}", | ||
| 288 | record.short_time(), | ||
| 289 | record.game, | ||
| 290 | record.bet, | ||
| 291 | record.winnings, | ||
| 292 | record.balance_after, | ||
| 293 | record.summary() | ||
| 294 | )); | ||
| 295 | } | ||
| 296 | lines | ||
| 297 | } | ||
| 298 | |||
| 270 | /// Render remembered hosts. | 299 | /// Render remembered hosts. |
| 271 | /// | 300 | /// |
| 272 | /// Two addresses that present the same certificate key are the same server, so | 301 | /// Two addresses that present the same certificate key are the same server, so |
diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..5ef375b --- /dev/null +++ b/src/history.rs | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | //! Play history as sent to the client. | ||
| 2 | |||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | |||
| 5 | pub const DEFAULT_HISTORY: u32 = 20; | ||
| 6 | pub const MAX_HISTORY: u32 = 50; | ||
| 7 | |||
| 8 | /// One settled play. `details` is the game's own audit blob. | ||
| 9 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 10 | pub struct PlayRecord { | ||
| 11 | pub game: String, | ||
| 12 | pub bet: i64, | ||
| 13 | pub winnings: i64, | ||
| 14 | pub balance_after: i64, | ||
| 15 | /// Timestamp as stored in SQLite (`YYYY-MM-DD HH:MM:SS`, UTC). | ||
| 16 | pub at: String, | ||
| 17 | #[serde(default)] | ||
| 18 | pub details: serde_json::Value, | ||
| 19 | } | ||
| 20 | |||
| 21 | impl PlayRecord { | ||
| 22 | /// Short human summary of the game-specific details. | ||
| 23 | pub fn summary(&self) -> String { | ||
| 24 | let field = |name: &str| -> Option<String> { | ||
| 25 | self.details.get(name).map(|value| match value.as_str() { | ||
| 26 | Some(text) => text.to_string(), | ||
| 27 | None => value.to_string(), | ||
| 28 | }) | ||
| 29 | }; | ||
| 30 | |||
| 31 | match self.game.as_str() { | ||
| 32 | "slots" => field("win_type").unwrap_or_else(|| "spin".to_string()), | ||
| 33 | "roulette" => match (field("number"), field("color")) { | ||
| 34 | (Some(number), Some(color)) => format!("{number} {color}"), | ||
| 35 | (Some(number), None) => number, | ||
| 36 | _ => String::new(), | ||
| 37 | }, | ||
| 38 | "blackjack" => field("result").unwrap_or_default(), | ||
| 39 | "crash" => { | ||
| 40 | let result = field("result").unwrap_or_default(); | ||
| 41 | match self.details.get("multiplier").and_then(|value| value.as_f64()) { | ||
| 42 | Some(multiplier) => format!("{result} {multiplier:.2}x"), | ||
| 43 | None => result, | ||
| 44 | } | ||
| 45 | } | ||
| 46 | _ => String::new(), | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | /// `YYYY-MM-DD HH:MM`, dropping the seconds. | ||
| 51 | pub fn short_time(&self) -> &str { | ||
| 52 | self.at.get(..16).unwrap_or(&self.at) | ||
| 53 | } | ||
| 54 | } | ||
| @@ -8,6 +8,7 @@ | |||
| 8 | pub mod blackjack; | 8 | pub mod blackjack; |
| 9 | pub mod client; | 9 | pub mod client; |
| 10 | pub mod crash; | 10 | pub mod crash; |
| 11 | pub mod history; | ||
| 11 | pub mod protocol; | 12 | pub mod protocol; |
| 12 | pub mod roulette; | 13 | pub mod roulette; |
| 13 | pub mod server; | 14 | pub mod server; |
diff --git a/src/roulette.rs b/src/roulette.rs index e7b3aad..6b73c2a 100644 --- a/src/roulette.rs +++ b/src/roulette.rs | |||
| @@ -140,6 +140,17 @@ impl BetTarget { | |||
| 140 | } | 140 | } |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | /// Total return for a winning version of this bet. | ||
| 144 | pub fn return_label(&self) -> &'static str { | ||
| 145 | match self { | ||
| 146 | BetTarget::Straight { .. } => "36x", | ||
| 147 | BetTarget::Split { .. } => "18x", | ||
| 148 | BetTarget::Corner { .. } => "9x", | ||
| 149 | BetTarget::Dozen { .. } | BetTarget::Column { .. } => "3x", | ||
| 150 | BetTarget::Outside { .. } => "2x", | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 143 | pub fn label(&self) -> String { | 154 | pub fn label(&self) -> String { |
| 144 | match self { | 155 | match self { |
| 145 | BetTarget::Straight { number } => number.to_string(), | 156 | BetTarget::Straight { number } => number.to_string(), |
diff --git a/src/server/core.rs b/src/server/core.rs index 4b68ccb..89264f3 100644 --- a/src/server/core.rs +++ b/src/server/core.rs | |||
| @@ -172,6 +172,21 @@ impl ServerCore { | |||
| 172 | self.handle_auth(state, parts[1], parts[2]) | 172 | self.handle_auth(state, parts[1], parts[2]) |
| 173 | } | 173 | } |
| 174 | } | 174 | } |
| 175 | "HISTORY" => { | ||
| 176 | if parts.len() > 2 { | ||
| 177 | "ERROR Invalid HISTORY format. Usage: HISTORY [count]".to_string() | ||
| 178 | } else { | ||
| 179 | match parts.get(1) { | ||
| 180 | Some(raw) => match raw.parse::<u32>() { | ||
| 181 | Ok(count) if count > 0 => { | ||
| 182 | self.handle_history(state, count.min(crate::history::MAX_HISTORY)) | ||
| 183 | } | ||
| 184 | _ => "ERROR Invalid HISTORY count".to_string(), | ||
| 185 | }, | ||
| 186 | None => self.handle_history(state, crate::history::DEFAULT_HISTORY), | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 175 | "PING" => "PONG".to_string(), | 190 | "PING" => "PONG".to_string(), |
| 176 | "INFO" => self.handle_info(state), | 191 | "INFO" => self.handle_info(state), |
| 177 | _ => msg.to_string(), | 192 | _ => msg.to_string(), |
| @@ -234,6 +249,25 @@ impl ServerCore { | |||
| 234 | } | 249 | } |
| 235 | } | 250 | } |
| 236 | 251 | ||
| 252 | fn handle_history(&mut self, state: &ConnectionState, limit: u32) -> String { | ||
| 253 | let Some(user) = state.user.as_deref() else { | ||
| 254 | return "ERROR Not logged in".to_string(); | ||
| 255 | }; | ||
| 256 | match database::recent_plays(&self.conn, user, limit) { | ||
| 257 | Ok(records) => match serde_json::to_string(&records) { | ||
| 258 | Ok(json) => format!("HISTORY {json}"), | ||
| 259 | Err(error) => { | ||
| 260 | eprintln!("History serialization failed for {user}: {error}"); | ||
| 261 | "ERROR Could not read history".to_string() | ||
| 262 | } | ||
| 263 | }, | ||
| 264 | Err(error) => { | ||
| 265 | eprintln!("History query failed for {user}: {error}"); | ||
| 266 | "ERROR Could not read history".to_string() | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 237 | fn handle_info(&self, state: &ConnectionState) -> String { | 271 | fn handle_info(&self, state: &ConnectionState) -> String { |
| 238 | let Some(user) = state.user.as_deref() else { | 272 | let Some(user) = state.user.as_deref() else { |
| 239 | return "ERROR Not logged in".to_string(); | 273 | return "ERROR Not logged in".to_string(); |
diff --git a/src/server/database.rs b/src/server/database.rs index 6dd71a3..cfc48c1 100644 --- a/src/server/database.rs +++ b/src/server/database.rs | |||
| @@ -399,3 +399,30 @@ fn abandoned_details(raw: &str) -> String { | |||
| 399 | } | 399 | } |
| 400 | value.to_string() | 400 | value.to_string() |
| 401 | } | 401 | } |
| 402 | |||
| 403 | /// Most recent settled plays for a user, newest first. | ||
| 404 | pub fn recent_plays( | ||
| 405 | conn: &Connection, | ||
| 406 | username: &str, | ||
| 407 | limit: u32, | ||
| 408 | ) -> rusqlite::Result<Vec<crate::history::PlayRecord>> { | ||
| 409 | let mut statement = conn.prepare( | ||
| 410 | "SELECT game_type, bet_amount, winnings, balance_after, details, created_at | ||
| 411 | FROM plays | ||
| 412 | WHERE username = ?1 | ||
| 413 | ORDER BY created_at DESC, rowid DESC | ||
| 414 | LIMIT ?2", | ||
| 415 | )?; | ||
| 416 | let rows = statement.query_map(params![username, limit], |row| { | ||
| 417 | let details: String = row.get(4)?; | ||
| 418 | Ok(crate::history::PlayRecord { | ||
| 419 | game: row.get(0)?, | ||
| 420 | bet: row.get(1)?, | ||
| 421 | winnings: row.get(2)?, | ||
| 422 | balance_after: row.get(3)?, | ||
| 423 | at: row.get(5)?, | ||
| 424 | details: serde_json::from_str(&details).unwrap_or(serde_json::Value::Null), | ||
| 425 | }) | ||
| 426 | })?; | ||
| 427 | rows.collect() | ||
| 428 | } | ||
