From 3b50928cb083b104043ac65433ed090c87731eb7 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 17:02:49 +0100 Subject: v2.0.0: reset plays audit to a generic per-game schema --- Cargo.toml | 3 +- docs/GAMES.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/SERVER_SETUP.md | 16 +++++++++++ docs/TESTING.md | 13 +++++++++ src/server/core.rs | 7 +++-- src/server/database.rs | 28 +++++++++---------- tests/server_core.rs | 27 ++++++++++++++++++ 7 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 docs/GAMES.md diff --git a/Cargo.toml b/Cargo.toml index f9e56f9..6b32e67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "1.3.1" +version = "2.0.0" edition = "2024" [dependencies] @@ -12,6 +12,7 @@ parking_lot = "0.12" sha2 = "0.10" ctrlc = "3.4" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" uuid = { version = "1.0", features = ["v4"] } native-tls = "0.2" rustls = { 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 @@ +# Games and the play audit + +## Implemented + +| Game | Status | Notes | +| --- | --- | --- | +| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP | + +## Planned + +| Game | Shape | Main open decisions | +| --- | --- | --- | +| Roulette | One spin, many bets | European/French/American wheel; which bet types; 35:1 straight and other payouts | +| Blackjack | Multi-step hand | Dealer hits/stands soft 17; double/split/insurance; blackjack payout; shoe persistence | +| Crash | Real-time round | Crash-point distribution; cashout latency; provable fairness | + +Roulette fits the current one-request-settle model most easily. Blackjack needs +server-side hand state and player actions. Crash needs a timed round loop and a +cashout action, so it should come last unless we redesign the loop first. + +## Audit schema + +Every settled play is one row in `plays`: + +``` +plays ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + game_type TEXT NOT NULL, + bet_amount INTEGER NOT NULL, + winnings INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + details TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) +``` + +`game_type` is the game that was played. `details` is a JSON object whose shape +depends on the game: + +```json +// slots +{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"} + +// rumoured future examples +{"player":["AS","10H"],"dealer":["9D","7C"],"outcome":"blackjack"} +{"bets":[{"kind":"straight","number":17,"amount":10}],"winning":17} +{"crash_point":2.41,"cashout":1.80} +``` + +The balance updates and the `plays` insert happen in one transaction +(`database::apply_play`). The slots-specific wire fields (`reels`, `win_lines`, +`win_cells`) are still in `GAME_RESULT`, but the database no longer assumes +them. + +## Protocol gap + +The current exchange is slots-shaped: + +``` +PLAY_START +GAME_RESULT +``` + +Before blackjack, roulette, or crash can be added, the game session needs a +generic state/action protocol. A possible shape: + +``` +GAME_START +GAME_ACTION [json] +GAME_STATE # cards, bets, multiplier, legal actions, result +``` + +That 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 regenerate the server identity, the client will reject the new key; run `forget-host` in the client if the change is intentional. +## Database + +The server creates `server.db` in the current directory on first use. It holds +users, balances, and the per-game `plays` audit. The client keeps its own +`client.db` for host fingerprints and stored credentials. + +During development, delete both files to reset users, balances, history, and +trust: + +```sh +rm -f server.db client.db +``` + +The `plays` schema is not auto-migrated from the old slots-only shape; a stale +database must be removed. + ## Windows `--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. `tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them on a machine where the `openssl` CLI is available. +## Development resets + +The server creates `server.db` in its working directory; the client creates +`client.db` for host trust and stored credentials. While the software is in +testing, delete both after a schema or trust change: + +```sh +rm -f server.db client.db +``` + +The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` +table. Starting against an old database will fail until it is removed. + ## Manual TUI checklist There 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 { .get("win_cells") .cloned() .unwrap_or_else(|| "-".to_string()); + let details = + serde_json::to_string(&result.result_data).unwrap_or_else(|_| "{}".to_string()); - match database::apply_spin( + match database::apply_play( &mut self.conn, &session_id, user, game_type, bet_amount, winnings, - &reels, - &win_type, + &details, ) { Ok(new_balance) => { 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) { game_type TEXT NOT NULL, bet_amount INTEGER NOT NULL, winnings INTEGER NOT NULL, - reels TEXT NOT NULL DEFAULT '', - win_type TEXT NOT NULL DEFAULT '', balance_after INTEGER NOT NULL, + details TEXT NOT NULL DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )", [], @@ -111,20 +110,22 @@ pub fn get_balance(conn: &Connection, username: &str) -> Option { } } -/// Settle one spin and record its audit row in a single transaction. +/// Settle one play and record its audit row in a single transaction. /// -/// Returns the balance after the spin. All SQL is parameterized: usernames -/// are data, never part of the statement text. +/// `details` is a JSON object with game-specific fields (reels, cards, +/// winning number, crash point, ...). All SQL is parameterized: usernames are +/// data, never part of the statement text. +/// +/// Returns the balance after the play. #[allow(clippy::too_many_arguments)] -pub fn apply_spin( +pub fn apply_play( conn: &mut Connection, session_id: &str, username: &str, game_type: &str, bet_amount: i64, winnings: i64, - reels: &str, - win_type: &str, + details: &str, ) -> rusqlite::Result { let tx = conn.transaction()?; @@ -150,18 +151,17 @@ pub fn apply_spin( tx.execute( "INSERT INTO plays - (session_id, username, game_type, bet_amount, winnings, reels, - win_type, balance_after) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + (session_id, username, game_type, bet_amount, winnings, + balance_after, details) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ session_id, username, game_type, bet_amount, winnings, - reels, - win_type, - balance_after + balance_after, + details ], )?; 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 @@ -136,6 +136,33 @@ fn a_malicious_username_cannot_change_another_balance() { assert_eq!(core.play_count(evil), 1); } +#[test] +fn plays_record_the_game_type_and_game_specific_details() { + let (mut core, path) = setup("details", WIN3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let response = core.handle_message(&mut state, "PLAY_START slots 10"); + assert!(response.starts_with("GAME_RESULT"), "spin failed: {response}"); + + let conn = Connection::open(&path).unwrap(); + let (game_type, details): (String, String) = conn + .query_row( + "SELECT game_type, details FROM plays WHERE username = 'alice'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + assert_eq!(game_type, "slots"); + let details: serde_json::Value = serde_json::from_str(&details).unwrap(); + assert_eq!(details["reels"], "CCCCLLCLODGSDGS"); + assert_eq!(details["win_type"], "win"); + assert_eq!(details["win_lines"], "1"); + assert_eq!(details["win_cells"], "0,3,6"); +} + #[test] fn a_failed_audit_insert_rolls_back_the_balance_change() { let (mut core, path) = setup("rollback", LOSS); -- cgit v1.2.3