summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 17:02:49 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 17:02:49 +0100
commit3b50928cb083b104043ac65433ed090c87731eb7 (patch)
tree1d65beaaa68188c521b0d7da3f78c847540e5c32
parentv1.3.1: allow = to increase the bet (diff)
downloadzagan-bet-3b50928cb083b104043ac65433ed090c87731eb7.tar.gz
zagan-bet-3b50928cb083b104043ac65433ed090c87731eb7.zip
v2.0.0: reset plays audit to a generic per-game schema
-rw-r--r--Cargo.toml3
-rw-r--r--docs/GAMES.md74
-rw-r--r--docs/SERVER_SETUP.md16
-rw-r--r--docs/TESTING.md13
-rw-r--r--src/server/core.rs7
-rw-r--r--src/server/database.rs28
-rw-r--r--tests/server_core.rs27
7 files changed, 150 insertions, 18 deletions
diff --git a/Cargo.toml b/Cargo.toml
index f9e56f9..6b32e67 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "1.3.1" 3version = "2.0.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
@@ -12,6 +12,7 @@ parking_lot = "0.12"
12sha2 = "0.10" 12sha2 = "0.10"
13ctrlc = "3.4" 13ctrlc = "3.4"
14serde = { version = "1.0", features = ["derive"] } 14serde = { version = "1.0", features = ["derive"] }
15serde_json = "1.0"
15uuid = { version = "1.0", features = ["v4"] } 16uuid = { version = "1.0", features = ["v4"] }
16native-tls = "0.2" 17native-tls = "0.2"
17rustls = { version = "0.21", features = ["dangerous_configuration"] } 18rustls = { version = "0.21", features = ["dangerous_configuration"] }
diff --git a/docs/GAMES.md b/docs/GAMES.md
new file mode 100644
index 0000000..cbe355e
--- /dev/null
+++ b/docs/GAMES.md
@@ -0,0 +1,74 @@
1# Games and the play audit
2
3## Implemented
4
5| Game | Status | Notes |
6| --- | --- | --- |
7| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP |
8
9## Planned
10
11| Game | Shape | Main open decisions |
12| --- | --- | --- |
13| Roulette | One spin, many bets | European/French/American wheel; which bet types; 35:1 straight and other payouts |
14| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence |
15| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness |
16
17Roulette fits the current one-request-settle model most easily. Blackjack needs
18server-side hand state and player actions. Crash needs a timed round loop and a
19cashout action, so it should come last unless we redesign the loop first.
20
21## Audit schema
22
23Every settled play is one row in `plays`:
24
25```
26plays (
27 session_id TEXT PRIMARY KEY,
28 username TEXT NOT NULL,
29 game_type TEXT NOT NULL,
30 bet_amount INTEGER NOT NULL,
31 winnings INTEGER NOT NULL,
32 balance_after INTEGER NOT NULL,
33 details TEXT NOT NULL DEFAULT '{}',
34 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
35)
36```
37
38`game_type` is the game that was played. `details` is a JSON object whose shape
39depends on the game:
40
41```json
42// slots
43{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"}
44
45// rumoured future examples
46{"player":["AS","10H"],"dealer":["9D","7C"],"outcome":"blackjack"}
47{"bets":[{"kind":"straight","number":17,"amount":10}],"winning":17}
48{"crash_point":2.41,"cashout":1.80}
49```
50
51The balance updates and the `plays` insert happen in one transaction
52(`database::apply_play`). The slots-specific wire fields (`reels`, `win_lines`,
53`win_cells`) are still in `GAME_RESULT`, but the database no longer assumes
54them.
55
56## Protocol gap
57
58The current exchange is slots-shaped:
59
60```
61PLAY_START <game> <bet>
62GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells>
63```
64
65Before blackjack, roulette, or crash can be added, the game session needs a
66generic state/action protocol. A possible shape:
67
68```
69GAME_START <game> <bet>
70GAME_ACTION <action> [json]
71GAME_STATE <json> # cards, bets, multiplier, legal actions, result
72```
73
74That is a wire-protocol change and will be its own major version.
diff --git a/docs/SERVER_SETUP.md b/docs/SERVER_SETUP.md
index e2f3177..62d1a9c 100644
--- a/docs/SERVER_SETUP.md
+++ b/docs/SERVER_SETUP.md
@@ -69,6 +69,22 @@ On first contact the client prints the SPKI fingerprint and pins it. If you
69regenerate the server identity, the client will reject the new key; run 69regenerate the server identity, the client will reject the new key; run
70`forget-host` in the client if the change is intentional. 70`forget-host` in the client if the change is intentional.
71 71
72## Database
73
74The server creates `server.db` in the current directory on first use. It holds
75users, balances, and the per-game `plays` audit. The client keeps its own
76`client.db` for host fingerprints and stored credentials.
77
78During development, delete both files to reset users, balances, history, and
79trust:
80
81```sh
82rm -f server.db client.db
83```
84
85The `plays` schema is not auto-migrated from the old slots-only shape; a stale
86database must be removed.
87
72## Windows 88## Windows
73 89
74`--init` shells out to `openssl`, so OpenSSL must be installed and on `PATH` 90`--init` shells out to `openssl`, so OpenSSL must be installed and on `PATH`
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 2a3572d..4d46855 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -23,6 +23,19 @@ shared global database. Each test owns its state.
23`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them 23`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them
24on a machine where the `openssl` CLI is available. 24on a machine where the `openssl` CLI is available.
25 25
26## Development resets
27
28The server creates `server.db` in its working directory; the client creates
29`client.db` for host trust and stored credentials. While the software is in
30testing, delete both after a schema or trust change:
31
32```sh
33rm -f server.db client.db
34```
35
36The audit schema is not auto-migrated from the pre-2.0 slots-only `plays`
37table. Starting against an old database will fail until it is removed.
38
26## Manual TUI checklist 39## Manual TUI checklist
27 40
28There is no CI. Run this on Linux and on a Windows VM before a release. 41There is no CI. Run this on Linux and on a Windows VM before a release.
diff --git a/src/server/core.rs b/src/server/core.rs
index 350fe18..7231577 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -259,16 +259,17 @@ impl ServerCore {
259 .get("win_cells") 259 .get("win_cells")
260 .cloned() 260 .cloned()
261 .unwrap_or_else(|| "-".to_string()); 261 .unwrap_or_else(|| "-".to_string());
262 let details =
263 serde_json::to_string(&result.result_data).unwrap_or_else(|_| "{}".to_string());
262 264
263 match database::apply_spin( 265 match database::apply_play(
264 &mut self.conn, 266 &mut self.conn,
265 &session_id, 267 &session_id,
266 user, 268 user,
267 game_type, 269 game_type,
268 bet_amount, 270 bet_amount,
269 winnings, 271 winnings,
270 &reels, 272 &details,
271 &win_type,
272 ) { 273 ) {
273 Ok(new_balance) => { 274 Ok(new_balance) => {
274 let _ = self.sessions.remove_session(&session_id); 275 let _ = self.sessions.remove_session(&session_id);
diff --git a/src/server/database.rs b/src/server/database.rs
index f6c8872..2a1fc40 100644
--- a/src/server/database.rs
+++ b/src/server/database.rs
@@ -24,9 +24,8 @@ pub fn init_db(conn: &Connection) {
24 game_type TEXT NOT NULL, 24 game_type TEXT NOT NULL,
25 bet_amount INTEGER NOT NULL, 25 bet_amount INTEGER NOT NULL,
26 winnings INTEGER NOT NULL, 26 winnings INTEGER NOT NULL,
27 reels TEXT NOT NULL DEFAULT '',
28 win_type TEXT NOT NULL DEFAULT '',
29 balance_after INTEGER NOT NULL, 27 balance_after INTEGER NOT NULL,
28 details TEXT NOT NULL DEFAULT '{}',
30 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 29 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
31 )", 30 )",
32 [], 31 [],
@@ -111,20 +110,22 @@ pub fn get_balance(conn: &Connection, username: &str) -> Option<i64> {
111 } 110 }
112} 111}
113 112
114/// Settle one spin and record its audit row in a single transaction. 113/// Settle one play and record its audit row in a single transaction.
115/// 114///
116/// Returns the balance after the spin. All SQL is parameterized: usernames 115/// `details` is a JSON object with game-specific fields (reels, cards,
117/// are data, never part of the statement text. 116/// winning number, crash point, ...). All SQL is parameterized: usernames are
117/// data, never part of the statement text.
118///
119/// Returns the balance after the play.
118#[allow(clippy::too_many_arguments)] 120#[allow(clippy::too_many_arguments)]
119pub fn apply_spin( 121pub fn apply_play(
120 conn: &mut Connection, 122 conn: &mut Connection,
121 session_id: &str, 123 session_id: &str,
122 username: &str, 124 username: &str,
123 game_type: &str, 125 game_type: &str,
124 bet_amount: i64, 126 bet_amount: i64,
125 winnings: i64, 127 winnings: i64,
126 reels: &str, 128 details: &str,
127 win_type: &str,
128) -> rusqlite::Result<i64> { 129) -> rusqlite::Result<i64> {
129 let tx = conn.transaction()?; 130 let tx = conn.transaction()?;
130 131
@@ -150,18 +151,17 @@ pub fn apply_spin(
150 151
151 tx.execute( 152 tx.execute(
152 "INSERT INTO plays 153 "INSERT INTO plays
153 (session_id, username, game_type, bet_amount, winnings, reels, 154 (session_id, username, game_type, bet_amount, winnings,
154 win_type, balance_after) 155 balance_after, details)
155 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", 156 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
156 params![ 157 params![
157 session_id, 158 session_id,
158 username, 159 username,
159 game_type, 160 game_type,
160 bet_amount, 161 bet_amount,
161 winnings, 162 winnings,
162 reels, 163 balance_after,
163 win_type, 164 details
164 balance_after
165 ], 165 ],
166 )?; 166 )?;
167 167
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 3f5dc9a..cab74a7 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -137,6 +137,33 @@ fn a_malicious_username_cannot_change_another_balance() {
137} 137}
138 138
139#[test] 139#[test]
140fn plays_record_the_game_type_and_game_specific_details() {
141 let (mut core, path) = setup("details", WIN3);
142 let mut state = ConnectionState::default();
143 register(&mut core, &mut state, "alice");
144 login(&mut core, &mut state, "alice");
145
146 let response = core.handle_message(&mut state, "PLAY_START slots 10");
147 assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}");
148
149 let conn = Connection::open(&path).unwrap();
150 let (game_type, details): (String, String) = conn
151 .query_row(
152 "SELECT game_type, details FROM plays WHERE username = 'alice'",
153 [],
154 |row| Ok((row.get(0)?, row.get(1)?)),
155 )
156 .unwrap();
157
158 assert_eq!(game_type, "slots");
159 let details: serde_json::Value = serde_json::from_str(&details).unwrap();
160 assert_eq!(details["reels"], "CCCCLLCLODGSDGS");
161 assert_eq!(details["win_type"], "win");
162 assert_eq!(details["win_lines"], "1");
163 assert_eq!(details["win_cells"], "0,3,6");
164}
165
166#[test]
140fn a_failed_audit_insert_rolls_back_the_balance_change() { 167fn a_failed_audit_insert_rolls_back_the_balance_change() {
141 let (mut core, path) = setup("rollback", LOSS); 168 let (mut core, path) = setup("rollback", LOSS);
142 let mut state = ConnectionState::default(); 169 let mut state = ConnectionState::default();