1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
|
# 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 <id> [idem=<token>] <command>`, `RES <id> <body>` and
`EVT <seq> <body>`. 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.git) 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 <version>` 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`).
|