# Project Conventions for zagan-bet ## Type Safety - SQLite `INTEGER` is `i64`. Never cast to `u64` without a bounds check. - Balance, bet, and winnings fields must agree on `i64` or `u64` across the entire codebase — pick one and enforce it. - Negative balance is a state the DB can represent; the application must reject plays that would produce one, not silently wrap via `as u64`. ## Game Logic - Never compute the same payout/result in two places. `process_action` and `calculate_result` must not both independently derive winnings. The canonical split: `process_action` mutates state and stores raw data (reels, win_type); `calculate_result` reads that data and produces the `GameResult`. One path, one formula. - When checking symmetry (e.g. two-of-a-kind), enumerate **all** pairs, not just adjacent indices. `[a, b, a]` is a valid match. ## Session Tracking - Every `PLAY_START` must produce a session entry before the balance is touched. The session is the audit trail for the deduction; without it the deduction is orphaned. - Multi-step games (blackjack, crash) reserve with `database::reserve_play`: the debit and the `pending_plays` row are one transaction. Settlement deletes that row and writes the `plays` row in the same transaction. - Sweep orphaned reservations only at startup (`recover_orphaned_reservations`), never per connection. A per-connection sweep would settle another player's live hand. An orphaned reservation is a forfeit, never a refund. - Clean up sessions on client disconnect. If a game is in progress when the connection drops, settle it as a forfeit (the reserved bet is lost) and record the play; never refund an active hand. ## Server Handler - Never hold a lock, drop it, then re-acquire it in the same function to mutate the same resource. Acquire once, scope the lock to the operation. - All balance mutations (`SET balance = balance ± ?`) must happen in a transaction. ## Crash - The client draws the multiplier from its own clock, corrected by the server time in `HELLO`/`PONG`, for display only. A cashout carries a session id and nothing else; the multiplier and payout come from the server's clock at receipt. - Never send the crash point to the client before the round ends. A client that knows it can cash out at `crash - epsilon` every round for a guaranteed win. - The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of rounds that crash at 1.00x. That is where the house edge is charged on an instant cashout, and it is why no cashout lockout is needed. - Each round draws a 32-byte seed. `commit = sha256(seed)` goes out with `CRASH_START`; the seed itself is revealed only when the round ends. The crash point is `crash_point_from_seed(seed)` (first eight bytes, big-endian, over 2^64), so a client can recompute it. Never reveal the seed while the round is live. The guarantee is "fixed before you acted", not "ungrindable"; see ADR 0004. - The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. Client code must drain the socket on a timer and route pushed lines by prefix instead of assuming the next line answers the last request. - `ServerCore::poll` must be idempotent: it settles the round, removes the session, and pushes once. A second poll must produce nothing. ## Protocol v5 - Every packet is `[u32 big-endian length][UTF-8 payload]`, at most 64 KiB. Never read a declared length above the limit into memory; close instead. - Payloads are `REQ [idem=] `, `RES ` and `EVT `. The command/body text grammar is unchanged. - `ClientCore` allocates request ids. Adapters must return a reply with `handle_reply_id`; a reply with an unknown id is ignored, never applied to whatever request happens to be pending. - The server caches the last 256 request ids **per connection** and replays a matching reply. A reused id with a different payload is an error. Do not make this durable without a new ADR. - `HELLO` and `PONG` carry Unix milliseconds. The offset is for display and interpolation; the server settles crash. Never derive a payout from a browser or gateway clock. - While a crash round runs, `poll` pushes at most one `crash.tick` per 100 ms per session. Settlement (`CRASH_BUSTED`) is never dropped or replaced by a tick. - A wire change is a MAJOR bump. Do not add a compatibility mode for v4. ## Web gateway (`webd/`) - The browser is a frontend, not a client. It sends `Command` JSON and renders `View` JSON. It must never parse the game wire, compute a payout, or hold a credential. - `webd/` is a separate workspace member. Its dependencies (tungstenite, serde_json, cookie handling) must not enter the TUI, GUI or server build graph. - One session thread owns one `ClientCore` and one upstream TLS connection. No async runtime, no browser TOFU: `ZAGAN_UPSTREAM_FINGERPRINT` is required and a mismatch refuses to connect. - The session cookie is HttpOnly, SameSite=Strict, and Secure on HTTPS. Never store a password or credential in the browser or gateway. Session-only auth; no remember-me without a new ADR. - Close is not forfeit: the gateway holds upstream for `ZAGAN_GRACE_SECONDS` (default 60). Expiry closes upstream, which is the server's forfeit. A new socket with the same cookie takes over; tell the old tab it was replaced. - Bind to loopback and let nginx terminate TLS. Check `Origin` against `ZAGAN_WEB_ORIGIN`. `/ws` is the only gateway route; `/play/` is static. - A gateway restart loses live sessions and forfeits their rounds. Do not add protocol-level resume here without an ADR. ## Web app (`web/play/`) - Vanilla TypeScript + esbuild. No framework runtime, no game rules, no wire protocol. DOM for the shell, canvas for the game surface. - Colours and spacing come from `web/play/style.css`, which mirrors `gui/src/theme.rs`. Felt green is only ever the roulette table. - Build with `cd web/play && npm ci && npm run build`; commit the generated `app.js` so the site needs no Node at deploy time. - `/play/` is the app; `/` stays the download page. Web v1 is play-money only: no deposits, withdrawals or KYC copy. - The browser cookie covers refresh and the grace window only. A closed tab that returns later logs in again. ## GUI - The GUI is a second frontend over `ClientCore`, not a second client. Frontends send `Command` and render `View`; the core owns navigation and state and contains no I/O or terminal types. - The command-line editor and key mapping live in `client::input`. The core receives `Command` or a finished line through `submit_line`, never a key. - egui lives in the `gui/` workspace member. Its dependencies must never enter the TUI or server build graph: no GUI crate in the root package's `[dependencies]`. The GUI may depend on the core, never the reverse. - Build the GUI with `cargo build -p zagan-bet-gui --profile gui-release`; the profile lives at the workspace root because a member's is ignored. - Colours, spacing and font sizes come from `gui/src/theme.rs`; screens must not hard-code them. The embedded fonts are Open Sans (OFL) and Hack (MIT); ship their licence files with any change. - GUI integration tests live in `gui/tests/`, because the root package cannot depend on the GUI. - First contact shows the fingerprint and waits for a decision before sending any credentials. A changed key is a hard stop; forgetting it is explicit. - The GUI is themed deliberately. Default egui styling is not an acceptable look; keep a style module and a reference screen. The palette is derived from the terminal's ANSI colours, and felt green is only ever the roulette table. - The GUI felt is pointer-first: the hover snaps to the shared fine lattice (cell centre, edge, crossing), left click adds the chip and right or middle click takes one back. The terminal keeps its arrow-key cursor; the GUI does not paint it. - The GUI network worker (`gui/src/net.rs`) owns the TLS stream and the request deadline; `ClientCore` stays I/O-free. One thread with channels, no async runtime. - Game sidebars pin their action block with `widgets::sidebar_layout`. An action that moves as the bet slip grows is a bug. - The embedded fonts have no suit or arrow glyphs. Draw card suits as shapes and use plain words for navigation; a glyph that is not in Open Sans or Hack renders as tofu. - `scripts/publish.sh` builds the GUI with `gui-release`; the installers are stable site files (`web/install.sh`, `web/install.ps1`) that read `web/latest.php` for the newest build and its SHA-256. Never regenerate or version the installers per release, install system-wide, or skip the hash check. The Windows installer waits for Enter before closing; `-NoPause` is only for automation. - Windows renders through wgpu on Direct3D 12, including the WARP software adapter, so a machine with only OpenGL 1.1 (VM, Remote Desktop) still runs. Linux keeps the smaller glow renderer. Do not assume OpenGL 2.0 on Windows. - The download page links the public git repository (https://git.2137697.xyz/zagan-bet) for the GPL corresponding source. Run publish from a clean tree so the linked history matches the binaries. ## Client TUI - `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, count lines against the same string the cursor will erase. Prefer returning a fixed known count for the game's static layout and letting dynamic content fill it, rather than inferring the count. - The client refuses to start below 80x24 (`terminal_too_small`). Every game board must fit in that size; a new board wider or taller than the minimum must raise the minimum in the same change. - A resize below the minimum warns, but never during a live round (`ClientCore::is_live_game`): a reserved bet must be finishable. ## Schema - The app is in alpha: do not write schema migrations. `init_db` creates the current shape; a schema change means deleting `server.db` and `client.db`. Files written by an older build are not supported. ## Error Paths - When a game action fails after a balance deduction, the refund must happen in the same transaction scope as the deduction, not as a separate `execute()` call. - Never silently ignore `Err` on balance mutations. If the refund fails, log the username, balance, and operation; surface to the operator. ## Tests - Every `calculate_payout` / `calculate_result` edge case gets a unit test in `tests/`. Tests that exercise game logic use a deterministic seed or a test helper that calls the function directly, not the network. - A change to game logic that breaks an existing payout pattern must fail a test before it reaches the server. ## TLS / TOFU - See `src/tofu.rs` for the trust model. The fingerprint is over the certificate's SPKI, not the whole certificate. - Credentials are keyed by the SPKI fingerprint, not by address: one login covers every address that presents the same key. Never look credentials up by host. - `forget_host` removes one address. Drop the credential only when no address with that fingerprint remains, or two addresses for one server would stop sharing a login. `forget-login` is the narrower action: it drops the credential and keeps the pinned key. - `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... DO UPDATE SET last_used` — never `INSERT OR REPLACE`. - The client transport lives in `client::transport`. It records the key during the handshake, then asks the frontend (`TrustPrompt`) before sending `HELLO` or credentials. A changed key is a hard error, and forgetting it is explicit. ## Versioning Use semantic versioning (`MAJOR.MINOR.PATCH`) in `Cargo.toml`. - **MAJOR** — breaking protocol changes (wire format, TLS trust model, database schema migration that can't auto-upgrade). - **MINOR** — new features, new game types, new commands, or any change to shared semantics. - **PATCH** — bug fixes, payout corrections, refactors that don't change observable behavior. Client and server are **MAJOR.MINOR lockstep**: they may differ only in the patch component. `HELLO ` is the first line after TLS; the server refuses a mismatch before any credentials are sent. A feature that changes the wire format is a MAJOR bump and a simultaneous release of both sides, not a compatible MINOR. Bump the version in `Cargo.toml` as the **last** step before committing a release-worthy change. The commit message should contain the new version number (e.g. `v0.2.0`).