summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/client/core.rs34
-rw-r--r--src/client/games/response.rs20
-rw-r--r--src/client/games/roulette.rs141
-rw-r--r--src/client/ui.rs29
-rw-r--r--src/history.rs54
-rw-r--r--src/lib.rs1
-rw-r--r--src/roulette.rs11
-rw-r--r--src/server/core.rs34
-rw-r--r--src/server/database.rs27
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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::{ 2use 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};
5use crate::client::games::roulette::Direction; 6use crate::client::games::roulette::Direction;
6use crate::client::ui::{ 7use 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};
10use crate::crash::{CrashState, multiplier_at}; 11use crate::crash::{CrashState, multiplier_at};
12use crate::history::MAX_HISTORY;
11use crate::roulette::Bets; 13use crate::roulette::Bets;
12use std::time::{Duration, Instant}; 14use 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
188pub 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)]
37pub enum Cursor { 44pub 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.
53const FELT_X_MAX: usize = 2 * NUM_COLUMNS - 1;
54const FELT_Y_MAX: usize = 2 * 3 - 1;
55
45impl Default for Cursor { 56impl 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).
150fn lattice_target(x: usize, y: usize) -> Option<BetTarget> { 165fn 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.
173fn 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.
276pub 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
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_HISTORY: u32 = 20;
6pub 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)]
10pub 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
21impl 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}
diff --git a/src/lib.rs b/src/lib.rs
index ffc3d60..51afafc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,6 +8,7 @@
8pub mod blackjack; 8pub mod blackjack;
9pub mod client; 9pub mod client;
10pub mod crash; 10pub mod crash;
11pub mod history;
11pub mod protocol; 12pub mod protocol;
12pub mod roulette; 13pub mod roulette;
13pub mod server; 14pub 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.
404pub 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}