summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore3
-rw-r--r--AGENTS.md65
-rw-r--r--CONTEXT.md76
-rw-r--r--Cargo.toml4
-rw-r--r--docs/GUI-PLAN.md5
-rw-r--r--docs/PROTOCOL-IDEAS.md170
-rw-r--r--docs/TESTING.md52
-rw-r--r--docs/WEB-PLAN.md129
-rw-r--r--docs/adr/0001-gui-frontend-architecture.md3
-rw-r--r--docs/adr/0003-web-gateway.md85
-rw-r--r--docs/adr/0004-protocol-v5.md119
-rw-r--r--gui/Cargo.toml2
-rw-r--r--gui/src/app.rs19
-rw-r--r--gui/src/net.rs17
-rwxr-xr-xscripts/deploy.sh13
-rw-r--r--src/bin/client/connection.rs6
-rw-r--r--src/bin/client/repl.rs95
-rw-r--r--src/bin/server/handler.rs47
-rw-r--r--src/bin/server/tls.rs27
-rw-r--r--src/client/core.rs182
-rw-r--r--src/client/games/manager.rs21
-rw-r--r--src/client/games/roulette.rs9
-rw-r--r--src/client/transport.rs171
-rw-r--r--src/client/view.rs60
-rw-r--r--src/crash.rs54
-rw-r--r--src/protocol.rs226
-rw-r--r--src/server/core.rs81
-rw-r--r--src/server/games/crash.rs76
-rw-r--r--src/version.rs34
-rw-r--r--tests/client_core.rs151
-rw-r--r--tests/crash.rs39
-rw-r--r--tests/protocol.rs150
-rw-r--r--tests/server_core.rs121
-rw-r--r--tests/version.rs57
-rw-r--r--tests/web_types.rs138
-rw-r--r--web/README.md27
-rw-r--r--web/index.php1
-rw-r--r--web/nginx.conf.example25
-rw-r--r--web/play/.gitignore2
-rw-r--r--web/play/README.md56
-rw-r--r--web/play/app.js1
-rw-r--r--web/play/build.mjs22
-rw-r--r--web/play/index.html112
-rw-r--r--web/play/package-lock.json1031
-rw-r--r--web/play/package.json20
-rw-r--r--web/play/src/blackjack.ts152
-rw-r--r--web/play/src/crash.ts365
-rw-r--r--web/play/src/dom.ts104
-rw-r--r--web/play/src/history.ts60
-rw-r--r--web/play/src/main.ts323
-rw-r--r--web/play/src/net.ts120
-rw-r--r--web/play/src/roulette.ts474
-rw-r--r--web/play/src/slots.ts144
-rw-r--r--web/play/src/types.ts27
-rw-r--r--web/play/style.css407
-rw-r--r--web/play/test/smoke.mjs294
-rw-r--r--web/play/tsconfig.json13
-rw-r--r--webd/Cargo.toml13
-rw-r--r--webd/README.md118
-rw-r--r--webd/src/config.rs95
-rw-r--r--webd/src/main.rs61
-rw-r--r--webd/src/session.rs623
62 files changed, 6783 insertions, 414 deletions
diff --git a/.gitignore b/.gitignore
index a140ec2..f1140f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
5*.pem 5*.pem
6*.key 6*.key
7/text 7/text
8
9# The browser build pins its dependency tree.
10!web/play/package-lock.json
diff --git a/AGENTS.md b/AGENTS.md
index ab794f8..3827957 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,20 +37,79 @@
37 transaction. 37 transaction.
38 38
39## Crash 39## Crash
40- The client draws the multiplier from its own clock for display only. A 40- The client draws the multiplier from its own clock, corrected by the server
41 cashout carries a session id and nothing else; the multiplier and payout come 41 time in `HELLO`/`PONG`, for display only. A cashout carries a session id and
42 from the server's clock at receipt. 42 nothing else; the multiplier and payout come from the server's clock at
43 receipt.
43- Never send the crash point to the client before the round ends. A client that 44- Never send the crash point to the client before the round ends. A client that
44 knows it can cash out at `crash - epsilon` every round for a guaranteed win. 45 knows it can cash out at `crash - epsilon` every round for a guaranteed win.
45- The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of 46- The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of
46 rounds that crash at 1.00x. That is where the house edge is charged on an 47 rounds that crash at 1.00x. That is where the house edge is charged on an
47 instant cashout, and it is why no cashout lockout is needed. 48 instant cashout, and it is why no cashout lockout is needed.
49- Each round draws a 32-byte seed. `commit = sha256(seed)` goes out with
50 `CRASH_START`; the seed itself is revealed only when the round ends. The
51 crash point is `crash_point_from_seed(seed)` (first eight bytes, big-endian,
52 over 2^64), so a client can recompute it. Never reveal the seed while the
53 round is live. The guarantee is "fixed before you acted", not "ungrindable";
54 see ADR 0004.
48- The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. 55- The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply.
49 Client code must drain the socket on a timer and route pushed lines by 56 Client code must drain the socket on a timer and route pushed lines by
50 prefix instead of assuming the next line answers the last request. 57 prefix instead of assuming the next line answers the last request.
51- `ServerCore::poll` must be idempotent: it settles the round, removes the 58- `ServerCore::poll` must be idempotent: it settles the round, removes the
52 session, and pushes once. A second poll must produce nothing. 59 session, and pushes once. A second poll must produce nothing.
53 60
61## Protocol v5
62- Every packet is `[u32 big-endian length][UTF-8 payload]`, at most 64 KiB.
63 Never read a declared length above the limit into memory; close instead.
64- Payloads are `REQ <id> [idem=<token>] <command>`, `RES <id> <body>` and
65 `EVT <seq> <body>`. The command/body text grammar is unchanged.
66- `ClientCore` allocates request ids. Adapters must return a reply with
67 `handle_reply_id`; a reply with an unknown id is ignored, never applied to
68 whatever request happens to be pending.
69- The server caches the last 256 request ids **per connection** and replays a
70 matching reply. A reused id with a different payload is an error. Do not make
71 this durable without a new ADR.
72- `HELLO` and `PONG` carry Unix milliseconds. The offset is for display and
73 interpolation; the server settles crash. Never derive a payout from a
74 browser or gateway clock.
75- While a crash round runs, `poll` pushes at most one `crash.tick` per 100 ms
76 per session. Settlement (`CRASH_BUSTED`) is never dropped or replaced by a
77 tick.
78- A wire change is a MAJOR bump. Do not add a compatibility mode for v4.
79
80## Web gateway (`webd/`)
81- The browser is a frontend, not a client. It sends `Command` JSON and renders
82 `View` JSON. It must never parse the game wire, compute a payout, or hold a
83 credential.
84- `webd/` is a separate workspace member. Its dependencies (tungstenite,
85 serde_json, cookie handling) must not enter the TUI, GUI or server build
86 graph.
87- One session thread owns one `ClientCore` and one upstream TLS connection.
88 No async runtime, no browser TOFU: `ZAGAN_UPSTREAM_FINGERPRINT` is required
89 and a mismatch refuses to connect.
90- The session cookie is HttpOnly, SameSite=Strict, and Secure on HTTPS. Never
91 store a password or credential in the browser or gateway. Session-only auth;
92 no remember-me without a new ADR.
93- Close is not forfeit: the gateway holds upstream for `ZAGAN_GRACE_SECONDS`
94 (default 60). Expiry closes upstream, which is the server's forfeit. A new
95 socket with the same cookie takes over; tell the old tab it was replaced.
96- Bind to loopback and let nginx terminate TLS. Check `Origin` against
97 `ZAGAN_WEB_ORIGIN`. `/ws` is the only gateway route; `/play/` is static.
98- A gateway restart loses live sessions and forfeits their rounds. Do not add
99 protocol-level resume here without an ADR.
100
101## Web app (`web/play/`)
102- Vanilla TypeScript + esbuild. No framework runtime, no game rules, no wire
103 protocol. DOM for the shell, canvas for the game surface.
104- Colours and spacing come from `web/play/style.css`, which mirrors
105 `gui/src/theme.rs`. Felt green is only ever the roulette table.
106- Build with `cd web/play && npm ci && npm run build`; commit the generated
107 `app.js` so the site needs no Node at deploy time.
108- `/play/` is the app; `/` stays the download page. Web v1 is play-money only:
109 no deposits, withdrawals or KYC copy.
110- The browser cookie covers refresh and the grace window only. A closed tab
111 that returns later logs in again.
112
54## GUI 113## GUI
55- The GUI is a second frontend over `ClientCore`, not a second client. 114- The GUI is a second frontend over `ClientCore`, not a second client.
56 Frontends send `Command` and render `View`; the core owns navigation and 115 Frontends send `Command` and render `View`; the core owns navigation and
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..6fefd53
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,76 @@
1# Context
2
3zagan-bet is a small play-money casino: one Rust workspace with a shared
4library, a terminal client, a server, a desktop GUI and a browser frontend. This file names the things the code and the docs talk about. Keep it
5current when a term changes meaning.
6
7## Product terms
8
9- **Play credit** — the unit a player wins and loses. Registration grants a
10 starting balance. There are no deposits, withdrawals, cash-outs or KYC; the
11 web app is a game, not a regulated gambling service.
12- **Server** — the authoritative process. It owns the database, the game
13 rules, the balances, the random draws and the clock used to settle crash.
14- **Frontend** — a presentation adapter over `ClientCore`: the terminal client,
15 the egui desktop app, or the browser app behind the web gateway. Frontends
16 send `Command` values and render `View` values. They never compute payouts.
17- **ClientCore** — the I/O-free state machine shared by every frontend. It owns
18 navigation, the current game view, the pending request, and the local clock
19 anchor for crash. It contains no terminal, network or egui types.
20- **Gateway (`webd`)** — a server-side frontend adapter for the browser. It owns
21 one upstream TLS connection and one `ClientCore` per browser session, and
22 translates browser JSON into `Command`/`View`. The browser never speaks the
23 game wire protocol.
24- **Upstream** — the game server the gateway connects to. Its address and SPKI
25 fingerprint are operator configuration. The browser never sees a certificate.
26- **Session** — one browser login held by the gateway. Identified by an
27 HttpOnly cookie. A refresh or a new tab reattaches to the same session during
28 the grace window; a later visit starts a new login.
29
30## Wire terms
31
32- **Frame** — `[u32 length][UTF-8 payload]`, length big-endian, at most 64 KiB.
33 The frame is the unit of transport; the payload is text.
34- **Envelope** — the first token of a frame payload:
35 - `REQ <id> [idem=<token>] <command>` — client request.
36 - `RES <id> <body>` — the reply to that request.
37 - `EVT <seq> <body>` — a server-initiated event, numbered per connection.
38- **Request id** — a client-chosen `u64`, unique on the connection while its
39 reply is cached. It correlates a reply with the request that caused it.
40- **Idempotency key** — an optional opaque token on a request. The connection
41 caches the last 256 request ids; resending the same id and payload replays the
42 stored reply instead of performing the action twice.
43- **Event sequence** — a server-chosen `u64`, incremented once per `EVT` on a
44 connection. Clients use it to drop duplicates and to notice gaps.
45- **HELLO** — the first request after TLS. Client and server must share
46 `MAJOR.MINOR`; the reply carries the server version, download URL and the
47 server's Unix time in milliseconds.
48- **Tick** — `EVT crash.tick session=<id> t=<server_ms> m=<multiplier>`, pushed
49 at most every 100 ms while a crash round runs. The server clock still settles
50 a cash-out.
51- **Commit** — `sha256(seed)` in hex, sent with `CRASH_START` before the player
52 can act. **Reveal** — the seed itself, sent when the round ends. The crash
53 point is a pure function of the seed, so a player can check both.
54
55## Deployment terms
56
57- **Download site** — `web/`: `index.php` lists builds, `latest.php` feeds the
58 stable installers, `install.sh`/`install.ps1` fetch and verify the newest app.
59- **Play app** — the static browser bundle under `/play/`, built from
60 `web/play/` with esbuild and served by nginx.
61- **Grace window** — the 60 seconds the gateway keeps an upstream connection
62 after the last browser socket closes. Reattaching within it preserves a live
63 round; expiry closes upstream and the server forfeits per its existing rule.
64- **Takeover** — a new browser connection presenting an existing session cookie
65 replaces the old socket; the old tab is told it was replaced.
66
67## Invariants
68
69- One payout formula, one place. `process_action` stores raw data;
70 `calculate_result` derives the result. Frontends only render.
71- Every balance mutation is transactional, and an audit row exists before the
72 balance moves.
73- A crash point never reaches a client before the round ends.
74- The server clock, not a browser or gateway clock, settles cash-out.
75- `MAJOR.MINOR` is lockstep between client and server; only the patch component
76 may differ. A wire change is a MAJOR bump.
diff --git a/Cargo.toml b/Cargo.toml
index 16ed8c6..0a4aa76 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,9 @@
1[workspace] 1[workspace]
2members = ["gui"] 2members = ["gui", "webd"]
3 3
4[package] 4[package]
5name = "zagan-bet" 5name = "zagan-bet"
6version = "4.4.0" 6version = "5.0.0"
7edition = "2024" 7edition = "2024"
8license = "GPL-3.0-or-later" 8license = "GPL-3.0-or-later"
9 9
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md
index c0370a0..95ec922 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -5,7 +5,10 @@ Decisions and rationale live in `docs/adr/0001-gui-frontend-architecture.md` and
5 5
6## Non-goals 6## Non-goals
7 7
8Audio, 3D, macOS, auto-update, a browser client, and pixel-golden tests in CI. 8Audio, 3D, macOS, auto-update, a webview inside the desktop app, and
9pixel-golden tests in CI. A separate browser frontend is no longer a non-goal:
10it shipped in v5.0.0 through the `webd/` gateway, planned in
11`docs/WEB-PLAN.md`.
9 12
10## Phase 0 — version handshake (v4.0.0) 13## Phase 0 — version handshake (v4.0.0)
11 14
diff --git a/docs/PROTOCOL-IDEAS.md b/docs/PROTOCOL-IDEAS.md
new file mode 100644
index 0000000..3991a84
--- /dev/null
+++ b/docs/PROTOCOL-IDEAS.md
@@ -0,0 +1,170 @@
1# Server ↔ client transport ideas
2
3Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the
4differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids,
5per-connection idempotency, clock sync + ticks, commit–reveal crash, and
6length-prefixed framing. Items 6–7 remain future work.
7
8These came out of reviewing zagan-bet's transport, but they were prompted by a
9*different* app. Nothing here is a backlog item for this repository. The
10zagan-bet paths are included because that is where the patterns were worked
11out; treat the ideas as portable.
12
13zagan-bet's current shape, for context:
14
15- TLS with TOFU over the certificate's SPKI fingerprint, trust decision after
16 the handshake and before credentials (`src/client/transport.rs`,
17 `src/tofu.rs`).
18- Newline-delimited text lines over the TLS stream (`src/protocol.rs`).
19- `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`).
20- Request/reply plus exactly one server-initiated line, `CRASH_BUSTED`
21 (`src/bin/server/handler.rs`, `ServerCore::poll`).
22- `ClientCore` is I/O-free; frontends send `Command` and render `View`
23 (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`.
24
25Each idea below lists the wire shape, where it would attach, and the cost.
26
27## 1. Correlation IDs, so a single `Pending` can go away
28
29One request in flight, and pushes distinguished by string prefix
30(`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can
31race a pushed bust; the code already has a special case for it
32(`src/client/core.rs:211`). An envelope removes the whole class:
33
34```
35REQ 42 idem=f3a1... INFO
36RES 42 OK balance=1200 account=...
37EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42}
38```
39
40`REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers
41anything. The core keeps a map of in-flight requests instead of one `Option`,
42and "a stale reply must not clobber the result" becomes a generic rule rather
43than a crash-specific branch. Also allows pipelining, e.g. clock probes while a
44`HISTORY` is in flight, without app-layer head-of-line stalls. Keep line
45framing for now; just prepend tokens.
46
47## 2. Idempotency keys on every balance mutation
48
49A request can be applied server-side and the reply lost. Today that is safe
50only because nothing retries automatically; the moment a client retries or
51resumes, it can double-debit.
52
53```
54REQ 43 idem=9c2e... PLAY_START crash 50
55```
56
57Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The
58replay check, the debit, and the row insert sit in the same transaction as the
59existing reservation (`database::reserve_play`). A duplicate returns the stored
60reply and never re-executes. TTL-sweep at startup next to
61`recover_orphaned_reservations`. Scope by user, not connection, so a resumed
62session cannot replay someone else's key.
63
64## 3. Clock sync and a real tick stream
65
66zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`,
67`src/bin/server/handler.rs:7`) and the client draws crash locally from an
68`Instant`. Payouts are correctly server-clock authoritative, but the client
69does not know its offset. Two parts:
70
71**Clock sync**, Cristian/NTP style over the existing connection:
72
73```
74REQ 7 idem=... TIME t0=837412
75RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms
76```
77
78`offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding
79window. Render timed games from `server_now`.
80
81**Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics:
82
83```
84EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44}
85```
86
87Coalesce in the writer: replace an unqueued tick, never drop a settlement.
88That also enables lag compensation — render at `server_now - presentation_delay`
89(e.g. 150 ms) so the displayed number never exceeds what the server accepts at
90the moment of a click. A cleaner fairness story than trusting the client clock.
91
92## 4. Commit–reveal instead of a hidden `Option<f64>`
93
94zagan-bet already withholds the crash point (`CrashState::crash_point`,
95`src/crash.rs`). Make it verifiable:
96
97```
98RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id
99...round runs...
100EVT seq=980 crash.busted {"seed":"<server_seed>","crash_point":2.37,...}
101```
102
103Player supplies a client seed; the server echoes it. Crash point derives from
104`HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing
105`crash_point_from_uniform`. Verification is a pure function and belongs in a
106unit test, not a network test. A hash chain lets the server pre-commit every
107future round at session start; that is stronger than needed in most cases.
108
109## 5. Framing: length prefix, then maybe QUIC
110
111Weaknesses in newline framing:
112
113- `LineBuffer` grows without bound; a client that never sends `\n` is a memory
114 DoS. Reject oversized frames *before* allocating.
115- `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only
116 safe while `serde_json` keeps escaping newlines — an implicit dependency.
117
118```
119[u32 LE len][u8 proto_version][u8 kind][payload]
120```
121
122This allows a move to `postcard`/CBOR later without touching the read loop.
123The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY`
124cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds
125mid-round, and unreliable datagrams for ticks where latest-wins beats
126retransmission. zagan-bet already uses rustls on the client, so this would also
127drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a
128rustls bump.
129
130## 6. Resume tokens and a blocking reader thread
131
132`AUTH` returns an opaque HMAC-signed resume token plus a session id;
133`RESUME <token> since=<seq>` replays missed `EVT`s from a bounded per-session
134buffer. Turns disconnect from "forfeit everything" into a policy choice; a
135server-clocked round is trivially resumable. In zagan-bet this contradicts the
136AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out
137and a grace window.
138
139Separately, both ends poll with timers: the server's 50 ms read timeout, and
140the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in
141`TlsStream::read_available_line`. A dedicated blocking reader thread feeding an
142`mpsc` removes the per-call timeout mutation, `expects_push`, and
143`POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick
144deadline, and shutdown. Still no async runtime.
145
146## 7. Small ones worth batching
147
148- `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of
149 strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing.
150- Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the
151 frontend can tell "reconnect" from "show text".
152- `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP
153 connection is otherwise noticed only on the next write.
154- Per-connection token-bucket rate limiting. Every inbound line is logged, so a
155 chatty client is also a log-flood/disk problem.
156- A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics
157 and `balance >= 0` for arbitrary input, plus transcript-replay tests.
158
159## If only three
160
1611. Correlation IDs + idempotency keys — small, kills two bug classes.
1622. Clock sync + coalesced ticks — turns a special-case push into a generic
163 event channel.
1643. Commit–reveal — cheap, and the strongest fairness story for a betting
165 server.
166
167The envelope plus idempotency is the least invasive pair: it touches
168`src/protocol.rs`, `ServerCore::handle_message`, and
169`ClientCore::handle_reply`, and can sit behind a `caps=` flag so version
170lockstep stays intact.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 09ceb82..fbd510b 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -210,9 +210,9 @@ terminal client; a saved login from one signs in the other.
210 210
2111. `play crash` shows the ready board with a 1.00x multiplier; Space starts and 2111. `play crash` shows the ready board with a 1.00x multiplier; Space starts and
212 reserves the bet. 212 reserves the bet.
2132. The multiplier rises smoothly with no network traffic. Cash out with C and 2132. The multiplier rises smoothly from the server-clock anchor and is refreshed
214 confirm the board shows the cashout multiplier, the payout and the new 214 by 10 Hz ticks. Cash out with C and confirm the board shows the cashout
215 balance. 215 multiplier, the payout and the new balance.
2163. On another round, press nothing. The board must switch to `CRASHED at Xx` 2163. On another round, press nothing. The board must switch to `CRASHED at Xx`
217 by itself when the crash point is reached, and the bet must be lost. 217 by itself when the crash point is reached, and the bet must be lost.
2184. Press C just as a round crashes. Either it pays or the crashed result wins; 2184. Press C just as a round crashes. Either it pays or the crashed result wins;
@@ -220,8 +220,11 @@ terminal client; a saved login from one signs in the other.
2205. Q during a live round forfeits the bet; Q when idle just leaves. 2205. Q during a live round forfeits the bet; Q when idle just leaves.
2216. Check the audit rows: one per round, `game_type = crash`, the reserved bet, 2216. Check the audit rows: one per round, `game_type = crash`, the reserved bet,
222 the payout, and `crash_point`/`multiplier`/`result` in `details`. 222 the payout, and `crash_point`/`multiplier`/`result` in `details`.
2237. Confirm the crash point is absent from the `CRASH_START` line (it must only 2237. Confirm the crash point and seed are absent from the `CRASH_START` message;
224 appear once the round is over). 224 only `commit` and `started_at` appear. When the round ends, `seed` appears
225 and `sha256(seed) == commit`; recompute the crash point from the seed.
2268. Confirm `CRASH_BUSTED` is an `EVT`, not a `RES`, and that a second poll
227 produces no repeated settlement.
225 228
226## Manual restart check 229## Manual restart check
227 230
@@ -237,7 +240,7 @@ terminal client; a saved login from one signs in the other.
237 240
2381. Connect a client and server from the same release: the client connects and 2411. Connect a client and server from the same release: the client connects and
239 the server log shows the `HELLO` exchange. 242 the server log shows the `HELLO` exchange.
2402. Connect a 4.x client to a 3.x server (or the reverse): the client must print 2432. Connect a 5.x client to a 4.x server (or the reverse): the client must print
241 `Version mismatch: client ..., server ...` and never reach the login prompt. 244 `Version mismatch: client ..., server ...` and never reach the login prompt.
2423. With a deliberately skew-built server (PATCH differs) the connection is 2453. With a deliberately skew-built server (PATCH differs) the connection is
243 accepted and, when the server's version is newer, the client prints 246 accepted and, when the server's version is newer, the client prints
@@ -269,9 +272,11 @@ terminal client; a saved login from one signs in the other.
269 272
270## Packaging checklist 273## Packaging checklist
271 274
2721. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), 2751. Set the version in `Cargo.toml`, `gui/Cargo.toml` and `webd/Cargo.toml`
273 run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree 276 (same MAJOR.MINOR), run `cargo test --workspace`, build the browser app
274 because the site links the committed git history as the GPL source. 277 (`cd web/play && npm ci && npm run build`), and commit. `publish.sh` refuses
278 a dirty tree because the site links the committed git history as the GPL
279 source.
2752. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI 2802. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI
276 with `gui-release` and the terminal clients and servers. The installers are 281 with `gui-release` and the terminal clients and servers. The installers are
277 stable site files and are not regenerated. 282 stable site files and are not regenerated.
@@ -284,4 +289,33 @@ terminal client; a saved login from one signs in the other.
284 menu entry. It must install the newest version even when an older app sits 289 menu entry. It must install the newest version even when an older app sits
285 next to it, and refuse a corrupted download. 290 next to it, and refuse a corrupted download.
2865. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. 2915. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut.
2926. Start `webd` against the staging server with a pinned fingerprint and open
293 `/play/` through nginx. Run the manual web checklist below.
294
295## Manual web checklist
296
2971. Open `/play/` in a fresh browser. The auth card must appear; the top bar
298 must not claim a balance until login.
2992. Register, then log in with the password. The lobby must show 1 000 Kr and
300 every game. Refresh the page; the session cookie must reattach and the
301 balance must survive (within `ZAGAN_GRACE_SECONDS`).
3023. Open the same URL in a second tab. The first tab must show "Opened in
303 another tab" and stop reconnecting; the second must continue.
3044. Slots: spin and watch the reels settle on the server result; check the
305 payout and balance. Roulette: place straight, dozen, column and outside
306 chips, right-click one back, clear, spin, and confirm the result.
3075. Blackjack: deal, hit/stand/double, then deal again; forfeiting must warn
308 and lose the reserved bet.
3096. Crash: start a round, watch the curve and ticks, cash out; on another
310 round let it bust. Confirm the reveal line shows `sha256(seed) == commit`
311 and the recomputed crash point matches.
3127. Refresh in the middle of a live crash round. The current view must return
313 with the same `commit` and a rising multiplier; the round must still settle.
3148. Close the tab mid-round and wait past the grace window. The next visit must
315 log in again, and the server must have forfeited the round.
3169. Stop the gateway. The browser must show the reconnect overlay, then recover
317 when `webd` returns. Stop it for longer than the grace window: the round is
318 forfeited and a login is required.
31910. Check the browser console: no credential is stored, the cookie is HttpOnly,
320 and the only network target is `/ws`.
287 321
diff --git a/docs/WEB-PLAN.md b/docs/WEB-PLAN.md
new file mode 100644
index 0000000..895687b
--- /dev/null
+++ b/docs/WEB-PLAN.md
@@ -0,0 +1,129 @@
1# Browser frontend plan
2
3Status: v1 implemented in 5.0.0. This is the working plan and the deployment
4contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions.
5
6## Shape
7
8```
9browser ──wss──> nginx ──ws──> webd ──TLS──> game server
10 /play/ /ws 127.0.0.1:8890 127.0.0.1:11525
11 static files gateway one thread per authoritative rules,
12 browser session balances, crash clock
13```
14
15- `web/play/` is a static TypeScript app. It sends `Command` JSON and renders
16 `View` JSON. It has no rules and no wire parser.
17- `webd/` is a workspace member. It owns one `ClientCore`, one upstream TLS
18 connection and a 60-second grace window per browser session.
19- `src/protocol.rs` is v5: bounded frames, `REQ`/`RES`/`EVT`, request ids,
20 server time and crash commit–reveal.
21
22## Phases
23
241. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync,
25 ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change.
262. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned
27 upstream, `Command`/`View` JSON. Done in the 5.0.0 change.
283. **Web shell** — login/register, lobby, history, notices, route at `/play/`.
29 Done in the 5.0.0 change.
304. **Games** — slots, roulette (felt + chips), blackjack, crash (curve +
31 verification). Done in the 5.0.0 change.
325. **Deployment** — nginx routes, static build, systemd unit, verification in
33 `scripts/deploy.sh`. Done in the 5.0.0 change.
34
35## Browser contract
36
37Browser → gateway:
38
39```json
40{"type":"command","command":{"Login":{"user":"alice","credential":"<sha256 hex>"}}}
41{"type":"command","command":{"Play":{"game":"crash","bet":10}}}
42{"type":"command","command":"Start"}
43{"type":"ping"}
44```
45
46`Command` uses serde's external tagging because several variants carry a
47primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`).
48
49Gateway → browser:
50
51```json
52{"type":"hello","version":"5.0.0","grace_seconds":60,"user":"alice","time":1700000000000}
53{"type":"view","view":{"screen":"lobby", "...": "..."}}
54{"type":"notice","text":"Logged in as alice"}
55{"type":"error","text":"Upstream connection failed: ..."}
56{"type":"replaced"}
57{"type":"pong"}
58```
59
60`View` is internally tagged by `screen` (`lobby`, `history`, `slots`,
61`roulette`, `blackjack`, `crash`). The browser renders each screen; it never
62computes a result.
63
64The cookie is `zagan_session`, HttpOnly, SameSite=Strict, Secure on HTTPS. It
65is set on the WebSocket handshake. It covers refresh and the grace window only;
66there is no remember-me.
67
68## Build and run
69
70```sh
71# static app
72cd web/play
73npm ci
74npm run build # writes app.js; commit it
75
76# gateway
77cargo build -p zagan-bet-webd --release
78ZAGAN_UPSTREAM=127.0.0.1:11525 \
79ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \
80ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \
81ZAGAN_WEB_BIND=127.0.0.1:8890 \
82target/release/zagan-bet-webd
83```
84
85The fingerprint is the same SPKI SHA-256 the desktop client shows:
86
87```sh
88openssl x509 -in cert.pem -pubkey -noout \
89 | openssl pkey -pubin -outform DER \
90 | sha256sum
91```
92
93Test against a staging site with
94`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899 ZAGAN_WEB_COOKIE_SECURE=0`.
95
96## Security boundaries
97
98- The gateway binds loopback. nginx terminates TLS and is the only public
99 entry. Do not expose `ZAGAN_WEB_BIND` to the internet.
100- The upstream key is pinned; there is no TOFU and no unsafe mode. A mismatch
101 logs both fingerprints and refuses credentials.
102- The browser has no certificate UI and no credential storage. XSS cannot read
103 the HttpOnly cookie; the backend still must escape every notice.
104- The browser speaks JSON, not the game wire. A malicious page cannot use the
105 gateway as a protocol proxy: `Origin` is checked and the session cookie is
106 SameSite=Strict.
107- The web app is play-money only. No deposits, withdrawals, KYC or real-money
108 claims belong in v1 copy.
109
110## Failure behaviour
111
112| Event | Result |
113| --- | --- |
114| Refresh / new tab, within grace | Same `ClientCore`, current `View` restored |
115| New tab, same cookie | Old tab gets `replaced` and closes; new tab continues |
116| Socket closed, grace expires | Upstream closes; server forfeits live hand |
117| Gateway restart | Sessions lost; players log in again |
118| Upstream disconnect | Browser gets an error, core resets, reconnect attempts |
119| Browser retry of a request | Gateway does not retry; request ids are per connection |
120
121## Verification
122
123- `cargo test --workspace` covers frames, envelope parsing, idempotent replay,
124 commit verification, ticks, core routing and session JSON.
125- `web/play`: `npm run typecheck` and `npm run build`.
126- Manual WS smoke test: connect, login, play slots/crash, refresh in a live
127 crash round, verify the revealed seed.
128- `scripts/deploy.sh` checks `/play/`, `app.js`, `style.css` and the existing
129 download feed.
diff --git a/docs/adr/0001-gui-frontend-architecture.md b/docs/adr/0001-gui-frontend-architecture.md
index 44dca32..e4dd5ac 100644
--- a/docs/adr/0001-gui-frontend-architecture.md
+++ b/docs/adr/0001-gui-frontend-architecture.md
@@ -1,6 +1,7 @@
1# 0001 — GUI is a second frontend over a shared core 1# 0001 — GUI is a second frontend over a shared core
2 2
3Status: accepted, 2026-11-01 3Status: accepted, 2026-11-01; the "no browser" constraint is superseded by
4[0003](0003-web-gateway.md), which adds a browser frontend through a gateway.
4 5
5## Context 6## Context
6 7
diff --git a/docs/adr/0003-web-gateway.md b/docs/adr/0003-web-gateway.md
new file mode 100644
index 0000000..5337e2b
--- /dev/null
+++ b/docs/adr/0003-web-gateway.md
@@ -0,0 +1,85 @@
1# 0003 — Browser frontend through a server-side gateway
2
3Status: accepted, 2026-11-08
4
5## Context
6
7The game already runs over TLS/TCP with a newline-delimited text protocol. A
8browser cannot open a TCP socket, and putting game rules into JavaScript would
9create a second implementation of payout, session and forfeit logic — exactly
10what the project forbids.
11
12The site must stay a website in the ordinary sense: open `zagan.bet`, log in,
13play. Nothing to install. Balances are play credits; there are no deposits,
14withdrawals or KYC in this decision.
15
16## Decision
17
181. **The browser is a third frontend over `ClientCore`.** It sends `Command`
19 JSON and renders `View` JSON. It contains no rules, no wire-protocol parser
20 and no balance arithmetic.
212. **A Rust gateway, `webd/`, owns each session.** One blocking thread per
22 browser session, one `ClientCore`, and one ordinary client TLS connection
23 upstream. The existing `client::transport` and `ClientCore` are reused. No
24 async runtime and no new concurrency style.
253. **`webd/` is a separate workspace member.** Like `gui/`, its dependencies
26 (tungstenite, HTTP cookie handling) must never enter the TUI, GUI or server
27 build graph.
284. **Everything is same-origin and operator-configured.** nginx terminates
29 TLS, serves the static app at `/play/`, and proxies `/ws` to the gateway on
30 loopback. The browser never sees a certificate, there is no CORS, and the
31 upstream address (`ZAGAN_UPSTREAM`, default `127.0.0.1:11525`) and SPKI
32 fingerprint (`ZAGAN_UPSTREAM_FINGERPRINT`, required) are gateway config.
33 A mismatch refuses to connect and logs both fingerprints. There is no TOFU
34 and no unsafe mode for the upstream leg.
355. **Session-only authentication.** Log in per visit. The gateway issues a
36 random HttpOnly, SameSite=Strict, Secure session cookie; the cookie covers a
37 refresh and the grace window only. No credential is stored in the browser or
38 in the gateway. A new connection with the same cookie takes over the session
39 and the old socket is told it was replaced.
406. **60-second grace window.** When the last browser socket for a session
41 closes, the session thread keeps the upstream connection and `ClientCore`
42 alive for 60 seconds (configurable). A reconnecting socket with the same
43 cookie reattaches and immediately receives the current `View`. Expiry closes
44 upstream, and the server forfeits a live hand per the existing disconnect
45 rule. There is no wire-level resume: the gateway, not the browser, holds the
46 TCP connection.
477. **Vanilla TypeScript + esbuild.** DOM for login, lobby, history and notices;
48 one canvas for the game surface. No framework runtime. Theme tokens are
49 reused from the desktop palette; felt green is reserved for roulette.
508. **Web v1 has full parity after trust.** Register, log in, lobby, all four
51 games, history, log out, notices, refresh grace. The connect/trust screens
52 do not exist in the browser because the gateway owns the upstream key.
53
54## Consequences
55
56- One implementation of the rules and parsers remains authoritative, and the
57 TUI/GUI/gateway all exercise the same `ClientCore` tests.
58- The gateway becomes an operational component: it needs a service unit,
59 deployment, logging, a required upstream fingerprint, and a restart story.
60 A gateway restart loses live sessions and therefore forfeits their rounds;
61 this is accepted for v1.
62- The browser cannot recover from a gateway crash by itself. The player logs in
63 again.
64- A 60-second grace window means a live round can outlive a refresh, but not a
65 closed laptop for a minute and a half.
66- WebSocket frames are same-origin JSON, so the page cannot be used as an open
67 proxy by another origin. The gateway also checks the `Origin` header against
68 `ZAGAN_WEB_ORIGIN`.
69- The web app is play-money only. Adding payments later would be a separate
70 product and compliance decision, not a UI change.
71
72## Alternatives considered
73
74- **WASM `ClientCore` in the browser, dumb relay**: reuses Rust in the page,
75 but needs transport, TOFU and credential shims in wasm and a multi-megabyte
76 download for every player. Rejected.
77- **JavaScript rewrite speaking the game protocol**: fastest UI iteration, but
78 a second implementation of rules and payout invariants. Rejected.
79- **Tokio/axum gateway**: idiomatic WebSocket server, but a large async tree
80 and a second concurrency style for one blocking, low-traffic product.
81 Rejected.
82- **Long-lived remember-me in the gateway**: better return visits, but a new
83 credential store and revocation flow. Deferred; session-only first.
84- **Protocol-level resume tokens**: survives a gateway restart, but is a wire
85 change and more state on the server. Deferred.
diff --git a/docs/adr/0004-protocol-v5.md b/docs/adr/0004-protocol-v5.md
new file mode 100644
index 0000000..c74ad83
--- /dev/null
+++ b/docs/adr/0004-protocol-v5.md
@@ -0,0 +1,119 @@
1# 0004 — Protocol v5: frames, envelopes, time and commit–reveal
2
3Status: accepted, 2026-11-08
4
5## Context
6
7The current wire is newline-delimited text over TLS. It has served the TUI and
8GUI well, but four problems are now worth fixing before a browser gateway is
9built on top:
10
11- `LineBuffer` grows without bound if a peer never sends a newline, and there
12 is no explicit maximum message size.
13- A reply is assumed to answer the request that is currently pending. A late
14 reply, a retry or a second in-flight request can clobber state.
15- There is no idempotency: a gateway retry after a timeout can charge a bet
16 twice.
17- The crash multiplier is anchored to each frontend's local clock, and the
18 crash point is only "trust the server".
19
20The web gateway makes the first three worse (it retries and multiplexes) and
21the browser makes the fourth visible (a page and the desktop can disagree).
22
23## Decision
24
25This is one wire change and one `MAJOR` release (`5.0.0`); there is no
26compatibility mode.
27
28### Framing
29
30- Every packet is `[u32 length][payload]`, length big-endian.
31- `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a
32 protocol error and the connection closes; buffers never grow past one frame.
33- The payload has no trailing newline.
34
35### Envelope
36
37- `REQ <id> [idem=<token>] <command>` — client request. `<id>` is a `u64`
38 chosen by the client, monotonically increasing on the connection.
39- `RES <id> <body>` — the reply to that request.
40- `EVT <seq> <body>` — a server-initiated event. `<seq>` is a `u64`,
41 incremented once per event on the connection.
42
43The existing text grammar is the `<command>` and `<body>`; `HELLO 5.0.0`,
44`PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning.
45
46`HELLO` is the first framed request. The server ignores every other command
47before a successful handshake. The reply is
48`HELLO <version> latest=<v> url=<url> time=<unix_ms>`.
49
50### Correlation and idempotency
51
52- The client keeps a request id until its `RES` arrives. Replies are routed by
53 id, never by "the next line".
54- The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each
55 connection. A repeated id with the same payload returns the stored reply
56 without executing the command again. A repeated id with a different payload
57 returns `ERROR Request id reused with a different command`.
58- The cache is per connection and in memory. A reconnect is a new game session
59 by design; durable idempotency is deferred.
60- `idem=<token>` is optional metadata for logs and for a future cross-
61 connection cache. The request id is the cache key.
62
63### Clock and ticks
64
65- `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING`
66 replies `PONG <unix_ms>`.
67- Clients keep an offset between server time and their own clock. The offset is
68 for display and interpolation only; the server clock settles a cash-out.
69- A crash start carries `started_at=<unix_ms>` and `commit=<hex>`.
70- While a crash round runs, the server pushes at most one
71 `EVT <seq> crash.tick session=<id> t=<unix_ms> m=<multiplier>` per 100 ms.
72 The client interpolates between ticks and may keep rendering locally.
73- `CRASH_BUSTED` remains an event, now with the reveal attached.
74
75### Commit–reveal crash
76
77- At round start the server draws 32 random bytes as the round seed and sends
78 `commit = sha256(seed)` in hex. It also sends the crash point's start time.
79- The crash point is a pure function of the seed:
80 `u = u64_be(seed[0..8]) / 2^64`, then
81 `crash_point_from_uniform(u)`. The seed never reaches the client before the
82 round ends.
83- When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the
84 server includes `seed=<hex>`. A client verifies `sha256(seed) == commit` and
85 recomputes the crash point. The UI can show "verified".
86- This proves the crash point was fixed before the player acted. It does not
87 prove the seed was not ground to a favourable value; a hash chain with client
88 entropy is the documented next step if that matters.
89
90## Consequences
91
92- The TUI, GUI, server and gateway all move to v5 together; a v4 peer is
93 refused at `HELLO`.
94- `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based
95 tests and helpers are rewritten around frames.
96- `ClientCore` allocates request ids and routes replies by id. Its `Effect`
97 carries the id, so an adapter writes the envelope and hands the `RES` back
98 with the same id.
99- Crash rendering can be smoother and consistent across frontends, and the
100 crash result is independently checkable from the revealed seed.
101- The per-connection cache is deliberately small and forgets on reconnect; a
102 retry after a reconnect is a new action, not a replay.
103
104## Alternatives considered
105
106- **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite
107 of every parser for no scale need. Text inside a bounded frame keeps logs
108 readable and the change small.
109- **Newline-delimited frames with a maximum line length**: still needs a
110 separate envelope and does not bound the buffer as cleanly; a declared length
111 is simpler.
112- **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the
113 gateway owns retries on one connection and a reconnect is a fresh session.
114 Deferred.
115- **Server seed plus client seed without a commit round-trip**: the server
116 sees the client seed before committing and can bias the pair; a half-measure
117 is worse than an honest per-round commitment.
118- **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain
119 state and a restart story. Deferred.
diff --git a/gui/Cargo.toml b/gui/Cargo.toml
index e216309..8dc09df 100644
--- a/gui/Cargo.toml
+++ b/gui/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet-gui" 2name = "zagan-bet-gui"
3version = "4.4.0" 3version = "5.0.0"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet" 6description = "Desktop frontend for zagan-bet"
diff --git a/gui/src/app.rs b/gui/src/app.rs
index af64e5e..df8fec3 100644
--- a/gui/src/app.rs
+++ b/gui/src/app.rs
@@ -16,6 +16,7 @@ use zagan_bet::client::database;
16use zagan_bet::client::paths; 16use zagan_bet::client::paths;
17use zagan_bet::client::view::{Command, LobbyView, View}; 17use zagan_bet::client::view::{Command, LobbyView, View};
18use zagan_bet::history::PlayRecord; 18use zagan_bet::history::PlayRecord;
19use zagan_bet::protocol::Packet;
19use zagan_bet::version::{VERSION, is_newer}; 20use zagan_bet::version::{VERSION, is_newer};
20 21
21use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; 22use crate::net::{Command as NetCommand, Connected, Event, Failure, Network};
@@ -280,8 +281,8 @@ impl ZaganApp {
280 } 281 }
281 } 282 }
282 } 283 }
283 Effect::SendLine(line) => { 284 Effect::SendRequest { id, payload } => {
284 self.net.send(NetCommand::Send(line)); 285 self.net.send(NetCommand::Send { id, payload });
285 self.awaiting = Some(Instant::now()); 286 self.awaiting = Some(Instant::now());
286 } 287 }
287 Effect::Disconnect => { 288 Effect::Disconnect => {
@@ -355,17 +356,18 @@ impl ZaganApp {
355 failure, 356 failure,
356 message, 357 message,
357 } => self.failed(addr, failure, message), 358 } => self.failed(addr, failure, message),
358 Event::Line(line) => { 359 Event::Packet(packet) => match packet {
359 if self.core.is_async(&line) { 360 Packet::Event { body, .. } => {
360 if let Some(effects) = self.core.handle_async_line(&line) { 361 if let Some(effects) = self.core.handle_async_line(&body) {
361 self.apply(ctx, effects); 362 self.apply(ctx, effects);
362 } 363 }
363 } else { 364 }
365 Packet::Reply { id, body } => {
364 self.awaiting = None; 366 self.awaiting = None;
365 let effects = self.core.handle_reply(&line); 367 let effects = self.core.handle_reply_id(id, &body);
366 self.apply(ctx, effects); 368 self.apply(ctx, effects);
367 } 369 }
368 } 370 },
369 Event::Closed => { 371 Event::Closed => {
370 self.session = None; 372 self.session = None;
371 self.awaiting = None; 373 self.awaiting = None;
@@ -384,6 +386,7 @@ impl ZaganApp {
384 self.core 386 self.core
385 .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); 387 .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone());
386 self.core.set_logged_in(connected.user.clone()); 388 self.core.set_logged_in(connected.user.clone());
389 self.core.set_server_time(connected.server_time);
387 self.login.error = connected.auth_error.clone(); 390 self.login.error = connected.auth_error.clone();
388 self.login.user = connected.user.clone().unwrap_or_default(); 391 self.login.user = connected.user.clone().unwrap_or_default();
389 392
diff --git a/gui/src/net.rs b/gui/src/net.rs
index 7cba604..7882cd3 100644
--- a/gui/src/net.rs
+++ b/gui/src/net.rs
@@ -14,6 +14,7 @@ use eframe::egui;
14use rusqlite::Connection; 14use rusqlite::Connection;
15use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; 15use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt};
16use zagan_bet::client::{database, paths}; 16use zagan_bet::client::{database, paths};
17use zagan_bet::protocol::Packet;
17 18
18/// How often the worker checks the socket for pushes when idle. 19/// How often the worker checks the socket for pushes when idle.
19const POLL: Duration = Duration::from_millis(8); 20const POLL: Duration = Duration::from_millis(8);
@@ -24,7 +25,7 @@ const TRUST_TIMEOUT: Duration = Duration::from_secs(120);
24#[derive(Debug, Clone, PartialEq, Eq)] 25#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command { 26pub enum Command {
26 Connect { addr: String }, 27 Connect { addr: String },
27 Send(String), 28 Send { id: u64, payload: String },
28 Disconnect, 29 Disconnect,
29 Stop, 30 Stop,
30} 31}
@@ -49,6 +50,7 @@ pub struct Connected {
49 pub latest: String, 50 pub latest: String,
50 pub url: String, 51 pub url: String,
51 pub elapsed_ms: f32, 52 pub elapsed_ms: f32,
53 pub server_time: u64,
52} 54}
53 55
54/// What the worker tells the UI. 56/// What the worker tells the UI.
@@ -65,7 +67,7 @@ pub enum Event {
65 failure: Failure, 67 failure: Failure,
66 message: String, 68 message: String,
67 }, 69 },
68 Line(String), 70 Packet(Packet),
69 Closed, 71 Closed,
70} 72}
71 73
@@ -164,6 +166,7 @@ fn worker(
164 latest: connected.latest, 166 latest: connected.latest,
165 url: connected.url, 167 url: connected.url,
166 elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, 168 elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0,
169 server_time: connected.server_time,
167 })); 170 }));
168 stream = Some(connected.stream); 171 stream = Some(connected.stream);
169 } 172 }
@@ -177,9 +180,9 @@ fn worker(
177 } 180 }
178 ctx.request_repaint(); 181 ctx.request_repaint();
179 } 182 }
180 Ok(Command::Send(line)) => { 183 Ok(Command::Send { id, payload }) => {
181 if let Some(open) = &mut stream { 184 if let Some(open) = &mut stream {
182 if open.write_line(&line).is_err() { 185 if open.write_request(id, None, &payload).is_err() {
183 stream = None; 186 stream = None;
184 let _ = events.send(Event::Closed); 187 let _ = events.send(Event::Closed);
185 } 188 }
@@ -198,9 +201,9 @@ fn worker(
198 // Drain pushes and replies without blocking the command channel. 201 // Drain pushes and replies without blocking the command channel.
199 if let Some(open) = &mut stream { 202 if let Some(open) = &mut stream {
200 loop { 203 loop {
201 match open.read_available_line() { 204 match open.read_available_packet() {
202 Ok(Some(line)) => { 205 Ok(Some(packet)) => {
203 if events.send(Event::Line(line)).is_err() { 206 if events.send(Event::Packet(packet)).is_err() {
204 return; 207 return;
205 } 208 }
206 } 209 }
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 63bd31c..0527505 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -67,6 +67,7 @@ if [ "$VERIFY_ONLY" -eq 0 ]; then
67 RSYNC_FLAGS=( 67 RSYNC_FLAGS=(
68 -a --delay-updates --human-readable --chmod=D755,F644 68 -a --delay-updates --human-readable --chmod=D755,F644
69 --exclude=README.md --exclude=.gitignore 69 --exclude=README.md --exclude=.gitignore
70 --exclude=play/node_modules
70 ) 71 )
71 72
72 if [[ "$TARGET" == *:* ]]; then 73 if [[ "$TARGET" == *:* ]]; then
@@ -160,6 +161,16 @@ for local in "${artifacts[@]}"; do
160 verified=$((verified + 1)) 161 verified=$((verified + 1))
161done 162done
162 163
164curl -fsSL --max-time 30 "$origin/play/" -o "$tmp/play.html" \
165 || die "could not fetch the browser app at /play/"
166grep -q 'app.js' "$tmp/play.html" || die "/play/ does not reference app.js"
167for asset in app.js style.css; do
168 curl -fsSL --max-time 30 "$origin/play/$asset" -o "$tmp/$asset" \
169 || die "could not fetch /play/$asset"
170 cmp -s "web/play/$asset" "$tmp/$asset" \
171 || die "/play/$asset served from the site differs from the local file"
172done
173
163[ "$failed" -eq 0 ] || die "verification failed" 174[ "$failed" -eq 0 ] || die "verification failed"
164 175
165echo "==> $verified artifact(s) verified at $URL" 176echo "==> $verified artifact(s) and the browser app verified at $URL"
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs
index f88f62b..e808d90 100644
--- a/src/bin/client/connection.rs
+++ b/src/bin/client/connection.rs
@@ -8,7 +8,7 @@ use parking_lot::Mutex;
8use std::error::Error; 8use std::error::Error;
9use zagan_bet::client::transport::{self, TrustPrompt}; 9use zagan_bet::client::transport::{self, TrustPrompt};
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, VERSION}; 11use zagan_bet::version::{VERSION, is_newer};
12 12
13pub use zagan_bet::client::transport::TlsStream; 13pub use zagan_bet::client::transport::TlsStream;
14 14
@@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) {
21pub fn connect( 21pub fn connect(
22 addr: &str, 22 addr: &str,
23 conn: &rusqlite::Connection, 23 conn: &rusqlite::Connection,
24) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { 24) -> Result<(TlsStream, Option<String>, u64), Box<dyn Error>> {
25 let unsafe_mode = *UNSAFE_MODE.lock(); 25 let unsafe_mode = *UNSAFE_MODE.lock();
26 26
27 // The terminal has no dialog: print the first-contact notice and trust. 27 // The terminal has no dialog: print the first-contact notice and trust.
@@ -53,5 +53,5 @@ pub fn connect(
53 println!("Authentication failed: {error}"); 53 println!("Authentication failed: {error}");
54 } 54 }
55 55
56 Ok((connected.stream, connected.user)) 56 Ok((connected.stream, connected.user, connected.server_time))
57} 57}
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index cfdcdd5..deb5743 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -14,15 +14,15 @@ use crossterm::{
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect}; 16use zagan_bet::client::core::{ClientCore, Effect};
17use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
18use zagan_bet::client::shell::{self, Shell};
19use zagan_bet::client::view::{RoulettePhase, View};
20use zagan_bet::client::database; 17use zagan_bet::client::database;
18use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input};
19use zagan_bet::client::shell::{self, Shell};
21use zagan_bet::client::ui::{ 20use zagan_bet::client::ui::{
22 display_width, format_pong, help_lines, host_lines, input_cursor_column, 21 MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column,
23 lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, 22 lines_to_move_up, terminal_size_warning, visual_line_count,
24}; 23};
25use zagan_bet::protocol::io_failure_message; 24use zagan_bet::client::view::{RoulettePhase, View};
25use zagan_bet::protocol::{Packet, io_failure_message};
26use zagan_bet::roulette::{WHEEL, color as roulette_color}; 26use zagan_bet::roulette::{WHEEL, color as roulette_color};
27 27
28use crate::connection::TlsStream; 28use crate::connection::TlsStream;
@@ -78,9 +78,10 @@ impl ClientState {
78 pub fn connect_to(&mut self, addr: &str) { 78 pub fn connect_to(&mut self, addr: &str) {
79 self.addr = Some(addr.to_string()); 79 self.addr = Some(addr.to_string());
80 match crate::connection::connect(addr, &self.conn) { 80 match crate::connection::connect(addr, &self.conn) {
81 Ok((stream, authed_user)) => { 81 Ok((stream, authed_user, server_time)) => {
82 self.stream = Some(stream); 82 self.stream = Some(stream);
83 self.core.set_connected(true); 83 self.core.set_connected(true);
84 self.core.set_server_time(server_time);
84 self.core.set_logged_in(authed_user); 85 self.core.set_logged_in(authed_user);
85 let fingerprint = database::get_fingerprint(&self.conn, addr); 86 let fingerprint = database::get_fingerprint(&self.conn, addr);
86 self.core.set_host_info(Some(addr.to_string()), fingerprint); 87 self.core.set_host_info(Some(addr.to_string()), fingerprint);
@@ -126,9 +127,7 @@ impl ClientState {
126 key, 127 key,
127 ) { 128 ) {
128 GameInput::Quit => vec![Effect::Quit], 129 GameInput::Quit => vec![Effect::Quit],
129 GameInput::Command(command) => { 130 GameInput::Command(command) => self.core.command(command),
130 self.core.command(command)
131 }
132 GameInput::None => Vec::new(), 131 GameInput::None => Vec::new(),
133 } 132 }
134 } else { 133 } else {
@@ -189,10 +188,10 @@ impl ClientState {
189 let Some(mut stream) = self.stream.take() else { 188 let Some(mut stream) = self.stream.take() else {
190 return false; 189 return false;
191 }; 190 };
192 let line = match stream.read_available_line() { 191 let packet = match stream.read_available_packet() {
193 Ok(line) => { 192 Ok(packet) => {
194 self.stream = Some(stream); 193 self.stream = Some(stream);
195 line 194 packet
196 } 195 }
197 Err(_) => { 196 Err(_) => {
198 self.core.set_connected(false); 197 self.core.set_connected(false);
@@ -201,13 +200,15 @@ impl ClientState {
201 } 200 }
202 }; 201 };
203 202
204 let Some(line) = line else { 203 let Some(packet) = packet else {
205 return false; 204 return false;
206 }; 205 };
207 206
208 let effects = match self.core.handle_async_line(&line) { 207 let effects = match packet {
209 Some(effects) => effects, 208 Packet::Event { body, .. } => {
210 None => self.core.handle_reply(&line), 209 self.core.handle_async_line(&body).unwrap_or_default()
210 }
211 Packet::Reply { id, body } => self.core.handle_reply_id(id, &body),
211 }; 212 };
212 if self.apply_effects(effects) { 213 if self.apply_effects(effects) {
213 return true; 214 return true;
@@ -259,13 +260,13 @@ impl ClientState {
259 self.draw_region(&lines); 260 self.draw_region(&lines);
260 } 261 }
261 Effect::ClearRegion => self.clear_region(), 262 Effect::ClearRegion => self.clear_region(),
262 Effect::SendLine(line) => { 263 Effect::SendRequest { id, payload } => {
263 let is_ping = line == "PING"; 264 let is_ping = payload == "PING";
264 let started = Instant::now(); 265 let started = Instant::now();
265 let reply = self.send_and_read(&line); 266 let reply = self.send_and_read(id, &payload);
266 let elapsed = started.elapsed(); 267 let elapsed = started.elapsed();
267 let mut more = self.core.handle_reply(&reply); 268 let mut more = self.core.handle_reply_id(id, &reply);
268 if is_ping && reply.trim() == "PONG" { 269 if is_ping && reply.trim().starts_with("PONG") {
269 for effect in &mut more { 270 for effect in &mut more {
270 if let Effect::Print(lines) = effect { 271 if let Effect::Print(lines) = effect {
271 for text in lines { 272 for text in lines {
@@ -488,31 +489,30 @@ impl ClientState {
488 self.prompt_text = text.to_string(); 489 self.prompt_text = text.to_string();
489 } 490 }
490 491
491 fn send_and_read(&mut self, line: &str) -> String { 492 fn send_and_read(&mut self, id: u64, line: &str) -> String {
492 let Some(mut stream) = self.stream.take() else { 493 let Some(mut stream) = self.stream.take() else {
493 return "ERROR Not connected".to_string(); 494 return "ERROR Not connected".to_string();
494 }; 495 };
495 496
496 // A crash event can be pushed while a request is in flight. Route those 497 // A crash event can be pushed while a request is in flight. Route those
497 // lines as they arrive and keep waiting for the actual reply. 498 // events as they arrive and keep waiting for the reply with this id.
498 let mut pushed: Vec<String> = Vec::new(); 499 let mut pushed: Vec<String> = Vec::new();
499 let result = (|| -> std::io::Result<String> { 500 let result = (|| -> std::io::Result<String> {
500 stream.write_line(line)?; 501 stream.write_request(id, None, line)?;
501 loop { 502 loop {
502 let mut response = String::new(); 503 match stream.read_packet()? {
503 let read = stream.read_line(&mut response)?; 504 None => {
504 if read == 0 { 505 return Err(std::io::Error::new(
505 return Err(std::io::Error::new( 506 std::io::ErrorKind::UnexpectedEof,
506 std::io::ErrorKind::UnexpectedEof, 507 "connection closed",
507 "connection closed", 508 ));
508 )); 509 }
509 } 510 Some(Packet::Event { body, .. }) => pushed.push(body),
510 let response = response.trim_end().to_string(); 511 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => {
511 if self.core.is_async(&response) { 512 return Ok(body);
512 pushed.push(response); 513 }
513 continue; 514 Some(Packet::Reply { .. }) => continue,
514 } 515 }
515 return Ok(response);
516 } 516 }
517 })(); 517 })();
518 518
@@ -614,7 +614,11 @@ impl ClientState {
614 short_fingerprint(&identity), 614 short_fingerprint(&identity),
615 host 615 host
616 ), 616 ),
617 Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), 617 Ok(false) => println!(
618 "No saved login for server {} ({}).",
619 short_fingerprint(&identity),
620 host
621 ),
618 Err(error) => println!("Failed to forget the saved login: {}", error), 622 Err(error) => println!("Failed to forget the saved login: {}", error),
619 } 623 }
620 } 624 }
@@ -625,10 +629,9 @@ impl ClientState {
625 return; 629 return;
626 }; 630 };
627 match database::forget_host(&self.conn, &target) { 631 match database::forget_host(&self.conn, &target) {
628 Ok(outcome) if outcome.credentials_removed => println!( 632 Ok(outcome) if outcome.credentials_removed => {
629 "Cleared trust and stored credentials for {}.", 633 println!("Cleared trust and stored credentials for {}.", target)
630 target 634 }
631 ),
632 Ok(outcome) => { 635 Ok(outcome) => {
633 println!("Cleared trust for {}.", target); 636 println!("Cleared trust for {}.", target);
634 if !outcome.siblings.is_empty() { 637 if !outcome.siblings.is_empty() {
@@ -665,7 +668,8 @@ impl ClientState {
665 668
666 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 669 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
667 let message = format!("AUTH {} {}", username, hashed); 670 let message = format!("AUTH {} {}", username, hashed);
668 let response = self.send_and_read(&message); 671 let id = self.core.allocate_request_id();
672 let response = self.send_and_read(id, &message);
669 let response = response.trim(); 673 let response = response.trim();
670 674
671 if response.starts_with("OK") { 675 if response.starts_with("OK") {
@@ -709,7 +713,8 @@ impl ClientState {
709 713
710 let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); 714 let hashed = format!("{:x}", Sha256::digest(password.as_bytes()));
711 let message = format!("REGISTER {} {}", username, hashed); 715 let message = format!("REGISTER {} {}", username, hashed);
712 let response = self.send_and_read(&message); 716 let id = self.core.allocate_request_id();
717 let response = self.send_and_read(id, &message);
713 let response = response.trim(); 718 let response = response.trim();
714 719
715 if response == format!("REGISTERED {}", username) { 720 if response == format!("REGISTERED {}", username) {
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs
index dffae69..e6b623f 100644
--- a/src/bin/server/handler.rs
+++ b/src/bin/server/handler.rs
@@ -1,6 +1,7 @@
1use std::time::Duration; 1use std::time::Duration;
2 2
3use crate::tls::TlsStream; 3use crate::tls::TlsStream;
4use zagan_bet::protocol::{event_line, parse_request, reply_line};
4use zagan_bet::server::core::{ConnectionState, ServerCore}; 5use zagan_bet::server::core::{ConnectionState, ServerCore};
5 6
6/// How long the connection waits for a request before advancing timed games. 7/// How long the connection waits for a request before advancing timed games.
@@ -30,24 +31,35 @@ pub fn handle_client_tls(mut stream: TlsStream) {
30 } 31 }
31 32
32 let mut state = ConnectionState::default(); 33 let mut state = ConnectionState::default();
34 let mut event_seq = 0u64;
33 println!("[TLS Client connected] {client_addr}"); 35 println!("[TLS Client connected] {client_addr}");
34 36
35 'connection: loop { 37 'connection: loop {
36 let mut line = String::new(); 38 let mut frame = String::new();
37 match stream.read_line(&mut line) { 39 match stream.read_frame(&mut frame) {
38 Ok(0) => break, 40 Ok(0) => break,
39 Ok(_) => { 41 Ok(_) => {
40 let line = line.trim(); 42 let user = state.user().unwrap_or("guest").to_string();
41 if !line.is_empty() { 43 println!("[{user} @ {client_addr}] Received: {frame}");
42 let user = state.user().unwrap_or("guest").to_string(); 44 match parse_request(&frame) {
43 println!("[{user} @ {client_addr}] Received: {line}"); 45 Ok(request) => {
44 let response = core.handle_message(&mut state, line); 46 let response = core.handle_request(&mut state, &request);
45 if let Err(error) = stream.write_line(&response) { 47 let reply = reply_line(request.id, &response);
46 eprintln!("Write error: {error}"); 48 if let Err(error) = stream.write_frame(&reply) {
47 break; 49 eprintln!("Write error: {error}");
50 break;
51 }
52 let user = state.user().unwrap_or("guest");
53 println!("[{user} @ {client_addr}] Sent: {reply}");
54 }
55 Err(message) => {
56 let reply = reply_line(0, &format!("ERROR {message}"));
57 if let Err(error) = stream.write_frame(&reply) {
58 eprintln!("Write error: {error}");
59 break;
60 }
61 eprintln!("Malformed request from {client_addr}: {reply}");
48 } 62 }
49 let user = state.user().unwrap_or("guest");
50 println!("[{user} @ {client_addr}] Sent: {response}");
51 } 63 }
52 } 64 }
53 Err(error) 65 Err(error)
@@ -65,12 +77,17 @@ pub fn handle_client_tls(mut stream: TlsStream) {
65 } 77 }
66 78
67 for push in core.poll(&state) { 79 for push in core.poll(&state) {
68 if let Err(error) = stream.write_line(&push) { 80 event_seq = event_seq.wrapping_add(1);
81 let event = event_line(event_seq, &push);
82 if let Err(error) = stream.write_frame(&event) {
69 eprintln!("Write error: {error}"); 83 eprintln!("Write error: {error}");
70 break 'connection; 84 break 'connection;
71 } 85 }
72 let user = state.user().unwrap_or("guest"); 86 // Ticks are 10 Hz per live round; logging them floods the disk.
73 println!("[{user} @ {client_addr}] Pushed: {push}"); 87 if !push.starts_with("crash.tick ") {
88 let user = state.user().unwrap_or("guest");
89 println!("[{user} @ {client_addr}] Pushed: {event}");
90 }
74 } 91 }
75 } 92 }
76 93
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs
index 420b5b4..eb94637 100644
--- a/src/bin/server/tls.rs
+++ b/src/bin/server/tls.rs
@@ -1,7 +1,7 @@
1use std::io::{Read, Write}; 1use std::io::{Read, Write};
2use std::net::TcpStream; 2use std::net::TcpStream;
3 3
4use zagan_bet::protocol::LineBuffer; 4use zagan_bet::protocol::{FrameBuffer, write_frame};
5 5
6#[derive(Clone)] 6#[derive(Clone)]
7pub struct TlsServer { 7pub struct TlsServer {
@@ -44,34 +44,31 @@ impl TlsServer {
44 44
45pub struct TlsStream { 45pub struct TlsStream {
46 stream: native_tls::TlsStream<TcpStream>, 46 stream: native_tls::TlsStream<TcpStream>,
47 lines: LineBuffer, 47 frames: FrameBuffer,
48} 48}
49 49
50impl TlsStream { 50impl TlsStream {
51 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { 51 pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self {
52 Self { 52 Self {
53 stream, 53 stream,
54 lines: LineBuffer::new(), 54 frames: FrameBuffer::new(),
55 } 55 }
56 } 56 }
57 57
58 pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { 58 /// Read one protocol frame. `Ok(0)` is EOF with no partial frame.
59 match self.lines.read_line(&mut self.stream)? { 59 pub fn read_frame(&mut self, out: &mut String) -> std::io::Result<usize> {
60 Some(line) => { 60 match self.frames.read_frame(&mut self.stream)? {
61 s.push_str(&line); 61 Some(frame) => {
62 s.push('\n'); 62 let length = frame.len();
63 Ok(line.len() + 1) 63 out.push_str(&frame);
64 Ok(length)
64 } 65 }
65 None => Ok(0), 66 None => Ok(0),
66 } 67 }
67 } 68 }
68 69
69 pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { 70 pub fn write_frame(&mut self, msg: &str) -> std::io::Result<()> {
70 use std::io::Write; 71 write_frame(&mut self.stream, msg)
71 self.stream.write_all(msg.as_bytes())?;
72 self.stream.write_all(b"\n")?;
73 self.stream.flush()?;
74 Ok(())
75 } 72 }
76 73
77 pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { 74 pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> {
diff --git a/src/client/core.rs b/src/client/core.rs
index 6d8294c..278dc00 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -3,17 +3,16 @@ use crate::client::games::response::{
3 parse_blackjack_response, parse_crash_response, parse_history_response, 3 parse_blackjack_response, parse_crash_response, parse_history_response,
4 parse_roulette_response, parse_spin_response, 4 parse_roulette_response, parse_spin_response,
5}; 5};
6use crate::client::ui::{game_lines, history_lines};
6use crate::client::view::{ 7use crate::client::view::{
7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 8 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
8}; 9};
9use crate::client::ui::{game_lines, history_lines};
10use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
11use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{MAX_HISTORY, PlayRecord};
12use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
13use crate::version::{parse_pong, unix_time_ms};
13use std::time::{Duration, Instant}; 14use std::time::{Duration, Instant};
14 15
15
16
17/// High-level actions produced by the client core. The terminal adapter is 16/// High-level actions produced by the client core. The terminal adapter is
18/// responsible for turning these into crossterm calls and network writes. 17/// responsible for turning these into crossterm calls and network writes.
19#[derive(Debug, Clone, PartialEq)] 18#[derive(Debug, Clone, PartialEq)]
@@ -24,8 +23,8 @@ pub enum Effect {
24 Render(View), 23 Render(View),
25 /// Remove the game board region. 24 /// Remove the game board region.
26 ClearRegion, 25 ClearRegion,
27 /// Send one protocol line and feed the reply to `handle_reply`. 26 /// Send one enveloped request and route its reply back by `id`.
28 SendLine(String), 27 SendRequest { id: u64, payload: String },
29 /// Drop the connection. 28 /// Drop the connection.
30 Disconnect, 29 Disconnect,
31 /// Leave the program. 30 /// Leave the program.
@@ -61,6 +60,10 @@ pub struct ClientCore {
61 connected: bool, 60 connected: bool,
62 logged_in: Option<String>, 61 logged_in: Option<String>,
63 pending: Option<Pending>, 62 pending: Option<Pending>,
63 /// Id of the request whose reply fills `pending`.
64 pending_id: Option<u64>,
65 /// Next correlation id allocated on this connection.
66 next_request_id: u64,
64 balance: Option<i64>, 67 balance: Option<i64>,
65 /// Account number from the last `INFO`, shown by both frontends. 68 /// Account number from the last `INFO`, shown by both frontends.
66 account: Option<String>, 69 account: Option<String>,
@@ -68,6 +71,13 @@ pub struct ClientCore {
68 fingerprint: Option<String>, 71 fingerprint: Option<String>,
69 /// Local anchor for the crash multiplier, set when a round starts. 72 /// Local anchor for the crash multiplier, set when a round starts.
70 crash_started: Option<Instant>, 73 crash_started: Option<Instant>,
74 /// Server-time anchor for the crash multiplier, when the server supplied
75 /// `started_at`.
76 crash_started_ms: Option<u64>,
77 /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`.
78 server_offset_ms: i64,
79 /// True once a frontend has supplied a server timestamp.
80 clock_synced: bool,
71 /// App screen shown when no game is open. 81 /// App screen shown when no game is open.
72 screen: Screen, 82 screen: Screen,
73 /// Last plays fetched with `history`. 83 /// Last plays fetched with `history`.
@@ -83,11 +93,16 @@ impl ClientCore {
83 connected: false, 93 connected: false,
84 logged_in: None, 94 logged_in: None,
85 pending: None, 95 pending: None,
96 pending_id: None,
97 next_request_id: 1,
86 balance: None, 98 balance: None,
87 account: None, 99 account: None,
88 host: None, 100 host: None,
89 fingerprint: None, 101 fingerprint: None,
90 crash_started: None, 102 crash_started: None,
103 crash_started_ms: None,
104 server_offset_ms: 0,
105 clock_synced: false,
91 screen: Screen::Lobby, 106 screen: Screen::Lobby,
92 play_history: Vec::new(), 107 play_history: Vec::new(),
93 login_error: None, 108 login_error: None,
@@ -99,11 +114,19 @@ impl ClientCore {
99 if !connected { 114 if !connected {
100 self.logged_in = None; 115 self.logged_in = None;
101 self.pending = None; 116 self.pending = None;
117 self.pending_id = None;
102 self.crash_started = None; 118 self.crash_started = None;
119 self.crash_started_ms = None;
120 self.clock_synced = false;
103 self.screen = Screen::Lobby; 121 self.screen = Screen::Lobby;
104 } 122 }
105 } 123 }
106 124
125 /// The signed-in username, if any.
126 pub fn user(&self) -> Option<&str> {
127 self.logged_in.as_deref()
128 }
129
107 pub fn set_logged_in(&mut self, user: Option<String>) { 130 pub fn set_logged_in(&mut self, user: Option<String>) {
108 if user.is_some() { 131 if user.is_some() {
109 self.login_error = None; 132 self.login_error = None;
@@ -147,7 +170,44 @@ impl ClientCore {
147 self.board_effects() 170 self.board_effects()
148 } 171 }
149 172
173 /// Record the server's clock and the local time it was received. The
174 /// offset is for display; cash-out still settles on the server.
175 pub fn set_server_time(&mut self, server_ms: u64) {
176 self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64;
177 self.clock_synced = true;
178 }
179
180 /// The game server's clock, as this client currently estimates it.
181 pub fn server_now_ms(&self) -> u64 {
182 (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64
183 }
184
185 /// Allocate a correlation id for a request the adapter sends directly
186 /// (the terminal's login prompt bypasses `ClientCore`).
187 pub fn allocate_request_id(&mut self) -> u64 {
188 let id = self.next_request_id;
189 self.next_request_id = self.next_request_id.wrapping_add(1).max(1);
190 id
191 }
192
193 fn begin_request(&mut self, pending: Pending, payload: String) -> Effect {
194 let id = self.allocate_request_id();
195 self.pending = Some(pending);
196 self.pending_id = Some(id);
197 Effect::SendRequest { id, payload }
198 }
199
200 /// Route a reply by correlation id. A reply we no longer expect (a late
201 /// response after a timeout) is ignored instead of corrupting the board.
202 pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec<Effect> {
203 if self.pending_id != Some(id) {
204 return Vec::new();
205 }
206 self.handle_reply(reply)
207 }
208
150 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { 209 pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> {
210 self.pending_id = None;
151 match self.pending.take() { 211 match self.pending.take() {
152 Some(Pending::Spin) => match parse_spin_response(reply) { 212 Some(Pending::Spin) => match parse_spin_response(reply) {
153 Ok(result) => { 213 Ok(result) => {
@@ -265,7 +325,8 @@ impl ClientCore {
265 } 325 }
266 }, 326 },
267 Some(Pending::Ping) => { 327 Some(Pending::Ping) => {
268 if reply.trim() == "PONG" { 328 if let Some(server_ms) = parse_pong(reply) {
329 self.set_server_time(server_ms);
269 print_line("Pong") 330 print_line("Pong")
270 } else { 331 } else {
271 self.connected = false; 332 self.connected = false;
@@ -305,10 +366,8 @@ impl ClientCore {
305 if self.game.begin_spin().is_err() { 366 if self.game.begin_spin().is_err() {
306 return Vec::new(); 367 return Vec::new();
307 } 368 }
308 self.pending = Some(Pending::Spin);
309
310 let mut effects = self.board_effects(); 369 let mut effects = self.board_effects();
311 effects.push(Effect::SendLine(request)); 370 effects.push(self.begin_request(Pending::Spin, request));
312 effects 371 effects
313 } 372 }
314 373
@@ -322,24 +381,24 @@ impl ClientCore {
322 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 381 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
323 } 382 }
324 self.login_error = None; 383 self.login_error = None;
325 self.pending = Some(Pending::Login { user: user.clone() }); 384 vec![self.begin_request(
326 vec![Effect::SendLine(format!("AUTH {user} {credential}"))] 385 Pending::Login { user: user.clone() },
386 format!("AUTH {user} {credential}"),
387 )]
327 } 388 }
328 Command::Register { user, credential } => { 389 Command::Register { user, credential } => {
329 if !self.connected { 390 if !self.connected {
330 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 391 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
331 } 392 }
332 self.login_error = None; 393 self.login_error = None;
333 self.pending = Some(Pending::Register); 394 vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))]
334 vec![Effect::SendLine(format!("REGISTER {user} {credential}"))]
335 } 395 }
336 Command::Games => vec![Effect::Print(game_lines(&self.games()))], 396 Command::Games => vec![Effect::Print(game_lines(&self.games()))],
337 Command::Ping => { 397 Command::Ping => {
338 if !self.connected { 398 if !self.connected {
339 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 399 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
340 } 400 }
341 self.pending = Some(Pending::Ping); 401 vec![self.begin_request(Pending::Ping, "PING".to_string())]
342 vec![Effect::SendLine("PING".to_string())]
343 } 402 }
344 Command::Echo(message) => { 403 Command::Echo(message) => {
345 if !self.connected { 404 if !self.connected {
@@ -348,8 +407,7 @@ impl ClientCore {
348 if message.trim().is_empty() { 407 if message.trim().is_empty() {
349 return print_line("Usage: send <message>"); 408 return print_line("Usage: send <message>");
350 } 409 }
351 self.pending = Some(Pending::Echo); 410 vec![self.begin_request(Pending::Echo, message)]
352 vec![Effect::SendLine(message)]
353 } 411 }
354 Command::Info => self.info_effects(), 412 Command::Info => self.info_effects(),
355 Command::Play { game, bet } => self.play_game(&game, bet), 413 Command::Play { game, bet } => self.play_game(&game, bet),
@@ -458,6 +516,7 @@ impl ClientCore {
458 516
459 fn exit_game(&mut self) -> Vec<Effect> { 517 fn exit_game(&mut self) -> Vec<Effect> {
460 self.crash_started = None; 518 self.crash_started = None;
519 self.crash_started_ms = None;
461 self.screen = Screen::Lobby; 520 self.screen = Screen::Lobby;
462 self.game.end_game(); 521 self.game.end_game();
463 vec![ 522 vec![
@@ -475,19 +534,17 @@ impl ClientCore {
475 return self.exit_game(); 534 return self.exit_game();
476 }; 535 };
477 self.game.set_blackjack_status("forfeiting"); 536 self.game.set_blackjack_status("forfeiting");
478 self.pending = Some(Pending::BlackjackForfeit); 537 vec![self.begin_request(
479 vec![Effect::SendLine(format!( 538 Pending::BlackjackForfeit,
480 "BLACKJACK_ACTION {} forfeit", 539 format!("BLACKJACK_ACTION {} forfeit", hand.session_id),
481 hand.session_id 540 )]
482 ))]
483 } 541 }
484 542
485 fn forfeit_crash(&mut self) -> Vec<Effect> { 543 fn forfeit_crash(&mut self) -> Vec<Effect> {
486 let Some(session) = self.game.crash_session_id() else { 544 let Some(session) = self.game.crash_session_id() else {
487 return self.exit_game(); 545 return self.exit_game();
488 }; 546 };
489 self.pending = Some(Pending::CrashForfeit); 547 vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))]
490 vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))]
491 } 548 }
492 549
493 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { 550 fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> {
@@ -533,10 +590,8 @@ impl ClientCore {
533 if self.game.begin_spin().is_err() { 590 if self.game.begin_spin().is_err() {
534 return Vec::new(); 591 return Vec::new();
535 } 592 }
536 self.pending = Some(Pending::RouletteSpin);
537
538 let mut effects = self.board_effects(); 593 let mut effects = self.board_effects();
539 effects.push(Effect::SendLine(request)); 594 effects.push(self.begin_request(Pending::RouletteSpin, request));
540 effects 595 effects
541 } 596 }
542 597
@@ -566,9 +621,8 @@ impl ClientCore {
566 } 621 }
567 622
568 self.game.set_blackjack_status("dealing"); 623 self.game.set_blackjack_status("dealing");
569 self.pending = Some(Pending::BlackjackStart);
570 let mut effects = self.board_effects(); 624 let mut effects = self.board_effects();
571 effects.push(Effect::SendLine(request)); 625 effects.push(self.begin_request(Pending::BlackjackStart, request));
572 effects 626 effects
573 } 627 }
574 628
@@ -582,15 +636,21 @@ impl ClientCore {
582 if action == "double" && !hand.actions.iter().any(|available| available == "double") { 636 if action == "double" && !hand.actions.iter().any(|available| available == "double") {
583 return Vec::new(); 637 return Vec::new();
584 } 638 }
585 self.pending = Some(Pending::BlackjackAction); 639 vec![self.begin_request(
586 vec![Effect::SendLine(format!( 640 Pending::BlackjackAction,
587 "BLACKJACK_ACTION {} {}", 641 format!("BLACKJACK_ACTION {} {}", hand.session_id, action),
588 hand.session_id, action 642 )]
589 ))]
590 } 643 }
591 644
592 /// Redraw the rising multiplier. Called by the terminal loop on a timer. 645 /// Redraw the rising multiplier. Called by the terminal loop on a timer.
593 pub fn tick(&mut self) -> Vec<Effect> { 646 pub fn tick(&mut self) -> Vec<Effect> {
647 if self.clock_synced {
648 if let Some(started_ms) = self.crash_started_ms {
649 let now = unix_time_ms() as i64 + self.server_offset_ms;
650 let elapsed_ms = (now - started_ms as i64).max(0) as u64;
651 return self.tick_at(Duration::from_millis(elapsed_ms));
652 }
653 }
594 let Some(started) = self.crash_started else { 654 let Some(started) = self.crash_started else {
595 return Vec::new(); 655 return Vec::new();
596 }; 656 };
@@ -619,12 +679,16 @@ impl ClientCore {
619 } 679 }
620 680
621 pub fn is_async(&self, line: &str) -> bool { 681 pub fn is_async(&self, line: &str) -> bool {
622 line.trim_start().starts_with("CRASH_BUSTED ") 682 let line = line.trim_start();
683 line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ")
623 } 684 }
624 685
625 /// Handle a server-initiated line. Returns `None` when the line is a normal 686 /// Handle a server-initiated line. Returns `None` when the line is a normal
626 /// request/response reply and belongs to `handle_reply`. 687 /// request/response reply and belongs to `handle_reply`.
627 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { 688 pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> {
689 if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") {
690 return Some(self.apply_crash_tick(tick));
691 }
628 if !self.is_async(line) { 692 if !self.is_async(line) {
629 return None; 693 return None;
630 } 694 }
@@ -643,13 +707,43 @@ impl ClientCore {
643 if self.crash_started.is_none() { 707 if self.crash_started.is_none() {
644 self.crash_started = Some(Instant::now()); 708 self.crash_started = Some(Instant::now());
645 } 709 }
710 if state.started_at > 0 {
711 self.crash_started_ms = Some(state.started_at);
712 }
646 } else { 713 } else {
647 self.crash_started = None; 714 self.crash_started = None;
715 self.crash_started_ms = None;
648 } 716 }
649 self.game.apply_crash_state(state); 717 self.game.apply_crash_state(state);
650 self.board_effects() 718 self.board_effects()
651 } 719 }
652 720
721 /// Apply `crash.tick session=<id> t=<server_ms> m=<multiplier>`.
722 fn apply_crash_tick(&mut self, tick: &str) -> Vec<Effect> {
723 let mut session = None;
724 let mut multiplier = None;
725 for token in tick.split_whitespace() {
726 if let Some(value) = token.strip_prefix("session=") {
727 session = Some(value);
728 } else if let Some(value) = token.strip_prefix("m=") {
729 multiplier = value.parse::<f64>().ok();
730 }
731 }
732 let Some(multiplier) = multiplier else {
733 return Vec::new();
734 };
735 if let Some(session) = session {
736 if self.game.crash_session_id().as_deref() != Some(session) {
737 return Vec::new();
738 }
739 }
740 if self.game.crash_status() != "running" {
741 return Vec::new();
742 }
743 self.game.set_crash_multiplier(multiplier);
744 self.board_effects()
745 }
746
653 fn start_crash(&mut self) -> Vec<Effect> { 747 fn start_crash(&mut self) -> Vec<Effect> {
654 let status = self.game.crash_status(); 748 let status = self.game.crash_status();
655 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { 749 if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") {
@@ -671,9 +765,8 @@ impl ClientCore {
671 } 765 }
672 766
673 self.game.set_crash_status("starting"); 767 self.game.set_crash_status("starting");
674 self.pending = Some(Pending::CrashStart);
675 let mut effects = self.board_effects(); 768 let mut effects = self.board_effects();
676 effects.push(Effect::SendLine(request)); 769 effects.push(self.begin_request(Pending::CrashStart, request));
677 effects 770 effects
678 } 771 }
679 772
@@ -681,8 +774,7 @@ impl ClientCore {
681 let Some(session) = self.game.crash_session_id() else { 774 let Some(session) = self.game.crash_session_id() else {
682 return Vec::new(); 775 return Vec::new();
683 }; 776 };
684 self.pending = Some(Pending::CrashCashout); 777 vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))]
685 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))]
686 } 778 }
687 779
688 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { 780 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> {
@@ -706,8 +798,7 @@ impl ClientCore {
706 } 798 }
707 799
708 let mut effects = self.board_effects(); 800 let mut effects = self.board_effects();
709 self.pending = Some(Pending::Info); 801 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
710 effects.push(Effect::SendLine("INFO".to_string()));
711 effects 802 effects
712 } 803 }
713 Err(_) => print_line("Failed to enter game"), 804 Err(_) => print_line("Failed to enter game"),
@@ -726,13 +817,12 @@ impl ClientCore {
726 } 817 }
727 818
728 self.screen = Screen::History; 819 self.screen = Screen::History;
729 self.pending = Some(Pending::History);
730 let request = if limit == crate::history::DEFAULT_HISTORY { 820 let request = if limit == crate::history::DEFAULT_HISTORY {
731 "HISTORY".to_string() 821 "HISTORY".to_string()
732 } else { 822 } else {
733 format!("HISTORY {limit}") 823 format!("HISTORY {limit}")
734 }; 824 };
735 vec![Effect::SendLine(request)] 825 vec![self.begin_request(Pending::History, request)]
736 } 826 }
737 827
738 fn info_effects(&mut self) -> Vec<Effect> { 828 fn info_effects(&mut self) -> Vec<Effect> {
@@ -754,8 +844,7 @@ impl ClientCore {
754 ])), 844 ])),
755 } 845 }
756 846
757 self.pending = Some(Pending::Info); 847 effects.push(self.begin_request(Pending::Info, "INFO".to_string()));
758 effects.push(Effect::SendLine("INFO".to_string()));
759 effects 848 effects
760 } 849 }
761 850
@@ -809,7 +898,6 @@ impl ClientCore {
809 } 898 }
810 self.view().map(Effect::Render).into_iter().collect() 899 self.view().map(Effect::Render).into_iter().collect()
811 } 900 }
812
813} 901}
814 902
815fn auth_result(reply: &str) -> Result<(), String> { 903fn auth_result(reply: &str) -> Result<(), String> {
diff --git a/src/client/games/manager.rs b/src/client/games/manager.rs
index 24ceeaa..d35308b 100644
--- a/src/client/games/manager.rs
+++ b/src/client/games/manager.rs
@@ -5,7 +5,7 @@ use super::roulette::RouletteGame;
5use super::slots::SlotsGame; 5use super::slots::SlotsGame;
6use super::traits::{Game, GameError, GamePhase, GameState}; 6use super::traits::{Game, GameError, GamePhase, GameState};
7use crate::blackjack::BlackjackState; 7use crate::blackjack::BlackjackState;
8use crate::crash::{payout, CrashState}; 8use crate::crash::{CrashState, payout};
9use crate::roulette::Bets; 9use crate::roulette::Bets;
10use std::collections::HashMap; 10use std::collections::HashMap;
11 11
@@ -244,9 +244,7 @@ impl GameManager {
244 244
245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { 245 pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) {
246 if let Some(session) = self.current_session.as_mut() { 246 if let Some(session) = self.current_session.as_mut() {
247 session 247 session.data.insert("cursor".to_string(), cursor.encoded());
248 .data
249 .insert("cursor".to_string(), cursor.encoded());
250 } 248 }
251 } 249 }
252 250
@@ -339,7 +337,7 @@ impl GameManager {
339 }; 337 };
340 session.bet_amount = state.bet; 338 session.bet_amount = state.bet;
341 session.data.remove("error"); 339 session.data.remove("error");
342 for key in ["crash_point", "payout"] { 340 for key in ["crash_point", "payout", "commit", "seed"] {
343 session.data.remove(key); 341 session.data.remove(key);
344 } 342 }
345 session 343 session
@@ -360,10 +358,21 @@ impl GameManager {
360 session 358 session
361 .data 359 .data
362 .insert("payout".to_string(), state.payout.to_string()); 360 .insert("payout".to_string(), state.payout.to_string());
361 session
362 .data
363 .insert("started_at".to_string(), state.started_at.to_string());
364 if let Some(commit) = &state.commit {
365 session.data.insert("commit".to_string(), commit.clone());
366 }
367 if let Some(seed) = &state.seed {
368 session.data.insert("seed".to_string(), seed.clone());
369 }
363 if let Some(crash_point) = state.crash_point { 370 if let Some(crash_point) = state.crash_point {
371 // Keep full precision: the browser recomputes this from the
372 // revealed seed, and the GUI charts it.
364 session 373 session
365 .data 374 .data
366 .insert("crash_point".to_string(), format!("{crash_point:.2}")); 375 .insert("crash_point".to_string(), crash_point.to_string());
367 } 376 }
368 } 377 }
369 378
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs
index f0ed816..15cc6ed 100644
--- a/src/client/games/roulette.rs
+++ b/src/client/games/roulette.rs
@@ -1,6 +1,7 @@
1use super::traits::{Game, GameState}; 1use super::traits::{Game, GameState};
2use crate::client::view::{RoulettePhase, RouletteView}; 2use crate::client::view::{RoulettePhase, RouletteView};
3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; 3use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd};
4use serde::{Deserialize, Serialize};
4 5
5pub const ROULETTE_BOARD_LINES: usize = 12; 6pub const ROULETTE_BOARD_LINES: usize = 12;
6 7
@@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [
21 22
22pub struct RouletteGame; 23pub struct RouletteGame;
23 24
24#[derive(Debug, Clone, Copy, PartialEq, Eq)] 25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum Direction { 26pub enum Direction {
26 Up, 27 Up,
27 Down, 28 Down,
@@ -41,7 +42,7 @@ pub enum Direction {
41/// odd,even line between rows split of the two numbers 42/// odd,even line between rows split of the two numbers
42/// even,even corner of four cells corner 43/// even,even corner of four cells corner
43/// ``` 44/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq)] 45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum Cursor { 46pub enum Cursor {
46 Zero, 47 Zero,
47 Lattice { x: usize, y: usize }, 48 Lattice { x: usize, y: usize },
@@ -67,7 +68,9 @@ impl Cursor {
67 match parts.as_slice() { 68 match parts.as_slice() {
68 ["zero"] => Cursor::Zero, 69 ["zero"] => Cursor::Zero,
69 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { 70 ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) {
70 (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { 71 (Ok(x), Ok(y))
72 if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) =>
73 {
71 Cursor::Lattice { x, y } 74 Cursor::Lattice { x, y }
72 } 75 }
73 _ => Cursor::default(), 76 _ => Cursor::default(),
diff --git a/src/client/transport.rs b/src/client/transport.rs
index 97c9227..60e7ab6 100644
--- a/src/client/transport.rs
+++ b/src/client/transport.rs
@@ -7,20 +7,27 @@
7use std::fmt; 7use std::fmt;
8use std::io::{Read, Write}; 8use std::io::{Read, Write};
9use std::net::TcpStream; 9use std::net::TcpStream;
10use std::sync::mpsc;
11use std::sync::Arc; 10use std::sync::Arc;
11use std::sync::mpsc;
12use std::thread; 12use std::thread;
13use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
14 14
15use parking_lot::Mutex; 15use parking_lot::Mutex;
16use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
17use rustls::DigitallySignedStruct;
18use rusqlite::Connection; 16use rusqlite::Connection;
17use rustls::DigitallySignedStruct;
18use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
19 19
20use crate::client::database; 20use crate::client::database;
21use crate::protocol::LineBuffer; 21use crate::protocol::{
22use crate::tofu::{decide, spki_fingerprint, TrustDecision}; 22 FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame,
23use crate::version::{parse_hello, HelloReply, VERSION}; 23};
24use crate::tofu::{TrustDecision, decide, spki_fingerprint};
25use crate::version::{HelloReply, VERSION, parse_hello};
26
27/// Reserved request ids for the transport handshake. Application requests
28/// start at 1, so the two ranges cannot collide on one connection.
29const HELLO_REQUEST_ID: u64 = u64::MAX;
30const AUTH_REQUEST_ID: u64 = u64::MAX - 1;
24 31
25/// How long to wait for TCP + TLS before giving up. 32/// How long to wait for TCP + TLS before giving up.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 33const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -43,6 +50,8 @@ pub struct Connected {
43 pub server_version: String, 50 pub server_version: String,
44 pub latest: String, 51 pub latest: String,
45 pub url: String, 52 pub url: String,
53 /// Server Unix time reported by `HELLO`, milliseconds.
54 pub server_time: u64,
46 /// Round trip of the HELLO exchange. 55 /// Round trip of the HELLO exchange.
47 pub elapsed: Duration, 56 pub elapsed: Duration,
48} 57}
@@ -51,10 +60,7 @@ pub struct Connected {
51pub enum TransportError { 60pub enum TransportError {
52 Timeout, 61 Timeout,
53 TrustDeclined, 62 TrustDeclined,
54 KeyChanged { 63 KeyChanged { expected: String, observed: String },
55 expected: String,
56 observed: String,
57 },
58 VersionMismatch(String), 64 VersionMismatch(String),
59 Closed, 65 Closed,
60 Unexpected(String), 66 Unexpected(String),
@@ -121,7 +127,7 @@ pub fn connect(
121 return Err(TransportError::KeyChanged { 127 return Err(TransportError::KeyChanged {
122 expected, 128 expected,
123 observed: actual, 129 observed: actual,
124 }) 130 });
125 } 131 }
126 } 132 }
127 } 133 }
@@ -131,12 +137,7 @@ pub fn connect(
131 .map_err(io_failure)?; 137 .map_err(io_failure)?;
132 138
133 let start = Instant::now(); 139 let start = Instant::now();
134 tls.write_line(&format!("HELLO {VERSION}")) 140 let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?;
135 .map_err(io_failure)?;
136 let mut response = String::new();
137 if tls.read_line(&mut response).map_err(io_failure)? == 0 {
138 return Err(TransportError::Closed);
139 }
140 let elapsed = start.elapsed(); 141 let elapsed = start.elapsed();
141 142
142 let response = response.trim(); 143 let response = response.trim();
@@ -151,10 +152,11 @@ pub fn connect(
151 let mut user = None; 152 let mut user = None;
152 let mut auth_error = None; 153 let mut auth_error = None;
153 if let Some((username, password)) = database::get_credentials(conn, &observed) { 154 if let Some((username, password)) = database::get_credentials(conn, &observed) {
154 tls.write_line(&format!("AUTH {username} {password}")) 155 let auth = exchange(
155 .map_err(io_failure)?; 156 &mut tls,
156 let mut auth = String::new(); 157 AUTH_REQUEST_ID,
157 tls.read_line(&mut auth).map_err(io_failure)?; 158 &format!("AUTH {username} {password}"),
159 )?;
158 if auth.trim().starts_with("OK") { 160 if auth.trim().starts_with("OK") {
159 user = Some(username); 161 user = Some(username);
160 } else { 162 } else {
@@ -169,19 +171,31 @@ pub fn connect(
169 server_version: hello.version, 171 server_version: hello.version,
170 latest: hello.latest, 172 latest: hello.latest,
171 url: hello.url, 173 url: hello.url,
174 server_time: hello.time,
172 elapsed, 175 elapsed,
173 }) 176 })
174} 177}
175 178
179/// Send one request and wait for the reply with the same id, ignoring events
180/// that arrive in between. Used only for the handshake.
181fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result<String, TransportError> {
182 stream
183 .write_request(id, None, payload)
184 .map_err(io_failure)?;
185 loop {
186 match stream.read_packet().map_err(io_failure)? {
187 None => return Err(TransportError::Closed),
188 Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body),
189 Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue,
190 }
191 }
192}
193
176/// TCP + TLS on a thread so a blocked handshake can time out. 194/// TCP + TLS on a thread so a blocked handshake can time out.
177fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { 195fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> {
178 let (sender, receiver) = mpsc::channel(); 196 let (sender, receiver) = mpsc::channel();
179 let addr = addr.to_string(); 197 let addr = addr.to_string();
180 let server_name = addr 198 let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string();
181 .split(':')
182 .next()
183 .unwrap_or("127.0.0.1")
184 .to_string();
185 199
186 thread::spawn(move || { 200 thread::spawn(move || {
187 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { 201 let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> {
@@ -241,7 +255,7 @@ impl TlsConnector {
241 session: Arc::new(Mutex::new(Session { 255 session: Arc::new(Mutex::new(Session {
242 sock, 256 sock,
243 tls: connection, 257 tls: connection,
244 lines: LineBuffer::new(), 258 frames: FrameBuffer::new(),
245 read_timeout: Some(READ_TIMEOUT), 259 read_timeout: Some(READ_TIMEOUT),
246 })), 260 })),
247 }) 261 })
@@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier {
268 _ocsp_response: &[u8], 282 _ocsp_response: &[u8],
269 _now: std::time::SystemTime, 283 _now: std::time::SystemTime,
270 ) -> Result<ServerCertVerified, rustls::Error> { 284 ) -> Result<ServerCertVerified, rustls::Error> {
271 let fingerprint = spki_fingerprint(&end_entity.0) 285 let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| {
272 .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; 286 rustls::Error::General(format!("certificate parse failed: {error}"))
287 })?;
273 *self.observed.lock() = Some(fingerprint); 288 *self.observed.lock() = Some(fingerprint);
274 Ok(ServerCertVerified::assertion()) 289 Ok(ServerCertVerified::assertion())
275 } 290 }
@@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier {
313struct Session { 328struct Session {
314 sock: TcpStream, 329 sock: TcpStream,
315 tls: rustls::ClientConnection, 330 tls: rustls::ClientConnection,
316 lines: LineBuffer, 331 frames: FrameBuffer,
317 read_timeout: Option<Duration>, 332 read_timeout: Option<Duration>,
318} 333}
319 334
@@ -329,37 +344,58 @@ pub struct TlsStream {
329} 344}
330 345
331impl TlsStream { 346impl TlsStream {
332 pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { 347 /// Write one enveloped request. Application code allocates `id`.
348 pub fn write_request(
349 &mut self,
350 id: u64,
351 idem: Option<&str>,
352 payload: &str,
353 ) -> std::io::Result<()> {
354 self.write_frame(&request_line(id, idem, payload))
355 }
356
357 /// Write one raw frame payload.
358 pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> {
359 let mut s = self.session.lock();
360 write_protocol_frame(&mut s.tls.writer(), payload)?;
361 s.drive()
362 }
363
364 /// Block until one whole frame arrives.
365 pub fn read_frame(&mut self) -> std::io::Result<Option<String>> {
333 let mut s = self.session.lock(); 366 let mut s = self.session.lock();
334 loop { 367 loop {
335 if let Some(line) = s.lines.next_line() { 368 match s.frames.next_frame() {
336 buf.push_str(&line); 369 Some(Ok(frame)) => return Ok(Some(frame)),
337 buf.push('\n'); 370 Some(Err(error)) => {
338 return Ok(line.len() + 1); 371 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
372 }
373 None => {}
339 } 374 }
340 375
341 let mut chunk = [0u8; 4096]; 376 let mut chunk = [0u8; 4096];
342 match s.tls.reader().read(&mut chunk) { 377 match s.tls.reader().read(&mut chunk) {
343 Ok(0) => { 378 Ok(0) => return Ok(None),
344 if let Some(rest) = s.lines.take_rest() { 379 Ok(n) => s.frames.push(&chunk[..n]),
345 let read = rest.len();
346 buf.push_str(&rest);
347 return Ok(read);
348 }
349 return Ok(0);
350 }
351 Ok(n) => s.lines.push(&chunk[..n]),
352 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, 380 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?,
353 Err(error) => return Err(error), 381 Err(error) => return Err(error),
354 } 382 }
355 } 383 }
356 } 384 }
357 385
358 pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { 386 /// Block until one `RES` or `EVT` arrives.
359 let mut s = self.session.lock(); 387 pub fn read_packet(&mut self) -> std::io::Result<Option<Packet>> {
360 s.tls.writer().write_all(message.as_bytes())?; 388 loop {
361 s.tls.writer().write_all(b"\n")?; 389 let Some(frame) = self.read_frame()? else {
362 s.drive() 390 return Ok(None);
391 };
392 match parse_packet(&frame) {
393 Ok(packet) => return Ok(Some(packet)),
394 Err(error) => {
395 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
396 }
397 }
398 }
363 } 399 }
364 400
365 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { 401 pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> {
@@ -368,24 +404,36 @@ impl TlsStream {
368 s.sock.set_read_timeout(duration) 404 s.sock.set_read_timeout(duration)
369 } 405 }
370 406
371 /// Return one complete line if the socket already has one, without 407 /// Return one packet if the socket already has one, without blocking.
372 /// blocking. Used to pick up server pushes between requests. 408 /// Used to pick up server pushes between requests.
373 pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { 409 pub fn read_available_packet(&mut self) -> std::io::Result<Option<Packet>> {
374 let mut s = self.session.lock(); 410 let mut s = self.session.lock();
375 if let Some(line) = s.lines.next_line() {
376 return Ok(Some(line));
377 }
378 411
379 // `set_read_timeout(Some(0))` is rejected by std, so poll with the 412 // `set_read_timeout(Some(0))` is rejected by std, so poll with the
380 // shortest timeout the socket accepts instead of a nonblocking mode. 413 // shortest timeout the socket accepts instead of a nonblocking mode.
381 s.sock 414 s.sock
382 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; 415 .set_read_timeout(Some(std::time::Duration::from_millis(1)))?;
383 let drained = (|| -> std::io::Result<()> { 416 let drained = (|| -> std::io::Result<Option<Packet>> {
384 loop { 417 loop {
418 match s.frames.next_frame() {
419 Some(Ok(frame)) => {
420 return match parse_packet(&frame) {
421 Ok(packet) => Ok(Some(packet)),
422 Err(error) => {
423 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
424 }
425 };
426 }
427 Some(Err(error)) => {
428 return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error));
429 }
430 None => {}
431 }
432
385 let mut buf = [0u8; 4096]; 433 let mut buf = [0u8; 4096];
386 match s.tls.reader().read(&mut buf) { 434 match s.tls.reader().read(&mut buf) {
387 Ok(0) => return Ok(()), 435 Ok(0) => return Ok(None),
388 Ok(n) => s.lines.push(&buf[..n]), 436 Ok(n) => s.frames.push(&buf[..n]),
389 // The reader only drains plaintext rustls has already 437 // The reader only drains plaintext rustls has already
390 // decrypted; `drive` moves bytes off the socket. 438 // decrypted; `drive` moves bytes off the socket.
391 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 439 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
@@ -397,7 +445,7 @@ impl TlsStream {
397 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 445 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
398 ) => 446 ) =>
399 { 447 {
400 return Ok(()); 448 return Ok(None);
401 } 449 }
402 Err(error) => return Err(error), 450 Err(error) => return Err(error),
403 } 451 }
@@ -408,15 +456,14 @@ impl TlsStream {
408 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut 456 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
409 ) => 457 ) =>
410 { 458 {
411 return Ok(()); 459 return Ok(None);
412 } 460 }
413 Err(error) => return Err(error), 461 Err(error) => return Err(error),
414 } 462 }
415 } 463 }
416 })(); 464 })();
417 s.sock.set_read_timeout(s.read_timeout)?; 465 s.sock.set_read_timeout(s.read_timeout)?;
418 drained?; 466 drained
419 Ok(s.lines.next_line())
420 } 467 }
421} 468}
422 469
diff --git a/src/client/view.rs b/src/client/view.rs
index 4615720..0886d64 100644
--- a/src/client/view.rs
+++ b/src/client/view.rs
@@ -7,15 +7,17 @@ use crate::blackjack::BlackjackState;
7use crate::client::games::roulette::{Cursor, Direction}; 7use crate::client::games::roulette::{Cursor, Direction};
8use crate::client::games::traits::{GamePhase, GameState}; 8use crate::client::games::traits::{GamePhase, GameState};
9use crate::roulette::{BetTarget, Bets}; 9use crate::roulette::{BetTarget, Bets};
10use serde::{Deserialize, Serialize};
10 11
11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
12pub enum RoulettePhase { 14pub enum RoulettePhase {
13 Placing, 15 Placing,
14 Spinning, 16 Spinning,
15 Result, 17 Result,
16} 18}
17 19
18#[derive(Debug, Clone, PartialEq, Eq)] 20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct RouletteView { 21pub struct RouletteView {
20 pub bet_amount: u64, 22 pub bet_amount: u64,
21 pub balance: Option<i64>, 23 pub balance: Option<i64>,
@@ -51,7 +53,10 @@ impl RouletteView {
51 Some("result") => RoulettePhase::Result, 53 Some("result") => RoulettePhase::Result,
52 _ => RoulettePhase::Placing, 54 _ => RoulettePhase::Placing,
53 }; 55 };
54 let number = state.data.get("number").and_then(|value| value.parse().ok()); 56 let number = state
57 .data
58 .get("number")
59 .and_then(|value| value.parse().ok());
55 let color = state.data.get("color").cloned(); 60 let color = state.data.get("color").cloned();
56 let winnings = state 61 let winnings = state
57 .data 62 .data
@@ -82,14 +87,15 @@ impl RouletteView {
82 } 87 }
83} 88}
84 89
85#[derive(Debug, Clone, Copy, PartialEq, Eq)] 90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
86pub enum SlotsPhase { 92pub enum SlotsPhase {
87 Ready, 93 Ready,
88 Spinning, 94 Spinning,
89 Result, 95 Result,
90} 96}
91 97
92#[derive(Debug, Clone, PartialEq, Eq)] 98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SlotsView { 99pub struct SlotsView {
94 pub bet: u64, 100 pub bet: u64,
95 pub balance: Option<i64>, 101 pub balance: Option<i64>,
@@ -149,7 +155,7 @@ impl SlotsView {
149 } 155 }
150} 156}
151 157
152#[derive(Debug, Clone, PartialEq, Eq)] 158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct BlackjackView { 159pub struct BlackjackView {
154 pub status: String, 160 pub status: String,
155 pub balance: Option<i64>, 161 pub balance: Option<i64>,
@@ -186,7 +192,7 @@ impl BlackjackView {
186 } 192 }
187} 193}
188 194
189#[derive(Debug, Clone, PartialEq)] 195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct CrashView { 196pub struct CrashView {
191 pub status: String, 197 pub status: String,
192 pub balance: Option<i64>, 198 pub balance: Option<i64>,
@@ -195,6 +201,11 @@ pub struct CrashView {
195 pub potential: u64, 201 pub potential: u64,
196 pub crash_point: Option<f64>, 202 pub crash_point: Option<f64>,
197 pub payout: u64, 203 pub payout: u64,
204 pub started_at: u64,
205 /// `sha256(seed)` for this round, before it ends.
206 pub commit: Option<String>,
207 /// The revealed seed, after the round ends.
208 pub seed: Option<String>,
198 pub error: Option<String>, 209 pub error: Option<String>,
199} 210}
200 211
@@ -232,6 +243,13 @@ impl CrashView {
232 .get("payout") 243 .get("payout")
233 .and_then(|value| value.parse::<u64>().ok()) 244 .and_then(|value| value.parse::<u64>().ok())
234 .unwrap_or(0), 245 .unwrap_or(0),
246 started_at: state
247 .data
248 .get("started_at")
249 .and_then(|value| value.parse::<u64>().ok())
250 .unwrap_or(0),
251 commit: state.data.get("commit").cloned(),
252 seed: state.data.get("seed").cloned(),
235 error: state 253 error: state
236 .data 254 .data
237 .get("error") 255 .get("error")
@@ -242,7 +260,7 @@ impl CrashView {
242} 260}
243 261
244/// The lobby: who is logged in, the balance, and what can be played. 262/// The lobby: who is logged in, the balance, and what can be played.
245#[derive(Debug, Clone, PartialEq, Eq)] 263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct LobbyView { 264pub struct LobbyView {
247 pub connected: bool, 265 pub connected: bool,
248 pub user: Option<String>, 266 pub user: Option<String>,
@@ -254,13 +272,14 @@ pub struct LobbyView {
254} 272}
255 273
256/// The play log, already parsed. 274/// The play log, already parsed.
257#[derive(Debug, Clone, PartialEq)] 275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct HistoryView { 276pub struct HistoryView {
259 pub records: Vec<crate::history::PlayRecord>, 277 pub records: Vec<crate::history::PlayRecord>,
260} 278}
261 279
262/// Everything a frontend can draw. 280/// Everything a frontend can draw.
263#[derive(Debug, Clone, PartialEq)] 281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282#[serde(tag = "screen", rename_all = "snake_case")]
264pub enum View { 283pub enum View {
265 Lobby(LobbyView), 284 Lobby(LobbyView),
266 History(HistoryView), 285 History(HistoryView),
@@ -271,17 +290,28 @@ pub enum View {
271} 290}
272 291
273/// Semantic input. Frontends turn keys and clicks into these. 292/// Semantic input. Frontends turn keys and clicks into these.
274#[derive(Debug, Clone, PartialEq, Eq)] 293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub enum Command { 294pub enum Command {
276 /// The credential is already hashed by the frontend. 295 /// The credential is already hashed by the frontend.
277 Login { user: String, credential: String }, 296 Login {
297 user: String,
298 credential: String,
299 },
278 Games, 300 Games,
279 Ping, 301 Ping,
280 Echo(String), 302 Echo(String),
281 Info, 303 Info,
282 Register { user: String, credential: String }, 304 Register {
283 Play { game: String, bet: Option<u64> }, 305 user: String,
284 History { limit: u32 }, 306 credential: String,
307 },
308 Play {
309 game: String,
310 bet: Option<u64>,
311 },
312 History {
313 limit: u32,
314 },
285 ShowLobby, 315 ShowLobby,
286 Logout, 316 Logout,
287 SetBet(u64), 317 SetBet(u64),
diff --git a/src/crash.rs b/src/crash.rs
index 9d6e98c..40b8743 100644
--- a/src/crash.rs
+++ b/src/crash.rs
@@ -6,6 +6,7 @@
6//! clock, never from anything the client sends. 6//! clock, never from anything the client sends.
7 7
8use serde::{Deserialize, Serialize}; 8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
9use std::time::Duration; 10use std::time::Duration;
10 11
11/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. 12/// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds.
@@ -47,6 +48,53 @@ pub fn crash_point_from_uniform(u: f64) -> f64 {
47 ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) 48 ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER)
48} 49}
49 50
51/// How many bytes a round seed contains. The first eight bytes choose the
52/// crash point; the rest are revealed and hashed with it.
53pub const SEED_LEN: usize = 32;
54
55/// Hex-encoded SHA-256 of a round seed, sent before the round starts.
56pub fn commit_seed(seed: &[u8]) -> String {
57 let digest = Sha256::digest(seed);
58 format!("{digest:x}")
59}
60
61/// Uniform draw encoded by a seed: the first eight bytes, big-endian.
62pub fn seed_to_uniform(seed: &[u8]) -> f64 {
63 let mut bytes = [0u8; 8];
64 let take = seed.len().min(8);
65 bytes[..take].copy_from_slice(&seed[..take]);
66 u64::from_be_bytes(bytes) as f64 / 18_446_744_073_709_551_616.0
67}
68
69/// The crash point a seed commits to. A client recomputes this after the
70/// reveal and checks it against the published commit.
71pub fn crash_point_from_seed(seed: &[u8]) -> f64 {
72 crash_point_from_uniform(seed_to_uniform(seed))
73}
74
75/// Decode a lowercase or uppercase hex seed. Returns `None` for odd lengths,
76/// bad digits or a seed that is not exactly [`SEED_LEN`] bytes.
77pub fn seed_from_hex(raw: &str) -> Option<Vec<u8>> {
78 if raw.len() != SEED_LEN * 2 {
79 return None;
80 }
81 let mut seed = Vec::with_capacity(SEED_LEN);
82 for pair in raw.as_bytes().chunks_exact(2) {
83 let high = (pair[0] as char).to_digit(16)?;
84 let low = (pair[1] as char).to_digit(16)?;
85 seed.push((high * 16 + low) as u8);
86 }
87 Some(seed)
88}
89
90/// Check a revealed seed against the commit published before the round.
91pub fn verify_seed(seed_hex: &str, commit: &str) -> bool {
92 let Some(seed) = seed_from_hex(seed_hex) else {
93 return false;
94 };
95 commit_seed(&seed).eq_ignore_ascii_case(commit)
96}
97
50/// Whole-Kr cashout value. Balances are integers, so the payout is floored. 98/// Whole-Kr cashout value. Balances are integers, so the payout is floored.
51pub fn payout(bet: u64, multiplier: f64) -> u64 { 99pub fn payout(bet: u64, multiplier: f64) -> u64 {
52 if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { 100 if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 {
@@ -72,10 +120,16 @@ pub struct CrashState {
72 pub multiplier: f64, 120 pub multiplier: f64,
73 /// `floor(bet * multiplier)` for the value above. 121 /// `floor(bet * multiplier)` for the value above.
74 pub potential: u64, 122 pub potential: u64,
123 /// Server Unix time when the round started, milliseconds.
124 pub started_at: u64,
75 /// Hidden until the round ends. 125 /// Hidden until the round ends.
76 pub crash_point: Option<f64>, 126 pub crash_point: Option<f64>,
77 pub payout: u64, 127 pub payout: u64,
78 pub result: String, 128 pub result: String,
129 /// `sha256(seed)`, present for the whole round.
130 pub commit: Option<String>,
131 /// The seed itself, present only after the round ends.
132 pub seed: Option<String>,
79} 133}
80 134
81impl CrashState { 135impl CrashState {
diff --git a/src/protocol.rs b/src/protocol.rs
index 11f54a1..e838b08 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -1,16 +1,60 @@
1//! Newline-delimited protocol helpers shared by the client and server. 1//! Protocol v5: bounded frames and the request/reply/event envelope.
2//! 2//!
3//! TLS reads can return more than one line at a time. Keeping the leftover 3//! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and
4//! bytes is essential: dropping them silently loses the next message. 4//! capped, so a peer cannot make the reader allocate without bound. Inside a
5//! frame the payload is text:
6//!
7//! ```text
8//! REQ <id> [idem=<token>] <command>
9//! RES <id> <body>
10//! EVT <seq> <body>
11//! ```
12//!
13//! The command and body grammar is the same text protocol the games already
14//! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds
15//! correlation, event numbering and a hard size limit.
16
17use std::fmt;
18use std::io::{self, Read, Write};
19
20/// Largest payload a peer may send in one frame. A game message is a few
21/// hundred bytes; 64 KiB is generous and still bounds the reader.
22pub const MAX_FRAME_LEN: usize = 64 * 1024;
23
24/// How many request ids a connection remembers for idempotent retries.
25pub const REPLAY_CACHE_LEN: usize = 256;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FrameError {
29 /// The declared payload length exceeds [`MAX_FRAME_LEN`].
30 TooLarge(usize),
31 /// The payload was not valid UTF-8.
32 InvalidUtf8,
33}
34
35impl fmt::Display for FrameError {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 FrameError::TooLarge(length) => {
39 write!(
40 f,
41 "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit"
42 )
43 }
44 FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"),
45 }
46 }
47}
5 48
6use std::io::{self, Read}; 49impl std::error::Error for FrameError {}
7 50
51/// A bounded frame decoder. Feed it bytes from a socket, take whole frames.
8#[derive(Debug, Default)] 52#[derive(Debug, Default)]
9pub struct LineBuffer { 53pub struct FrameBuffer {
10 buffer: Vec<u8>, 54 buffer: Vec<u8>,
11} 55}
12 56
13impl LineBuffer { 57impl FrameBuffer {
14 pub fn new() -> Self { 58 pub fn new() -> Self {
15 Self::default() 59 Self::default()
16 } 60 }
@@ -19,46 +63,172 @@ impl LineBuffer {
19 self.buffer.extend_from_slice(bytes); 63 self.buffer.extend_from_slice(bytes);
20 } 64 }
21 65
22 /// Return the next complete line, including a line that ended at EOF. 66 /// Return the next complete frame, if the buffer holds one.
23 pub fn next_line(&mut self) -> Option<String> { 67 ///
24 let end = self.buffer.iter().position(|byte| *byte == b'\n')?; 68 /// A declared length above the limit is reported immediately instead of
25 let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); 69 /// waiting for bytes that will never be accepted; the caller should close
26 line.pop(); // '\n' 70 /// the connection.
27 if line.last() == Some(&b'\r') { 71 pub fn next_frame(&mut self) -> Option<Result<String, FrameError>> {
28 line.pop(); 72 if self.buffer.len() < 4 {
73 return None;
29 } 74 }
30 Some(String::from_utf8_lossy(&line).into_owned())
31 }
32 75
33 /// Return the unterminated remainder at EOF, if any. 76 let length = u32::from_be_bytes([
34 pub fn take_rest(&mut self) -> Option<String> { 77 self.buffer[0],
35 if self.buffer.is_empty() { 78 self.buffer[1],
79 self.buffer[2],
80 self.buffer[3],
81 ]) as usize;
82
83 if length > MAX_FRAME_LEN {
84 self.buffer.clear();
85 return Some(Err(FrameError::TooLarge(length)));
86 }
87 if self.buffer.len() < 4 + length {
36 return None; 88 return None;
37 } 89 }
38 let mut rest = std::mem::take(&mut self.buffer); 90
39 if rest.last() == Some(&b'\r') { 91 let payload: Vec<u8> = self.buffer[4..4 + length].to_vec();
40 rest.pop(); 92 self.buffer.drain(..4 + length);
93 match String::from_utf8(payload) {
94 Ok(text) => Some(Ok(text)),
95 Err(_) => Some(Err(FrameError::InvalidUtf8)),
41 } 96 }
42 Some(String::from_utf8_lossy(&rest).into_owned())
43 } 97 }
44 98
45 /// Read one line from a blocking reader, preserving any extra lines for 99 /// Read one frame from a blocking reader, preserving leftover bytes.
46 /// the next call. Returns `None` at EOF with nothing buffered. 100 /// `None` means EOF with no complete frame buffered.
47 pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { 101 pub fn read_frame<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> {
48 loop { 102 loop {
49 if let Some(line) = self.next_line() { 103 match self.next_frame() {
50 return Ok(Some(line)); 104 Some(Ok(frame)) => return Ok(Some(frame)),
105 Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
106 None => {}
51 } 107 }
52 108
53 let mut chunk = [0u8; 4096]; 109 let mut chunk = [0u8; 4096];
54 match reader.read(&mut chunk) { 110 match reader.read(&mut chunk) {
55 Ok(0) => return Ok(self.take_rest()), 111 Ok(0) => return Ok(None),
56 Ok(n) => self.push(&chunk[..n]), 112 Ok(n) => self.push(&chunk[..n]),
57 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, 113 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
58 Err(error) => return Err(error), 114 Err(error) => return Err(error),
59 } 115 }
60 } 116 }
61 } 117 }
118
119 /// True when a partial frame is buffered (used by tests and diagnostics).
120 pub fn is_empty(&self) -> bool {
121 self.buffer.is_empty()
122 }
123}
124
125/// Encode one payload as a length-prefixed frame.
126pub fn encode_frame(payload: &str) -> Vec<u8> {
127 let length = payload.len() as u32;
128 let mut bytes = Vec::with_capacity(4 + payload.len());
129 bytes.extend_from_slice(&length.to_be_bytes());
130 bytes.extend_from_slice(payload.as_bytes());
131 bytes
132}
133
134/// Write one payload as a length-prefixed frame.
135pub fn write_frame<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> {
136 writer.write_all(&encode_frame(payload))?;
137 writer.flush()
138}
139
140/// One client request. `id` is the correlation key; `idem` is optional
141/// metadata for logs and a future cross-connection cache.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Request {
144 pub id: u64,
145 pub idem: Option<String>,
146 pub payload: String,
147}
148
149/// A frame received by a client.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum Packet {
152 Reply { id: u64, body: String },
153 Event { seq: u64, body: String },
154}
155
156pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String {
157 match idem {
158 Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"),
159 _ => format!("REQ {id} {payload}"),
160 }
161}
162
163pub fn reply_line(id: u64, body: &str) -> String {
164 format!("RES {id} {body}")
165}
166
167pub fn event_line(seq: u64, body: &str) -> String {
168 format!("EVT {seq} {body}")
169}
170
171/// Parse a `REQ` payload. Errors are safe to return to the peer.
172pub fn parse_request(frame: &str) -> Result<Request, String> {
173 let frame = frame.trim_end();
174 let rest = frame
175 .strip_prefix("REQ ")
176 .ok_or_else(|| "Malformed request: expected REQ".to_string())?;
177
178 let (id_token, mut rest) = split_token(rest);
179 let id: u64 = id_token
180 .parse()
181 .map_err(|_| "Malformed request id".to_string())?;
182
183 let mut idem = None;
184 let (next, after) = split_token(rest);
185 if let Some(value) = next.strip_prefix("idem=") {
186 if value.is_empty() {
187 return Err("Malformed idempotency key".to_string());
188 }
189 idem = Some(value.to_string());
190 rest = after;
191 }
192
193 Ok(Request {
194 id,
195 idem,
196 payload: rest.to_string(),
197 })
198}
199
200/// Parse a `RES` or `EVT` payload on the client side.
201pub fn parse_packet(frame: &str) -> Result<Packet, String> {
202 let frame = frame.trim_end();
203 if let Some(rest) = frame.strip_prefix("RES ") {
204 let (id_token, body) = split_token(rest);
205 let id: u64 = id_token
206 .parse()
207 .map_err(|_| "Malformed reply id".to_string())?;
208 return Ok(Packet::Reply {
209 id,
210 body: body.to_string(),
211 });
212 }
213 if let Some(rest) = frame.strip_prefix("EVT ") {
214 let (seq_token, body) = split_token(rest);
215 let seq: u64 = seq_token
216 .parse()
217 .map_err(|_| "Malformed event sequence".to_string())?;
218 return Ok(Packet::Event {
219 seq,
220 body: body.to_string(),
221 });
222 }
223 Err(format!("Unexpected packet: {frame}"))
224}
225
226fn split_token(rest: &str) -> (&str, &str) {
227 let rest = rest.trim_start();
228 match rest.find(' ') {
229 Some(index) => (&rest[..index], rest[index + 1..].trim_start()),
230 None => (rest, ""),
231 }
62} 232}
63 233
64/// A stable, user-facing message for a failed request. 234/// A stable, user-facing message for a failed request.
diff --git a/src/server/core.rs b/src/server/core.rs
index 3cd6469..0b2efb4 100644
--- a/src/server/core.rs
+++ b/src/server/core.rs
@@ -1,22 +1,26 @@
1use bcrypt::{hash, verify, DEFAULT_COST}; 1use bcrypt::{DEFAULT_COST, hash, verify};
2use rusqlite::Connection; 2use rusqlite::Connection;
3use std::collections::HashMap; 3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc; 4use std::sync::Arc;
5 5
6use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState; 7use crate::crash::CrashState;
8use crate::protocol::{REPLAY_CACHE_LEN, Request};
8use crate::server::database; 9use crate::server::database;
9use crate::version::{download_url, hello_line, Version, VERSION};
10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; 11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
12use crate::server::games::registry::GameRegistry; 12use crate::server::games::registry::GameRegistry;
13use crate::server::games::session::SessionManager; 13use crate::server::games::session::SessionManager;
14use crate::server::games::traits::{GameError, GamePhase, GameState}; 14use crate::server::games::traits::{GameError, GamePhase, GameState};
15use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms};
15 16
16#[derive(Debug, Default)] 17#[derive(Debug, Default)]
17pub struct ConnectionState { 18pub struct ConnectionState {
18 user: Option<String>, 19 user: Option<String>,
19 greeted: bool, 20 greeted: bool,
21 /// Last replies on this connection, oldest first. Request ids are unique
22 /// while cached, so a retried request replays its first result.
23 replays: VecDeque<(u64, String, String)>,
20} 24}
21 25
22/// Settle reservations left behind by a previous process. 26/// Settle reservations left behind by a previous process.
@@ -39,6 +43,27 @@ impl ConnectionState {
39 pub fn greeted(&self) -> bool { 43 pub fn greeted(&self) -> bool {
40 self.greeted 44 self.greeted
41 } 45 }
46
47 fn replay(&self, id: u64, payload: &str) -> Option<Result<String, String>> {
48 self.replays
49 .iter()
50 .find(|(cached_id, _, _)| *cached_id == id)
51 .map(|(_, cached_payload, reply)| {
52 if cached_payload == payload {
53 Ok(reply.clone())
54 } else {
55 Err("ERROR Request id reused with a different command".to_string())
56 }
57 })
58 }
59
60 fn remember(&mut self, id: u64, payload: &str, reply: &str) {
61 if self.replays.len() >= REPLAY_CACHE_LEN {
62 self.replays.pop_front();
63 }
64 self.replays
65 .push_back((id, payload.to_string(), reply.to_string()));
66 }
42} 67}
43 68
44pub struct ServerCore { 69pub struct ServerCore {
@@ -49,6 +74,8 @@ pub struct ServerCore {
49 blackjack: BlackjackGame, 74 blackjack: BlackjackGame,
50 crash: CrashGame, 75 crash: CrashGame,
51 clock: Arc<dyn Clock>, 76 clock: Arc<dyn Clock>,
77 /// Last time a crash tick was pushed, in seconds on the game clock.
78 last_tick: HashMap<String, f64>,
52} 79}
53 80
54impl ServerCore { 81impl ServerCore {
@@ -65,6 +92,7 @@ impl ServerCore {
65 blackjack: BlackjackGame::new(), 92 blackjack: BlackjackGame::new(),
66 crash: CrashGame::new(), 93 crash: CrashGame::new(),
67 clock: Arc::new(RealClock::new()), 94 clock: Arc::new(RealClock::new()),
95 last_tick: HashMap::new(),
68 } 96 }
69 } 97 }
70 98
@@ -96,6 +124,21 @@ impl ServerCore {
96 Ok(Self::new(conn)) 124 Ok(Self::new(conn))
97 } 125 }
98 126
127 /// Handle one enveloped request, replaying a cached reply for a repeated
128 /// request id. The payload grammar is unchanged; the envelope only adds
129 /// correlation and idempotency.
130 pub fn handle_request(&mut self, state: &mut ConnectionState, request: &Request) -> String {
131 if let Some(reply) = state.replay(request.id, &request.payload) {
132 return match reply {
133 Ok(reply) => reply,
134 Err(message) => message,
135 };
136 }
137 let reply = self.handle_message(state, &request.payload);
138 state.remember(request.id, &request.payload, &reply);
139 reply
140 }
141
99 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { 142 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
100 let parts: Vec<&str> = msg.splitn(5, ' ').collect(); 143 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
101 144
@@ -201,7 +244,7 @@ impl ServerCore {
201 } 244 }
202 } 245 }
203 } 246 }
204 "PING" => "PONG".to_string(), 247 "PING" => pong_line(unix_time_ms()),
205 "INFO" => self.handle_info(state), 248 "INFO" => self.handle_info(state),
206 _ => msg.to_string(), 249 _ => msg.to_string(),
207 } 250 }
@@ -212,7 +255,8 @@ impl ServerCore {
212 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string(); 255 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string();
213 } 256 }
214 257
215 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { 258 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION))
259 else {
216 return "ERROR Invalid version".to_string(); 260 return "ERROR Invalid version".to_string();
217 }; 261 };
218 262
@@ -225,7 +269,7 @@ impl ServerCore {
225 } 269 }
226 270
227 state.greeted = true; 271 state.greeted = true;
228 hello_line(VERSION, VERSION, &download_url()) 272 hello_line(VERSION, VERSION, &download_url(), unix_time_ms())
229 } 273 }
230 274
231 fn handle_register(&mut self, username: &str, credential: &str) -> String { 275 fn handle_register(&mut self, username: &str, credential: &str) -> String {
@@ -529,8 +573,8 @@ impl ServerCore {
529 573
530 // The session is the audit guard for the deduction, so it exists before 574 // The session is the audit guard for the deduction, so it exists before
531 // the balance is touched. 575 // the balance is touched.
532 let crash_point = self.crash.next_crash_point(); 576 let seed = self.crash.next_seed();
533 let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); 577 let round = CrashRound::new(bet as u64, seed, self.clock.now(), unix_time_ms());
534 let mut data = HashMap::new(); 578 let mut data = HashMap::new();
535 data.insert("round".to_string(), round.to_json()); 579 data.insert("round".to_string(), round.to_json());
536 data.insert("balance".to_string(), balance.to_string()); 580 data.insert("balance".to_string(), balance.to_string());
@@ -643,11 +687,27 @@ impl ServerCore {
643 if !round.is_running() { 687 if !round.is_running() {
644 continue; 688 continue;
645 } 689 }
646 let elapsed = (self.clock.now() - round.started_at).max(0.0); 690 let now = self.clock.now();
647 if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { 691 let elapsed = (now - round.started_at).max(0.0);
692 let multiplier = crate::crash::multiplier_at_secs(elapsed);
693 if multiplier < round.crash_point {
694 let last = self
695 .last_tick
696 .get(&session.session_id)
697 .copied()
698 .unwrap_or(f64::NEG_INFINITY);
699 if now - last >= 0.1 {
700 self.last_tick.insert(session.session_id.clone(), now);
701 out.push(format!(
702 "crash.tick session={} t={} m={multiplier:.4}",
703 session.session_id,
704 unix_time_ms()
705 ));
706 }
648 continue; 707 continue;
649 } 708 }
650 round.crash(); 709 round.crash();
710 self.last_tick.remove(&session.session_id);
651 match self.settle_crash(user, &session.session_id, &round, 0) { 711 match self.settle_crash(user, &session.session_id, &round, 0) {
652 Ok(balance) => { 712 Ok(balance) => {
653 let state = round.state(&session.session_id, balance); 713 let state = round.state(&session.session_id, balance);
@@ -678,6 +738,7 @@ impl ServerCore {
678 payout as i64, 738 payout as i64,
679 &details, 739 &details,
680 ); 740 );
741 self.last_tick.remove(session_id);
681 let _ = self.sessions.remove_session(session_id); 742 let _ = self.sessions.remove_session(session_id);
682 result.map_err(|error| error.to_string()) 743 result.map_err(|error| error.to_string())
683 } 744 }
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs
index ba73c7f..1728afc 100644
--- a/src/server/games/crash.rs
+++ b/src/server/games/crash.rs
@@ -1,5 +1,8 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; 1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use crate::crash::{crash_point_from_uniform, payout, CrashState}; 2use crate::crash::{
3 CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform,
4};
5use rand::RngCore;
3use serde::{Deserialize, Serialize}; 6use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, VecDeque}; 7use std::collections::{HashMap, VecDeque};
5use std::sync::{Arc, Mutex}; 8use std::sync::{Arc, Mutex};
@@ -64,7 +67,14 @@ pub struct CrashRound {
64 pub bet: u64, 67 pub bet: u64,
65 /// Drawn at start, revealed only when the round ends. 68 /// Drawn at start, revealed only when the round ends.
66 pub crash_point: f64, 69 pub crash_point: f64,
70 /// The 32 random bytes the crash point commits to, hex encoded. Kept on
71 /// the server until the round ends.
72 pub seed: String,
73 /// `sha256(seed)` in hex, sent with the start reply.
74 pub commit: String,
67 pub started_at: f64, 75 pub started_at: f64,
76 /// Server Unix time at start, milliseconds, for clock sync.
77 pub started_at_ms: u64,
68 /// `running`, `cashed`, `crashed` or `forfeit`. 78 /// `running`, `cashed`, `crashed` or `forfeit`.
69 pub phase: String, 79 pub phase: String,
70 pub result: String, 80 pub result: String,
@@ -74,11 +84,15 @@ pub struct CrashRound {
74} 84}
75 85
76impl CrashRound { 86impl CrashRound {
77 pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { 87 pub fn new(bet: u64, seed: [u8; SEED_LEN], started_at: f64, started_at_ms: u64) -> Self {
88 let crash_point = crash_point_from_seed(&seed);
78 Self { 89 Self {
79 bet, 90 bet,
80 crash_point, 91 crash_point,
92 seed: seed_hex(&seed),
93 commit: commit_seed(&seed),
81 started_at, 94 started_at,
95 started_at_ms,
82 phase: "running".to_string(), 96 phase: "running".to_string(),
83 result: "playing".to_string(), 97 result: "playing".to_string(),
84 multiplier: 1.0, 98 multiplier: 1.0,
@@ -120,6 +134,7 @@ impl CrashRound {
120 balance, 134 balance,
121 multiplier: self.multiplier, 135 multiplier: self.multiplier,
122 potential: payout(self.bet, self.multiplier), 136 potential: payout(self.bet, self.multiplier),
137 started_at: self.started_at_ms,
123 crash_point: if self.is_running() { 138 crash_point: if self.is_running() {
124 None 139 None
125 } else { 140 } else {
@@ -127,6 +142,12 @@ impl CrashRound {
127 }, 142 },
128 payout: self.payout, 143 payout: self.payout,
129 result: self.result.clone(), 144 result: self.result.clone(),
145 commit: Some(self.commit.clone()),
146 seed: if self.is_running() {
147 None
148 } else {
149 Some(self.seed.clone())
150 },
130 } 151 }
131 } 152 }
132 153
@@ -146,6 +167,34 @@ pub struct CrashGame {
146 uniforms: VecDeque<f64>, 167 uniforms: VecDeque<f64>,
147} 168}
148 169
170/// Hex encode a round seed. Lowercase, no separators.
171fn seed_hex(seed: &[u8]) -> String {
172 let mut out = String::with_capacity(seed.len() * 2);
173 for byte in seed {
174 use std::fmt::Write as _;
175 let _ = write!(out, "{byte:02x}");
176 }
177 out
178}
179
180/// Draw a seed whose committed point matches a queued test uniform.
181///
182/// The first eight bytes are the big-endian integer closest to
183/// `uniform * 2^64`; the remaining bytes are zero. This keeps the existing
184/// deterministic crash-point tests meaningful while every round still has a
185/// real seed.
186fn seed_from_uniform(uniform: f64) -> [u8; SEED_LEN] {
187 let uniform = if uniform.is_finite() {
188 uniform.clamp(0.0, 0.999_999_999_999_999_9)
189 } else {
190 0.0
191 };
192 let value = (uniform * 18_446_744_073_709_551_616.0) as u64;
193 let mut seed = [0u8; SEED_LEN];
194 seed[..8].copy_from_slice(&value.to_be_bytes());
195 seed
196}
197
149impl CrashGame { 198impl CrashGame {
150 pub fn new() -> Self { 199 pub fn new() -> Self {
151 Self { 200 Self {
@@ -163,12 +212,25 @@ impl CrashGame {
163 } 212 }
164 } 213 }
165 214
215 /// Draw the next round seed. Tests supply uniforms; production uses the
216 /// operating system's random source.
217 pub fn next_seed(&mut self) -> [u8; SEED_LEN] {
218 if let Some(uniform) = self.uniforms.pop_front() {
219 return seed_from_uniform(uniform);
220 }
221 let mut seed = [0u8; SEED_LEN];
222 rand::thread_rng().fill_bytes(&mut seed);
223 seed
224 }
225
226 /// Kept for callers that only need the point a queued uniform encodes.
166 pub fn next_crash_point(&mut self) -> f64 { 227 pub fn next_crash_point(&mut self) -> f64 {
167 let uniform = self 228 crash_point_from_seed(&self.next_seed())
168 .uniforms 229 }
169 .pop_front() 230
170 .unwrap_or_else(rand::random::<f64>); 231 /// The uniform encoded by a seed, exposed for verification tests.
171 crash_point_from_uniform(uniform) 232 pub fn uniform_of(seed: &[u8; SEED_LEN]) -> f64 {
233 seed_to_uniform(seed)
172 } 234 }
173} 235}
174 236
diff --git a/src/version.rs b/src/version.rs
index c1dcee5..adc2a8d 100644
--- a/src/version.rs
+++ b/src/version.rs
@@ -5,6 +5,7 @@
5//! accepted until the server has answered it. 5//! accepted until the server has answered it.
6 6
7use std::env; 7use std::env;
8use std::time::{SystemTime, UNIX_EPOCH};
8 9
9/// Version this build reports. The client and the server share it. 10/// Version this build reports. The client and the server share it.
10pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -73,9 +74,9 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
73 } 74 }
74} 75}
75 76
76/// The server line that accepts a handshake. 77/// The server line that accepts a handshake. `time` is Unix milliseconds.
77pub fn hello_line(version: &str, latest: &str, url: &str) -> String { 78pub fn hello_line(version: &str, latest: &str, url: &str, time: u64) -> String {
78 format!("HELLO {version} latest={latest} url={url}") 79 format!("HELLO {version} latest={latest} url={url} time={time}")
79} 80}
80 81
81#[derive(Debug, Clone, PartialEq, Eq)] 82#[derive(Debug, Clone, PartialEq, Eq)]
@@ -83,9 +84,11 @@ pub struct HelloReply {
83 pub version: String, 84 pub version: String,
84 pub latest: String, 85 pub latest: String,
85 pub url: String, 86 pub url: String,
87 /// Server clock at the moment the reply was built, Unix milliseconds.
88 pub time: u64,
86} 89}
87 90
88/// Parse `HELLO <version> latest=<version> url=<url>`. 91/// Parse `HELLO <version> latest=<version> url=<url> time=<unix_ms>`.
89pub fn parse_hello(line: &str) -> Option<HelloReply> { 92pub fn parse_hello(line: &str) -> Option<HelloReply> {
90 let rest = line.trim().strip_prefix("HELLO ")?; 93 let rest = line.trim().strip_prefix("HELLO ")?;
91 let mut tokens = rest.split_whitespace(); 94 let mut tokens = rest.split_whitespace();
@@ -93,11 +96,14 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> {
93 96
94 let mut latest = None; 97 let mut latest = None;
95 let mut url = None; 98 let mut url = None;
99 let mut time = None;
96 for token in tokens { 100 for token in tokens {
97 if let Some(value) = token.strip_prefix("latest=") { 101 if let Some(value) = token.strip_prefix("latest=") {
98 latest = Some(value.to_string()); 102 latest = Some(value.to_string());
99 } else if let Some(value) = token.strip_prefix("url=") { 103 } else if let Some(value) = token.strip_prefix("url=") {
100 url = Some(value.to_string()); 104 url = Some(value.to_string());
105 } else if let Some(value) = token.strip_prefix("time=") {
106 time = value.parse().ok();
101 } 107 }
102 } 108 }
103 109
@@ -105,9 +111,29 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> {
105 version, 111 version,
106 latest: latest?, 112 latest: latest?,
107 url: url?, 113 url: url?,
114 time: time?,
108 }) 115 })
109} 116}
110 117
118/// The reply to `PING`: `PONG <unix_ms>`.
119pub fn pong_line(time: u64) -> String {
120 format!("PONG {time}")
121}
122
123/// Parse a `PONG` reply's server time.
124pub fn parse_pong(line: &str) -> Option<u64> {
125 line.trim().strip_prefix("PONG ")?.trim().parse().ok()
126}
127
128/// Current Unix time in milliseconds. Used for handshake timestamps and crash
129/// anchors; a system clock before the epoch reads as zero.
130pub fn unix_time_ms() -> u64 {
131 SystemTime::now()
132 .duration_since(UNIX_EPOCH)
133 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
134 .unwrap_or(0)
135}
136
111/// Download page advertised by the server. `DOWNLOAD_URL` wins when set. 137/// Download page advertised by the server. `DOWNLOAD_URL` wins when set.
112pub fn download_url() -> String { 138pub fn download_url() -> String {
113 env::var("DOWNLOAD_URL") 139 env::var("DOWNLOAD_URL")
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 2f43d47..3a2fb27 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -7,17 +7,17 @@
7use std::time::Duration; 7use std::time::Duration;
8use zagan_bet::blackjack::BlackjackState; 8use zagan_bet::blackjack::BlackjackState;
9use zagan_bet::client::core::{ClientCore, Effect}; 9use zagan_bet::client::core::{ClientCore, Effect};
10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
11use zagan_bet::client::shell::{self, Shell};
12use zagan_bet::client::ui::help_lines;
13use zagan_bet::client::games::response::{ 10use zagan_bet::client::games::response::{
14 parse_blackjack_response, parse_crash_response, parse_spin_response, 11 parse_blackjack_response, parse_crash_response, parse_spin_response,
15}; 12};
16use zagan_bet::client::games::roulette::{Cursor, Direction}; 13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input};
15use zagan_bet::client::shell::{self, Shell};
17use zagan_bet::client::ui::command_names; 16use zagan_bet::client::ui::command_names;
17use zagan_bet::client::ui::help_lines;
18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; 18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View};
19use zagan_bet::roulette::BetTarget;
20use zagan_bet::crash::{CrashState, multiplier_at}; 19use zagan_bet::crash::{CrashState, multiplier_at};
20use zagan_bet::roulette::BetTarget;
21 21
22const WIN3: &str = "CCCCLLCLODGSDGS"; 22const WIN3: &str = "CCCCLLCLODGSDGS";
23const LOSS: &str = "CLOOGDSLCDCOGLS"; 23const LOSS: &str = "CLOOGDSLCDCOGLS";
@@ -54,7 +54,7 @@ impl Screen {
54 self.region_start = None; 54 self.region_start = None;
55 self.region_len = 0; 55 self.region_len = 0;
56 } 56 }
57 Effect::SendLine(line) => sent.push(line.clone()), 57 Effect::SendRequest { payload, .. } => sent.push(payload.clone()),
58 Effect::Quit | Effect::Disconnect => {} 58 Effect::Quit | Effect::Disconnect => {}
59 } 59 }
60 } 60 }
@@ -601,7 +601,7 @@ fn ping_prints_pong() {
601 h.line("ping"); 601 h.line("ping");
602 assert_eq!(h.sent.last().map(String::as_str), Some("PING")); 602 assert_eq!(h.sent.last().map(String::as_str), Some("PING"));
603 603
604 h.reply("PONG"); 604 h.reply("PONG 1700000000000");
605 605
606 assert!(h.screen.lines.iter().any(|line| line == "Pong")); 606 assert!(h.screen.lines.iter().any(|line| line == "Pong"));
607} 607}
@@ -968,6 +968,13 @@ fn crash_state(
968 crash_point, 968 crash_point,
969 payout, 969 payout,
970 result: phase.to_string(), 970 result: phase.to_string(),
971 started_at: 1_700_000_000_000,
972 commit: Some("00".repeat(32)),
973 seed: if phase == "running" {
974 None
975 } else {
976 Some("00".repeat(32))
977 },
971 } 978 }
972} 979}
973 980
@@ -1221,11 +1228,12 @@ fn history_prints_recent_plays() {
1221 let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; 1228 let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#;
1222 h.reply(&format!("HISTORY {json}")); 1229 h.reply(&format!("HISTORY {json}"));
1223 1230
1224 assert!(h 1231 assert!(
1225 .screen 1232 h.screen
1226 .lines 1233 .lines
1227 .iter() 1234 .iter()
1228 .any(|line| line.starts_with("Recent plays (1)"))); 1235 .any(|line| line.starts_with("Recent plays (1)"))
1236 );
1229 let row = h 1237 let row = h
1230 .screen 1238 .screen
1231 .lines 1239 .lines
@@ -1242,11 +1250,12 @@ fn history_without_plays_says_so() {
1242 let mut h = Harness::new(); 1250 let mut h = Harness::new();
1243 h.line("history"); 1251 h.line("history");
1244 h.reply("HISTORY []"); 1252 h.reply("HISTORY []");
1245 assert!(h 1253 assert!(
1246 .screen 1254 h.screen
1247 .lines 1255 .lines
1248 .iter() 1256 .iter()
1249 .any(|line| line == "No plays recorded yet.")); 1257 .any(|line| line == "No plays recorded yet.")
1258 );
1250} 1259}
1251 1260
1252#[test] 1261#[test]
@@ -1254,11 +1263,12 @@ fn history_rejects_a_bad_count_without_sending() {
1254 let mut h = Harness::new(); 1263 let mut h = Harness::new();
1255 h.line("history 0"); 1264 h.line("history 0");
1256 assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); 1265 assert!(!h.sent.iter().any(|line| line == "HISTORY 0"));
1257 assert!(h 1266 assert!(
1258 .screen 1267 h.screen
1259 .lines 1268 .lines
1260 .iter() 1269 .iter()
1261 .any(|line| line.contains("Usage: history [1-50]"))); 1270 .any(|line| line.contains("Usage: history [1-50]"))
1271 );
1262} 1272}
1263 1273
1264#[test] 1274#[test]
@@ -1291,10 +1301,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() {
1291 Some(BetTarget::Straight { number: 2 }) 1301 Some(BetTarget::Straight { number: 2 })
1292 ); 1302 );
1293 assert_eq!(view.bets.total(), 10); 1303 assert_eq!(view.bets.total(), 10);
1294 assert_eq!( 1304 assert_eq!(view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10);
1295 view.bets.amount_on(&BetTarget::Straight { number: 2 }),
1296 10
1297 );
1298} 1305}
1299 1306
1300#[test] 1307#[test]
@@ -1302,7 +1309,9 @@ fn clicking_a_bet_target_does_not_move_the_cursor() {
1302 let mut h = Harness::new(); 1309 let mut h = Harness::new();
1303 h.enter_roulette(); 1310 h.enter_roulette();
1304 1311
1305 let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); 1312 let effects = h
1313 .core
1314 .command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 }));
1306 h.apply(effects); 1315 h.apply(effects);
1307 1316
1308 let Some(View::Roulette(view)) = h.core.view() else { 1317 let Some(View::Roulette(view)) = h.core.view() else {
@@ -1387,9 +1396,7 @@ fn the_slots_view_carries_the_result() {
1387 let mut h = Harness::new(); 1396 let mut h = Harness::new();
1388 h.enter_slots("10"); 1397 h.enter_slots("10");
1389 h.key(Key::Char(' ')); 1398 h.key(Key::Char(' '));
1390 h.reply(&format!( 1399 h.reply(&format!("GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6"));
1391 "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6"
1392 ));
1393 1400
1394 let Some(View::Slots(view)) = h.core.view() else { 1401 let Some(View::Slots(view)) = h.core.view() else {
1395 panic!("expected a slots view"); 1402 panic!("expected a slots view");
@@ -1518,11 +1525,12 @@ fn history_is_a_screen_and_keeps_its_records() {
1518 assert_eq!(view.records[0].summary(), "cashed 1.13x"); 1525 assert_eq!(view.records[0].summary(), "cashed 1.13x");
1519 1526
1520 // The terminal adapter still prints the table. 1527 // The terminal adapter still prints the table.
1521 assert!(h 1528 assert!(
1522 .screen 1529 h.screen
1523 .lines 1530 .lines
1524 .iter() 1531 .iter()
1525 .any(|line| line.starts_with("Recent plays (1)"))); 1532 .any(|line| line.starts_with("Recent plays (1)"))
1533 );
1526} 1534}
1527 1535
1528#[test] 1536#[test]
@@ -1563,11 +1571,12 @@ fn a_login_command_sends_auth_and_sets_the_user() {
1563 }; 1571 };
1564 assert_eq!(lobby.user.as_deref(), Some("alice")); 1572 assert_eq!(lobby.user.as_deref(), Some("alice"));
1565 assert!(lobby.login_error.is_none()); 1573 assert!(lobby.login_error.is_none());
1566 assert!(h 1574 assert!(
1567 .screen 1575 h.screen
1568 .lines 1576 .lines
1569 .iter() 1577 .iter()
1570 .any(|line| line == "Logged in as alice")); 1578 .any(|line| line == "Logged in as alice")
1579 );
1571} 1580}
1572 1581
1573#[test] 1582#[test]
@@ -1600,11 +1609,12 @@ fn a_register_command_sends_register_and_reports_the_result() {
1600 assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); 1609 assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash"));
1601 1610
1602 h.reply("REGISTERED bob"); 1611 h.reply("REGISTERED bob");
1603 assert!(h 1612 assert!(
1604 .screen 1613 h.screen
1605 .lines 1614 .lines
1606 .iter() 1615 .iter()
1607 .any(|line| line == "Account created. Log in to play.")); 1616 .any(|line| line == "Account created. Log in to play.")
1617 );
1608} 1618}
1609 1619
1610#[test] 1620#[test]
@@ -1646,3 +1656,56 @@ fn the_crash_multiplier_is_not_rounded_for_the_chart() {
1646 "a two-decimal store would flatten the curve into steps" 1656 "a two-decimal store would flatten the curve into steps"
1647 ); 1657 );
1648} 1658}
1659
1660#[test]
1661fn a_reply_is_routed_by_request_id_and_late_replies_are_ignored() {
1662 let mut h = Harness::new();
1663 let first = h.core.command(Command::Ping);
1664 let first_id = first
1665 .iter()
1666 .find_map(|effect| match effect {
1667 Effect::SendRequest { id, .. } => Some(*id),
1668 _ => None,
1669 })
1670 .unwrap();
1671
1672 let second = h.core.command(Command::Info);
1673 let second_id = second
1674 .iter()
1675 .find_map(|effect| match effect {
1676 Effect::SendRequest { id, .. } => Some(*id),
1677 _ => None,
1678 })
1679 .unwrap();
1680 assert_ne!(first_id, second_id, "ids must be unique on the connection");
1681
1682 // The ping reply arrives after INFO was sent. It must not complete INFO.
1683 assert!(
1684 h.core
1685 .handle_reply_id(first_id, "PONG 1700000000000")
1686 .is_empty()
1687 );
1688 let effects = h.core.handle_reply_id(second_id, "INFO alice 1 1000");
1689 assert!(
1690 effects.iter().any(|effect| matches!(effect, Effect::Print(lines) if lines.iter().any(|line| line.contains("Balance: 1000")))),
1691 "the matching reply is applied"
1692 );
1693}
1694
1695#[test]
1696fn a_crash_tick_updates_the_board_for_its_own_session() {
1697 let mut h = Harness::new();
1698 h.enter_crash();
1699 h.key(Key::Char(' '));
1700 let running = crash_state("running", 1.0, None, 0, 990);
1701 h.reply(&crash_line("CRASH_START", &running));
1702 assert!(h.board()[2].contains("Multiplier: 1.00x"));
1703
1704 h.push("crash.tick session=cr1 t=1700000000100 m=1.5");
1705 assert!(h.board()[2].contains("Multiplier: 1.50x"));
1706 assert!(h.board()[3].contains("Potential: 15 Kr"));
1707
1708 // A tick for another round cannot move this one.
1709 h.push("crash.tick session=other t=1700000000200 m=9.0");
1710 assert!(h.board()[2].contains("Multiplier: 1.50x"));
1711}
diff --git a/tests/crash.rs b/tests/crash.rs
index 797874b..513f9c8 100644
--- a/tests/crash.rs
+++ b/tests/crash.rs
@@ -102,3 +102,42 @@ fn crash_points_are_clamped_to_the_liability_cap() {
102 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); 102 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER);
103 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); 103 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER);
104} 104}
105
106#[test]
107fn a_seed_commits_to_its_crash_point_and_can_be_verified() {
108 use zagan_bet::crash::{
109 SEED_LEN, commit_seed, crash_point_from_seed, seed_from_hex, seed_to_uniform, verify_seed,
110 };
111
112 let mut seed = [0u8; SEED_LEN];
113 let value = (0.67_f64 * 18_446_744_073_709_551_616.0) as u64;
114 seed[..8].copy_from_slice(&value.to_be_bytes());
115 let commit = commit_seed(&seed);
116 let hex = format!(
117 "{}",
118 seed.iter()
119 .map(|byte| format!("{byte:02x}"))
120 .collect::<String>()
121 );
122
123 assert_eq!(seed_from_hex(&hex).as_deref(), Some(seed.as_slice()));
124 assert!(verify_seed(&hex, &commit));
125 assert!(!verify_seed(&hex, &"00".repeat(32)), "a wrong commit fails");
126 assert!(!verify_seed("nope", &commit), "bad hex fails");
127
128 // The point the client recomputes is the point the server committed to.
129 close(crash_point_from_seed(&seed), 3.0, 1e-9);
130 close(crash_point_from_seed(&seed), 3.0, 1e-9);
131
132 // The first eight bytes are the uniform; the rest are still hashed.
133 close(seed_to_uniform(&seed), 0.67, 1e-9);
134 let mut altered = seed;
135 altered[SEED_LEN - 1] = 1;
136 assert!(!verify_seed(
137 &altered
138 .iter()
139 .map(|byte| format!("{byte:02x}"))
140 .collect::<String>(),
141 &commit
142 ));
143}
diff --git a/tests/protocol.rs b/tests/protocol.rs
index bf8a4fd..d829ba9 100644
--- a/tests/protocol.rs
+++ b/tests/protocol.rs
@@ -1,84 +1,136 @@
1use std::io::{Cursor, Error, ErrorKind}; 1use std::io::Cursor;
2use zagan_bet::protocol::{
3 FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message,
4 parse_packet, parse_request, reply_line, request_line,
5};
2 6
3use zagan_bet::protocol::{io_failure_message, LineBuffer}; 7fn framed(payload: &str) -> Vec<u8> {
8 encode_frame(payload)
9}
4 10
5#[test] 11#[test]
6fn keeps_data_after_the_first_newline() { 12fn decodes_every_complete_frame_and_keeps_the_remainder() {
7 let mut buffer = LineBuffer::new(); 13 let mut buffer = FrameBuffer::new();
8 buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); 14 let mut bytes = framed("RES 1 PONG 1700000000000");
15 bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5"));
16 buffer.push(&bytes);
9 17
10 assert_eq!(buffer.next_line().as_deref(), Some("PONG"));
11 assert_eq!( 18 assert_eq!(
12 buffer.next_line().as_deref(), 19 buffer.next_frame().unwrap().unwrap(),
13 Some("GAME_RESULT session 60 10 990 OSS two") 20 "RES 1 PONG 1700000000000"
21 );
22 assert_eq!(
23 buffer.next_frame().unwrap().unwrap(),
24 "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5"
14 ); 25 );
15 assert_eq!(buffer.next_line(), None); 26 assert!(buffer.next_frame().is_none());
27 assert!(buffer.is_empty());
16} 28}
17 29
18#[test] 30#[test]
19fn handles_crlf_and_empty_lines() { 31fn waits_for_a_partial_frame() {
20 let mut buffer = LineBuffer::new(); 32 let mut buffer = FrameBuffer::new();
21 buffer.push(b"PONG\r\n\r\n"); 33 let bytes = framed("RES 7 OK");
34 buffer.push(&bytes[..3]);
35 assert!(buffer.next_frame().is_none());
22 36
23 assert_eq!(buffer.next_line().as_deref(), Some("PONG")); 37 buffer.push(&bytes[3..7]);
24 assert_eq!(buffer.next_line().as_deref(), Some("")); 38 assert!(buffer.next_frame().is_none());
25 assert_eq!(buffer.next_line(), None); 39
40 buffer.push(&bytes[7..]);
41 assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK");
26} 42}
27 43
28#[test] 44#[test]
29fn waits_for_a_split_line() { 45fn an_oversized_length_is_rejected_without_waiting_for_bytes() {
30 let mut buffer = LineBuffer::new(); 46 let mut buffer = FrameBuffer::new();
31 buffer.push(b"PO"); 47 buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes()));
32 assert_eq!(buffer.next_line(), None); 48 match buffer.next_frame() {
49 Some(Err(FrameError::TooLarge(length))) => {
50 assert_eq!(length, MAX_FRAME_LEN + 1);
51 }
52 other => panic!("expected an oversize error, got {other:?}"),
53 }
54 assert!(buffer.is_empty(), "the buffer must not keep the bad frame");
55}
33 56
34 buffer.push(b"NG\n"); 57#[test]
35 assert_eq!(buffer.next_line().as_deref(), Some("PONG")); 58fn invalid_utf8_is_a_protocol_error() {
59 let mut buffer = FrameBuffer::new();
60 buffer.push(&1u32.to_be_bytes());
61 buffer.push(&[0xff]);
62 assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8)));
36} 63}
37 64
38#[test] 65#[test]
39fn reads_multiple_lines_from_a_reader() { 66fn reads_frames_from_a_blocking_reader() {
40 let mut buffer = LineBuffer::new(); 67 let mut bytes = framed("RES 1 OK");
41 let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); 68 bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0"));
69 let mut reader = Cursor::new(bytes);
70 let mut buffer = FrameBuffer::new();
42 71
43 assert_eq!( 72 assert_eq!(
44 buffer.read_line(&mut reader).unwrap().as_deref(), 73 buffer.read_frame(&mut reader).unwrap().as_deref(),
45 Some("one") 74 Some("RES 1 OK")
46 ); 75 );
47 assert_eq!( 76 assert_eq!(
48 buffer.read_line(&mut reader).unwrap().as_deref(), 77 buffer.read_frame(&mut reader).unwrap().as_deref(),
49 Some("two") 78 Some("EVT 1 crash.tick session=cr1 t=1 m=1.0")
50 ); 79 );
51 assert_eq!(buffer.read_line(&mut reader).unwrap(), None); 80 assert_eq!(buffer.read_frame(&mut reader).unwrap(), None);
52} 81}
53 82
54#[test] 83#[test]
55fn returns_a_partial_line_at_eof() { 84fn builds_and_parses_the_envelope() {
56 let mut buffer = LineBuffer::new();
57 let mut reader = Cursor::new(b"partial".to_vec());
58
59 assert_eq!( 85 assert_eq!(
60 buffer.read_line(&mut reader).unwrap().as_deref(), 86 request_line(42, None, "PLAY_START crash 50"),
61 Some("partial") 87 "REQ 42 PLAY_START crash 50"
62 ); 88 );
63 assert_eq!(buffer.read_line(&mut reader).unwrap(), None); 89 assert_eq!(
90 request_line(42, Some("abc123"), "PLAY_START crash 50"),
91 "REQ 42 idem=abc123 PLAY_START crash 50"
92 );
93
94 let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap();
95 assert_eq!(request.id, 42);
96 assert_eq!(request.idem.as_deref(), Some("abc123"));
97 assert_eq!(request.payload, "PLAY_START crash 50");
98
99 let request = parse_request("REQ 7 PING").unwrap();
100 assert_eq!(request.id, 7);
101 assert_eq!(request.idem, None);
102 assert_eq!(request.payload, "PING");
103
104 assert!(parse_request("PLAY_START crash 50").is_err());
105 assert!(parse_request("REQ nope PING").is_err());
106 assert!(parse_request("REQ 1 idem= PING").is_err());
64} 107}
65 108
66#[test] 109#[test]
67fn timeout_errors_get_a_timeout_message() { 110fn parses_replies_and_events() {
68 assert_eq!(
69 io_failure_message(&Error::from(ErrorKind::TimedOut)),
70 "ERROR Request timed out"
71 );
72 assert_eq!(
73 io_failure_message(&Error::from(ErrorKind::WouldBlock)),
74 "ERROR Request timed out"
75 );
76 assert_eq!( 111 assert_eq!(
77 io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), 112 parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(),
78 "ERROR Connection lost" 113 Packet::Reply {
114 id: 9,
115 body: "ERROR Insufficient balance".to_string()
116 }
79 ); 117 );
80 assert_eq!( 118 assert_eq!(
81 io_failure_message(&Error::from(ErrorKind::ConnectionReset)), 119 parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(),
82 "ERROR Connection lost" 120 Packet::Event {
121 seq: 3,
122 body: "CRASH_BUSTED {}".to_string()
123 }
83 ); 124 );
125 assert!(parse_packet("REQ 1 PING").is_err());
126 assert!(parse_packet("RES nope OK").is_err());
127}
128
129#[test]
130fn io_failures_are_reported_as_short_messages() {
131 let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow");
132 assert_eq!(io_failure_message(&timeout), "ERROR Request timed out");
133
134 let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone");
135 assert_eq!(io_failure_message(&closed), "ERROR Connection lost");
84} 136}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index e2600e4..6c9c835 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -101,11 +101,11 @@ fn crash_state(response: &str) -> CrashState {
101} 101}
102 102
103fn hello(core: &mut ServerCore, state: &mut ConnectionState) { 103fn hello(core: &mut ServerCore, state: &mut ConnectionState) {
104 let response = core.handle_message( 104 let response = core.handle_message(state, &format!("HELLO {}", zagan_bet::version::VERSION));
105 state, 105 assert!(
106 &format!("HELLO {}", zagan_bet::version::VERSION), 106 response.starts_with("HELLO "),
107 "handshake failed: {response}"
107 ); 108 );
108 assert!(response.starts_with("HELLO "), "handshake failed: {response}");
109} 109}
110 110
111fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 111fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
@@ -258,8 +258,18 @@ fn roulette_settles_split_bets_and_records_details() {
258 258
259 let mut bets = Bets::default(); 259 let mut bets = Bets::default();
260 bets.add(BetTarget::Outside { kind: Outside::Red }, 10); 260 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
261 bets.add(BetTarget::Outside { kind: Outside::Black }, 10); 261 bets.add(
262 bets.add(BetTarget::Outside { kind: Outside::Even }, 10); 262 BetTarget::Outside {
263 kind: Outside::Black,
264 },
265 10,
266 );
267 bets.add(
268 BetTarget::Outside {
269 kind: Outside::Even,
270 },
271 10,
272 );
263 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); 273 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10);
264 bets.add(BetTarget::Straight { number: 17 }, 5); 274 bets.add(BetTarget::Straight { number: 17 }, 5);
265 let bets = bets.to_json(); 275 let bets = bets.to_json();
@@ -671,10 +681,14 @@ fn crash_poll_pushes_the_bust_exactly_once() {
671 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); 681 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
672 682
673 clock.advance(1.0); 683 clock.advance(1.0);
684 let ticks = core.poll(&state);
685 assert_eq!(ticks.len(), 1, "a running round pushes coalesced ticks");
674 assert!( 686 assert!(
675 core.poll(&state).is_empty(), 687 ticks[0].starts_with("crash.tick session="),
676 "a running round must not push anything" 688 "tick: {}",
689 ticks[0]
677 ); 690 );
691 assert!(ticks[0].contains(" m="), "tick carries the multiplier");
678 692
679 clock.advance(15.0); 693 clock.advance(15.0);
680 let pushed = core.poll(&state); 694 let pushed = core.poll(&state);
@@ -920,9 +934,10 @@ fn history_lists_recent_plays_newest_first() {
920 login(&mut core, &mut state, "alice"); 934 login(&mut core, &mut state, "alice");
921 935
922 for _ in 0..3 { 936 for _ in 0..3 {
923 assert!(core 937 assert!(
924 .handle_message(&mut state, "PLAY_START slots 10") 938 core.handle_message(&mut state, "PLAY_START slots 10")
925 .starts_with("GAME_RESULT")); 939 .starts_with("GAME_RESULT")
940 );
926 } 941 }
927 942
928 let response = core.handle_message(&mut state, "HISTORY 2"); 943 let response = core.handle_message(&mut state, "HISTORY 2");
@@ -979,7 +994,10 @@ fn hello_reports_the_version_and_download_url() {
979 assert!(state.greeted()); 994 assert!(state.greeted());
980 995
981 // The gate is open now. 996 // The gate is open now.
982 assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); 997 assert!(
998 core.handle_message(&mut state, "PING").starts_with("PONG "),
999 "PING must carry the server time"
1000 );
983} 1001}
984 1002
985#[test] 1003#[test]
@@ -999,7 +1017,10 @@ fn hello_refuses_a_different_major_or_minor() {
999 response.starts_with("ERROR Version mismatch"), 1017 response.starts_with("ERROR Version mismatch"),
1000 "response: {response}" 1018 "response: {response}"
1001 ); 1019 );
1002 assert!(!state.greeted(), "a refused handshake must not open the gate"); 1020 assert!(
1021 !state.greeted(),
1022 "a refused handshake must not open the gate"
1023 );
1003 assert_eq!( 1024 assert_eq!(
1004 core.handle_message(&mut state, "PING"), 1025 core.handle_message(&mut state, "PING"),
1005 "ERROR HELLO required" 1026 "ERROR HELLO required"
@@ -1014,9 +1035,10 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() {
1014 let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); 1035 let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1);
1015 1036
1016 let mut state = ConnectionState::default(); 1037 let mut state = ConnectionState::default();
1017 assert!(core 1038 assert!(
1018 .handle_message(&mut state, &format!("HELLO {patch}")) 1039 core.handle_message(&mut state, &format!("HELLO {patch}"))
1019 .starts_with("HELLO ")); 1040 .starts_with("HELLO ")
1041 );
1020 1042
1021 let mut other = ConnectionState::default(); 1043 let mut other = ConnectionState::default();
1022 assert_eq!( 1044 assert_eq!(
@@ -1028,3 +1050,70 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() {
1028 "ERROR Invalid version" 1050 "ERROR Invalid version"
1029 ); 1051 );
1030} 1052}
1053
1054#[test]
1055fn crash_start_commits_to_a_seed_and_the_end_reveals_it() {
1056 use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed};
1057
1058 let (mut core, _path, clock) = setup_crash("crash-commit", CRASH_AT_3);
1059 let mut state = ConnectionState::default();
1060 register(&mut core, &mut state, "alice");
1061 login(&mut core, &mut state, "alice");
1062
1063 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
1064 let commit = started.commit.clone().expect("a start reply commits");
1065 assert_eq!(commit.len(), 64, "sha256 hex");
1066 assert_eq!(started.seed, None, "the seed stays hidden while running");
1067 assert_eq!(started.crash_point, None);
1068 assert!(started.started_at > 1_600_000_000_000, "server time anchor");
1069
1070 clock.advance(5.0);
1071 let cashed = crash_state(
1072 &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)),
1073 );
1074 let seed = cashed.seed.clone().expect("the end reveals the seed");
1075 assert!(verify_seed(&seed, &commit), "the reveal matches the commit");
1076 let bytes = seed_from_hex(&seed).expect("32-byte hex seed");
1077 let point = crash_point_from_seed(&bytes);
1078 assert!(
1079 (point - cashed.crash_point.unwrap()).abs() < 1e-9,
1080 "the client recomputes the same point"
1081 );
1082 assert_eq!(cashed.commit.as_deref(), Some(commit.as_str()));
1083}
1084
1085#[test]
1086fn a_repeated_request_id_replays_its_reply_without_charging_twice() {
1087 use zagan_bet::protocol::Request;
1088
1089 let (mut core, _path) = setup("idempotent-spin", WIN3);
1090 let mut state = ConnectionState::default();
1091 register(&mut core, &mut state, "alice");
1092 login(&mut core, &mut state, "alice");
1093
1094 let request = Request {
1095 id: 41,
1096 idem: Some("spin-1".to_string()),
1097 payload: "PLAY_START slots 10".to_string(),
1098 };
1099 let first = core.handle_request(&mut state, &request);
1100 assert!(first.starts_with("GAME_RESULT"), "first: {first}");
1101 assert_eq!(core.balance("alice"), Some(1000));
1102 assert_eq!(core.play_count("alice"), 1);
1103
1104 let replay = core.handle_request(&mut state, &request);
1105 assert_eq!(replay, first, "the cached reply is returned verbatim");
1106 assert_eq!(core.balance("alice"), Some(1000), "no second debit");
1107 assert_eq!(core.play_count("alice"), 1, "no second audit row");
1108
1109 let different = Request {
1110 id: 41,
1111 idem: Some("spin-2".to_string()),
1112 payload: "PLAY_START slots 20".to_string(),
1113 };
1114 assert_eq!(
1115 core.handle_request(&mut state, &different),
1116 "ERROR Request id reused with a different command"
1117 );
1118 assert_eq!(core.balance("alice"), Some(1000));
1119}
diff --git a/tests/version.rs b/tests/version.rs
index af244db..31bc2b6 100644
--- a/tests/version.rs
+++ b/tests/version.rs
@@ -1,5 +1,6 @@
1use zagan_bet::version::{ 1use zagan_bet::version::{
2 compatible, hello_line, is_newer, parse_hello, Version, VERSION, 2 VERSION, Version, compatible, hello_line, is_newer, parse_hello, parse_pong, pong_line,
3 unix_time_ms,
3}; 4};
4 5
5#[test] 6#[test]
@@ -24,16 +25,16 @@ fn versions_parse_and_reject_nonsense() {
24 Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), 25 Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)),
25 Some((4, 0, 0)) 26 Some((4, 0, 0))
26 ); 27 );
27 assert_eq!( 28 assert_eq!(Version::parse("4.0.0+build7").map(|v| v.patch), Some(0));
28 Version::parse("4.0.0+build7").map(|v| v.patch),
29 Some(0)
30 );
31 29
32 for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { 30 for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] {
33 assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); 31 assert!(Version::parse(bad).is_none(), "{bad:?} should not parse");
34 } 32 }
35 33
36 assert!(Version::parse(VERSION).is_some(), "the build version must parse"); 34 assert!(
35 Version::parse(VERSION).is_some(),
36 "the build version must parse"
37 );
37} 38}
38 39
39#[test] 40#[test]
@@ -57,19 +58,45 @@ fn newer_versions_are_ordered_by_major_minor_patch() {
57 58
58#[test] 59#[test]
59fn the_hello_reply_round_trips() { 60fn the_hello_reply_round_trips() {
60 let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); 61 let line = hello_line(
62 "4.0.0",
63 "4.1.2",
64 "https://zagan.example/",
65 1_700_000_000_123,
66 );
61 let reply = parse_hello(&line).expect("parse a generated reply"); 67 let reply = parse_hello(&line).expect("parse a generated reply");
62 68
63 assert_eq!(reply.version, "4.0.0"); 69 assert_eq!(reply.version, "4.0.0");
64 assert_eq!(reply.latest, "4.1.2"); 70 assert_eq!(reply.latest, "4.1.2");
65 assert_eq!(reply.url, "https://zagan.example/"); 71 assert_eq!(reply.url, "https://zagan.example/");
72 assert_eq!(reply.time, 1_700_000_000_123);
66 73
67 assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); 74 assert!(
68 assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); 75 parse_hello("HELLO 4.0.0 url=https://x/ time=1").is_none(),
76 "latest is required"
77 );
78 assert!(
79 parse_hello("HELLO 4.0.0 latest=4.0.0 time=1").is_none(),
80 "url is required"
81 );
82 assert!(
83 parse_hello("HELLO 4.0.0 latest=4.0.0 url=https://x/").is_none(),
84 "time is required"
85 );
69 assert!(parse_hello("PONG").is_none()); 86 assert!(parse_hello("PONG").is_none());
70 assert!(parse_hello("HELLO").is_none()); 87 assert!(parse_hello("HELLO").is_none());
71} 88}
72 89
90#[test]
91fn pong_carries_the_server_time() {
92 let line = pong_line(1_700_000_000_456);
93 assert_eq!(line, "PONG 1700000000456");
94 assert_eq!(parse_pong(&line), Some(1_700_000_000_456));
95 assert!(parse_pong("PONG").is_none());
96 assert!(parse_pong("RES 1 PONG 1700000000456").is_none());
97 assert!(unix_time_ms() > 1_600_000_000_000);
98}
99
73/// The GUI is a client, so it must ship in lockstep with the TUI and server. 100/// The GUI is a client, so it must ship in lockstep with the TUI and server.
74/// Keep `gui/Cargo.toml` at the same version as the root package. 101/// Keep `gui/Cargo.toml` at the same version as the root package.
75#[test] 102#[test]
@@ -83,6 +110,18 @@ fn the_gui_member_uses_the_same_version() {
83 assert_eq!(gui_version, VERSION); 110 assert_eq!(gui_version, VERSION);
84} 111}
85 112
113/// The gateway is a frontend too; keep it in lockstep with the core.
114#[test]
115fn the_webd_member_uses_the_same_version() {
116 let manifest = include_str!("../webd/Cargo.toml");
117 let version = manifest
118 .lines()
119 .find_map(|line| line.trim().strip_prefix("version = "))
120 .expect("webd/Cargo.toml declares a version")
121 .trim_matches('"');
122 assert_eq!(version, VERSION);
123}
124
86/// egui's dependency tree must never enter the TUI or server build graph. 125/// egui's dependency tree must never enter the TUI or server build graph.
87/// The GUI is a workspace member that depends on this package, never the 126/// The GUI is a workspace member that depends on this package, never the
88/// other way round. 127/// other way round.
diff --git a/tests/web_types.rs b/tests/web_types.rs
new file mode 100644
index 0000000..21af474
--- /dev/null
+++ b/tests/web_types.rs
@@ -0,0 +1,138 @@
1//! The browser and gateway exchange serde JSON for `Command`/`View`. These
2//! tests pin the wire shape the TypeScript app renders.
3
4use zagan_bet::client::games::roulette::{Cursor, Direction};
5use zagan_bet::client::view::{
6 BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView,
7 SlotsPhase, SlotsView, View,
8};
9use zagan_bet::roulette::{BetTarget, Bets, Outside};
10
11#[test]
12fn browser_commands_deserialize() {
13 let login: Command =
14 serde_json::from_str(r#"{"Login":{"user":"alice","credential":"abc"}}"#).unwrap();
15 assert_eq!(
16 login,
17 Command::Login {
18 user: "alice".to_string(),
19 credential: "abc".to_string()
20 }
21 );
22
23 let play: Command = serde_json::from_str(r#"{"Play":{"game":"crash","bet":25}}"#).unwrap();
24 assert_eq!(
25 play,
26 Command::Play {
27 game: "crash".to_string(),
28 bet: Some(25)
29 }
30 );
31
32 let bet: Command =
33 serde_json::from_str(r#"{"PlaceBetAt":{"bet":"outside","kind":"red"}}"#).unwrap();
34 assert_eq!(
35 bet,
36 Command::PlaceBetAt(BetTarget::Outside { kind: Outside::Red })
37 );
38
39 let split: Command =
40 serde_json::from_str(r#"{"PlaceBetAt":{"bet":"split","a":1,"b":2}}"#).unwrap();
41 assert_eq!(split, Command::PlaceBetAt(BetTarget::Split { a: 1, b: 2 }));
42
43 assert_eq!(
44 serde_json::from_str::<Command>(r#""Spin""#).unwrap(),
45 Command::Spin
46 );
47 assert_eq!(
48 serde_json::from_str::<Command>(r#""ShowLobby""#).unwrap(),
49 Command::ShowLobby
50 );
51 assert_eq!(
52 serde_json::from_str::<Command>(r#"{"SetBet":50}"#).unwrap(),
53 Command::SetBet(50)
54 );
55 assert_eq!(
56 serde_json::from_str::<Command>(r#"{"MoveCursor":"Left"}"#).unwrap(),
57 Command::MoveCursor(Direction::Left)
58 );
59}
60
61#[test]
62fn views_serialize_with_a_screen_tag() {
63 let lobby = View::Lobby(LobbyView {
64 connected: true,
65 user: Some("alice".to_string()),
66 account: Some("1".to_string()),
67 balance: Some(1000),
68 games: vec![("slots".to_string(), "Slots".to_string())],
69 login_error: None,
70 });
71 let json = serde_json::to_value(&lobby).unwrap();
72 assert_eq!(json["screen"], "lobby");
73 assert_eq!(json["user"], "alice");
74
75 let slots = View::Slots(SlotsView {
76 bet: 10,
77 balance: Some(990),
78 reels: "CLOGD".chars().collect(),
79 win_cells: vec![0],
80 win_lines: vec![1],
81 win_type: "win".to_string(),
82 winnings: Some(20),
83 phase: SlotsPhase::Result,
84 error: None,
85 });
86 let json = serde_json::to_value(&slots).unwrap();
87 assert_eq!(json["screen"], "slots");
88 assert_eq!(json["phase"], "result");
89 assert_eq!(json["reels"][0], "C");
90
91 let roulette = View::Roulette(RouletteView {
92 bet_amount: 10,
93 balance: Some(1000),
94 chip: 25,
95 bets: Bets::default(),
96 cursor: Cursor::default(),
97 phase: RoulettePhase::Placing,
98 number: None,
99 color: None,
100 winnings: None,
101 error: None,
102 });
103 let json = serde_json::to_value(&roulette).unwrap();
104 assert_eq!(json["screen"], "roulette");
105 assert_eq!(json["phase"], "placing");
106
107 let blackjack = View::Blackjack(BlackjackView {
108 status: "ready".to_string(),
109 balance: Some(1000),
110 bet: 10,
111 hand: None,
112 error: None,
113 });
114 assert_eq!(
115 serde_json::to_value(&blackjack).unwrap()["screen"],
116 "blackjack"
117 );
118
119 let crash = View::Crash(CrashView {
120 status: "running".to_string(),
121 balance: Some(990),
122 bet: 10,
123 multiplier: 1.5,
124 potential: 15,
125 crash_point: None,
126 payout: 0,
127 started_at: 1_700_000_000_000,
128 commit: Some("ab".repeat(32)),
129 seed: None,
130 error: None,
131 });
132 let json = serde_json::to_value(&crash).unwrap();
133 assert_eq!(json["screen"], "crash");
134 assert_eq!(json["commit"], "ab".repeat(32));
135
136 let history = View::History(HistoryView { records: vec![] });
137 assert_eq!(serde_json::to_value(&history).unwrap()["screen"], "history");
138}
diff --git a/web/README.md b/web/README.md
index 81346fd..3e81468 100644
--- a/web/README.md
+++ b/web/README.md
@@ -13,6 +13,7 @@ web/
13 releases.php shared scanner and hash cache 13 releases.php shared scanner and hash cache
14 install.sh stable Linux installer 14 install.sh stable Linux installer
15 install.ps1 stable Windows installer 15 install.ps1 stable Windows installer
16 play/ browser app, served at /play/
16 bin/gui/ apps, served at /bin/gui/... 17 bin/gui/ apps, served at /bin/gui/...
17 bin/client/ terminal clients 18 bin/client/ terminal clients
18 bin/server/ servers 19 bin/server/ servers
@@ -20,6 +21,26 @@ web/
20 21
21Serve `web/` as the document root. Binaries in `bin/` are gitignored. 22Serve `web/` as the document root. Binaries in `bin/` are gitignored.
22 23
24## Browser app
25
26`play/` is a static vanilla TypeScript app. It talks to the WebSocket gateway
27in `webd/`, not to the game server: the browser sends `Command` JSON and renders
28`View` JSON, and the gateway owns the upstream TLS connection and the pinned
29server key. nginx serves `/play/` and proxies `/ws` to `127.0.0.1:8890`.
30
31Build it before publishing:
32
33```sh
34cd web/play
35npm ci
36npm run build # writes app.js
37cd ../..
38```
39
40Commit the generated `app.js`; the site then needs no Node at deploy time.
41`npm run typecheck` checks the sources. The gateway has its own deployment
42notes in `webd/README.md`; it is not part of the player download.
43
23## Naming 44## Naming
24 45
25The page only lists files that match the release pattern; anything else in 46The page only lists files that match the release pattern; anything else in
@@ -132,9 +153,9 @@ Then open <http://127.0.0.1:8899/>.
132`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a 153`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a
133half-written binary, then fetches the live page and every artifact it lists — 154half-written binary, then fetches the live page and every artifact it lists —
134GUI apps, installers, terminal clients and servers — and compares the 155GUI apps, installers, terminal clients and servers — and compares the
135downloaded bytes with the local SHA-256. `--verify-only` skips the 156downloaded bytes with the local SHA-256. It also fetches `/play/`, `app.js` and
136upload and only checks the live site. Old versions are left on the server; 157`style.css`. `--verify-only` skips the upload and only checks the live site.
137prune them by hand. 158Old versions are left on the server; prune them by hand.
138 159
139A local directory works as a target, which is how the verification itself is 160A local directory works as a target, which is how the verification itself is
140tested: 161tested:
diff --git a/web/index.php b/web/index.php
index 3f0ab92..b03eac6 100644
--- a/web/index.php
+++ b/web/index.php
@@ -58,6 +58,7 @@ function human_size(int $bytes): string
58<body> 58<body>
59 59
60<h1>żagań.bet</h1> 60<h1>żagań.bet</h1>
61<p><a href="<?= e($base) ?>/play/"><strong>Play in your browser</strong></a> — no install, just an account. Play-money only.</p>
61<p>Official desktop app, terminal client and server builds.</p> 62<p>Official desktop app, terminal client and server builds.</p>
62<?php if ($newest > 0): ?> 63<?php if ($newest > 0): ?>
63<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> 64<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p>
diff --git a/web/nginx.conf.example b/web/nginx.conf.example
index 9fc942f..d440ab5 100644
--- a/web/nginx.conf.example
+++ b/web/nginx.conf.example
@@ -37,6 +37,31 @@ server {
37 add_header Cache-Control "public, max-age=31536000, immutable"; 37 add_header Cache-Control "public, max-age=31536000, immutable";
38 } 38 }
39 39
40 # The browser app: static files, no framework runtime.
41 location = /play {
42 return 301 /play/;
43 }
44
45 location /play/ {
46 try_files $uri $uri/ /play/index.html;
47 add_header Cache-Control "no-cache";
48 add_header X-Frame-Options "DENY" always;
49 add_header X-Content-Type-Options "nosniff" always;
50 }
51
52 # WebSocket gateway. Keep the upgrade headers and a long read timeout:
53 # a crash round can idle for a while between pushes.
54 location = /ws {
55 proxy_pass http://127.0.0.1:8890;
56 proxy_http_version 1.1;
57 proxy_set_header Upgrade $http_upgrade;
58 proxy_set_header Connection "upgrade";
59 proxy_set_header Host $host;
60 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
61 proxy_read_timeout 3600s;
62 proxy_send_timeout 3600s;
63 }
64
40 # The page rescans bin/ on every request, so don't cache it. 65 # The page rescans bin/ on every request, so don't cache it.
41 location = /index.php { 66 location = /index.php {
42 include fastcgi_params; 67 include fastcgi_params;
diff --git a/web/play/.gitignore b/web/play/.gitignore
new file mode 100644
index 0000000..63738f6
--- /dev/null
+++ b/web/play/.gitignore
@@ -0,0 +1,2 @@
1node_modules/
2app.js.map
diff --git a/web/play/README.md b/web/play/README.md
new file mode 100644
index 0000000..610c7a8
--- /dev/null
+++ b/web/play/README.md
@@ -0,0 +1,56 @@
1# `web/play` — browser frontend
2
3Vanilla TypeScript + esbuild. The browser sends `Command` JSON and renders
4`View` JSON; it contains no game rules, no payout math and no wire protocol.
5The gateway in `webd/` owns the upstream TLS connection.
6
7## Build
8
9```sh
10npm ci
11npm run build # writes app.js
12npm run typecheck
13npm test # headless DOM smoke test (jsdom)
14```
15
16Commit the generated `app.js` so the site deploys without Node.
17
18## Local development
19
20Serve the repository's `web/` directory (for `/play/`) and put the gateway
21behind the same origin at `/ws`. The simplest local setup is nginx with the
22`web/nginx.conf.example` block pointed at a staging root and
23`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899`. For the app alone:
24
25```sh
26python3 -m http.server 8900 -d web/play
27```
28
29It will load, but the WebSocket will only connect through nginx or another
30proxy that routes `/ws` to `webd`.
31
32## Screens
33
34- Auth: login/register. The password is hashed with Web Crypto (`SHA-256`)
35 before it leaves the page; the gateway and browser never store it.
36- Lobby: four games, per-game bet, history.
37- Slots: five reels, win cells and paylines.
38- Roulette: canvas felt with straight, dozen, column and outside chips;
39 left click adds, right click removes.
40- Blackjack: dealer/player cards, hit/stand/double/forfeit.
41- Crash: curve, live multiplier, cash-out, commit/seed verification.
42- History: settled plays with net result.
43
44## Theme
45
46`style.css` mirrors the palette in `gui/src/theme.rs`: near-black ground,
47cyan focus, green for wins, gold for chips, felt green only for roulette.
48Do not introduce a second palette or hard-code colours in the render modules.
49
50## Security
51
52- The session cookie is HttpOnly and SameSite=Strict; JavaScript never reads
53 it and never stores a credential.
54- Notices are inserted with `textContent`, not HTML.
55- The app is play-money only. Do not add deposits, withdrawals, KYC or
56 real-money claims without a product decision and an ADR.
diff --git a/web/play/app.js b/web/play/app.js
new file mode 100644
index 0000000..3320693
--- /dev/null
+++ b/web/play/app.js
@@ -0,0 +1 @@
"use strict";(()=>{function k(e){let n=document.getElementById(e);if(!n)throw new Error(`missing #${e}`);return n}function o(e,n,t){let s=document.createElement(e);return n&&(s.className=n),t!==void 0&&(s.textContent=t),s}function H(e){e.replaceChildren()}function Oe(e){let n=e<0,t=String(Math.abs(Math.trunc(e))),s="";for(let a=0;a<t.length;a+=1)a>0&&(t.length-a)%3===0&&(s+=" "),s+=t[a];return n?`-${s}`:s}function L(e){return e==null||Number.isNaN(e)?"— Kr":`${Oe(e)} Kr`}function me(e){return`${e<0?"-":"+"}${L(Math.abs(e))}`}function J(e,n="info"){let t=document.getElementById("toasts");if(!t||!e)return;let s=o("div",`toast ${n}`,e);for(t.append(s),window.setTimeout(()=>{s.style.opacity="0",s.style.transition="opacity 220ms ease",window.setTimeout(()=>s.remove(),240)},4200);t.children.length>4;)t.firstElementChild?.remove()}function he(e){let n=e.toLowerCase();return e.startsWith("ERROR")||n.includes("failed")||n.includes("lost")||n.includes("invalid")||n.includes("forfeit")?"bad":e.startsWith("Logged in")||e.startsWith("Account created")||n.includes("won")||n.includes("cashed")?"good":"info"}async function fe(e){let n=new TextEncoder().encode(e),t=await crypto.subtle.digest("SHA-256",n);return[...new Uint8Array(t)].map(s=>s.toString(16).padStart(2,"0")).join("")}function O(e,n=10){return e.length<=n*2?e:`${e.slice(0,n)}…${e.slice(-n)}`}function be(e){let n=Date.parse(e.includes("T")?e:e.replace(" ","T")+"Z");return Number.isNaN(n)?e:new Date(n).toLocaleString()}function ge(e){if(e==="?"||!e)return{rank:"?",suit:"",red:!1};let n=e.slice(-1);return{rank:e.slice(0,-1),suit:n==="C"?"♣":n==="D"?"♦":n==="H"?"♥":n==="S"?"♠":n,red:n==="D"||n==="H"}}var j=class{constructor(n,t){this.url=n;this.handlers=t;this.socket=null;this.stopped=!1;this.replaced=!1;this.attempts=0}connect(){if(this.stopped)return;this.clearTimer();let n;try{n=new WebSocket(this.url)}catch{this.scheduleReconnect()||this.handlers.onClosed(!1);return}this.socket=n,n.addEventListener("open",()=>{this.attempts=0,this.handlers.onOpen()}),n.addEventListener("message",t=>{let s;try{s=JSON.parse(String(t.data))}catch{return}switch(s.type){case"hello":this.handlers.onHello(s);break;case"view":this.handlers.onView(s.view);break;case"notice":this.handlers.onNotice(String(s.text??""));break;case"error":this.handlers.onError(String(s.text??""));break;case"replaced":this.replaced=!0,this.handlers.onReplaced();break;case"pong":this.handlers.onPong();break}}),n.addEventListener("close",()=>{if(this.socket===n&&(this.socket=null),this.replaced||this.stopped){this.handlers.onClosed(!1);return}let s=this.scheduleReconnect();this.handlers.onClosed(s)}),n.addEventListener("error",()=>{})}send(n){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"command",command:n}))}ping(){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"ping"}))}close(){this.stopped=!0,this.clearTimer(),this.socket?.close()}scheduleReconnect(){if(this.stopped||this.replaced||this.attempts>=6)return!1;this.attempts+=1;let n=Math.min(250*2**(this.attempts-1),4e3);return this.timer=window.setTimeout(()=>this.connect(),n),!0}clearTimer(){this.timer!==void 0&&(window.clearTimeout(this.timer),this.timer=void 0)}};function ze(e){if(e==="?"){let s=o("div","card back");return s.append(o("span","rank","?"),o("span","suit","")),s}let n=ge(e),t=o("div","card");return n.red&&t.classList.add("red"),t.append(o("span","rank",n.rank),o("span","suit",n.suit)),t}function ye(e,n,t){let s=o("div","hand");s.append(o("div","label",e));let a=o("div","cards");for(let d of n)a.append(ze(d));let r=o("div","total");return r.append(document.createTextNode("Total "),o("strong",void 0,String(t))),s.append(a,r),s}function Ve(e,n){let t=String(n?.result??""),s=Number(n?.winnings??0);switch(t){case"blackjack":return`Blackjack — won ${L(s)}`;case"win":return`You win — ${L(s)}`;case"push":return`Push — ${L(s)} returned`;case"bust":return"Bust — the bet is lost";case"lose":return"Dealer wins — the bet is lost";case"forfeit":return"Hand forfeited — the bet is lost";case"playing":return e.status==="player"?"Your move.":"Dealing…";default:return"Ready to deal."}}function ke(e,n,t,s){H(e),H(n);let a=t.hand,r=a?.phase==="player"||t.status==="player",d=a?.phase==="done"||t.status==="done",i=!a||d;if(e.append(o("h2",void 0,"Blackjack")),e.append(o("p","note","Six decks. Dealer stands on all 17. Blackjack pays 3:2.")),a)e.append(ye("Dealer",a.dealer??[],Number(a.dealer_total??0))),e.append(ye("You",a.player??[],Number(a.player_total??0)));else{let h=o("div","outcome");h.textContent="Press DEAL to start a hand.",e.append(h)}if(d||a&&!r){let h=o("div","outcome");h.textContent=Ve(t,a),(String(a?.result)==="win"||String(a?.result)==="blackjack"||String(a?.result)==="push")&&h.classList.add("win-text"),e.append(h)}t.error&&e.append(o("p","error-text",String(t.error))),n.append(o("div","label","Blackjack"));let m=Number(a?.bet??t.bet??0),p=t.balance??t.hand?.balance;if(n.append(o("p","note","Hit, stand or double. Forfeiting loses the reserved bet.")),i){let h=o("div","row"),v=o("button","btn small","−10"),y=o("strong","mono",L(m)),w=o("button","btn small","+10");v.addEventListener("click",()=>s({AdjustBet:-10})),w.addEventListener("click",()=>s({AdjustBet:10})),h.append(v,y,w),n.append(o("div","label","Bet"),h)}else{let h=o("div","stat");h.append(o("span","muted","Bet"),o("strong",void 0,L(m))),n.append(h)}if(p!==void 0){let h=o("div","stat");h.append(o("span","muted","Balance"),o("strong",void 0,L(Number(p)))),n.append(h)}let u=o("div","actions");if(r){let h=o("div","row wrap");for(let y of a?.actions??[]){let w=y==="hit"?"HIT":y==="stand"?"STAND":"DOUBLE",E=y==="stand"?o("button","btn primary",w):o("button","btn",w);E.addEventListener("click",()=>{s(y==="hit"?"Hit":y==="stand"?"Stand":"Double")}),h.append(E)}u.append(h);let v=o("button","btn danger wide","Forfeit hand");v.addEventListener("click",()=>{window.confirm("Forfeit this hand? The reserved bet is lost.")&&s("Forfeit")}),u.append(v)}else if(i){let h=o("button","btn primary wide big","DEAL");h.addEventListener("click",()=>s("Deal")),u.append(h)}let g=o("button","btn ghost wide",r?"Leave (forfeits)":"Leave table");g.addEventListener("click",()=>{r&&!window.confirm("Leaving forfeits this hand. Continue?")||s("Leave")}),u.append(g),n.append(u)}var q=Math.LN2/5,R=null,z=null,ve=null,P=null,W,Le=0,Y="";function _e(e,n,t,s){H(e),H(n),P=t,ve=s,e.append(o("h2",void 0,"Crash")),e.append(o("p","note","The multiplier rises from 1.00×. Cash out before it crashes."));let a=o("div","crash-wrap"),r=o("div","crash-number","1.00×"),d=o("div","crash-meta"),i=o("canvas","crash-canvas"),m=o("div","verify","Waiting for a round commitment.");a.append(r,d,i,m),e.append(a),n.append(o("div","label","Crash")),n.append(o("p","note","The server commits to a seed before the round. The seed is revealed when it ends."));let p=o("div","row"),u=o("button","btn small","−10"),g=o("strong","mono","—"),h=o("button","btn small","+10");u.addEventListener("click",()=>P?.({AdjustBet:-10})),h.addEventListener("click",()=>P?.({AdjustBet:10})),p.append(u,g,h),n.append(o("div","label","Bet"),p);let v=o("strong","mono","—"),y=o("strong","mono","—");for(let[M,N]of[["Potential",v],["Balance",y]]){let b=o("div","stat");b.append(o("span","muted",M),N),n.append(b)}let w=o("p","note","Ready."),E=o("p","note",""),S=o("p","error-text");n.append(w,E,S);let l=o("div","actions"),c=o("button","btn primary wide big","START"),f=o("button","btn ghost wide","Leave table");return c.addEventListener("click",()=>{z?.status==="running"?P?.("CashOut"):P?.("Start")}),f.addEventListener("click",()=>{z?.status==="running"&&!window.confirm("Forfeit this round? The bet is lost.")||P?.("Leave")}),l.append(c,f),n.append(l),{board:e,side:n,number:r,meta:d,verify:m,canvas:i,bet:g,potential:v,balance:y,status:w,lastCrash:E,error:S,primary:c,leave:f,minus:u,plus:h}}function we(e,n,t,s,a){(!R||R.board!==e)&&(R=_e(e,n,s,a),Y="");let r=R;P=s,ve=a,z=t,t.status!==Y&&((t.status==="starting"||t.status==="running")&&Y!=="running"&&(r.verify.className="verify",r.verify.textContent="Round committed; seed hidden until the round ends."),Y=String(t.status));let d=t.status==="running",i=t.status==="starting",m=d||i;r.bet.textContent=L(Number(t.bet??0)),r.potential.textContent=L(Number(t.potential??t.bet??0)),r.balance.textContent=t.balance===null||t.balance===void 0?"—":L(Number(t.balance)),r.error.textContent=t.error?String(t.error):"",r.minus.disabled=m,r.plus.disabled=m,r.primary.textContent=d?"CASH OUT":i?"STARTING…":"START",r.primary.disabled=i,r.primary.className="btn primary wide big",r.leave.textContent=d?"Forfeit round":"Leave table",d?(r.status.textContent="Rising — cash out before the crash.",r.status.className="note win-text"):i?(r.status.textContent="Starting the round…",r.status.className="note"):t.status==="cashed"?(r.status.textContent=`Cashed out at ${Number(t.multiplier??0).toFixed(2)}× for ${L(Number(t.payout??0))}.`,r.status.className="note win-text"):t.status==="crashed"?(r.status.textContent=`Crashed at ${Number(t.crash_point??t.multiplier??0).toFixed(2)}×. The bet is lost.`,r.status.className="note error-text"):t.status==="forfeit"?(r.status.textContent="Round forfeited.",r.status.className="note error-text"):(r.status.textContent="Ready. Start a round when you want.",r.status.className="note"),t.crash_point!==null&&t.crash_point!==void 0&&t.status==="crashed"&&(r.lastCrash.textContent=`Last crash: ${Number(t.crash_point).toFixed(2)}×`);let p=t.commit?String(t.commit):"";if(r.meta.textContent=p?`commit ${O(p,12)}`:"no commitment yet",t.seed&&p){let u=++Le,g=String(t.seed),h=Number(t.crash_point??0);Xe(g,p,h).then(([v,y])=>{if(u!==Le||!R)return;let w=v&&y;R.verify.className=`verify ${w?"ok":"bad"}`,R.verify.textContent=w?`Seed ${O(g,12)} · sha256 matches commit · crash point recomputed`:`VERIFICATION FAILED · seed ${O(g,12)} · commit ${O(p,12)}`})}else!m&&!p&&(r.verify.className="verify",r.verify.textContent="Waiting for a round commitment.");if(W!==void 0&&(cancelAnimationFrame(W),W=void 0),d){let u=()=>{if(!R||z?.status!=="running"){W=void 0;return}xe(R,z,a),W=requestAnimationFrame(u)};W=requestAnimationFrame(u)}else xe(r,t,a)}function je(e,n){if(e.status==="running"){let t=Math.max(0,(n.now()-Number(e.started_at??0))/1e3),s=Math.min(1e3,Math.exp(q*t));return Math.max(s,Number(e.multiplier??1))}return e.status==="cashed"?Number(e.multiplier??1):e.status==="crashed"?Number(e.crash_point??e.multiplier??1):1}function xe(e,n,t){let s=je(n,t);e.number.textContent=`${s.toFixed(2)}×`,e.number.className=`crash-number ${n.status==="running"?"running":n.status==="cashed"?"cashed":n.status==="crashed"?"crashed":""}`,e.potential.textContent=L(Math.floor(Number(n.bet??0)*s)),Ye(e.canvas,n,s,t)}function Ye(e,n,t,s){let a=e.clientWidth||600,r=e.clientHeight||360,d=window.devicePixelRatio||1;(e.width!==Math.round(a*d)||e.height!==Math.round(r*d))&&(e.width=Math.round(a*d),e.height=Math.round(r*d));let i=e.getContext("2d");if(!i)return;i.setTransform(d,0,0,d,0,0),i.clearRect(0,0,a,r);let m=54,p=16,u=18,g=34,h=Math.max(1,a-m-p),v=Math.max(1,r-u-g),y=n.status==="running"?Math.max(.25,(s.now()-Number(n.started_at??0))/1e3):t>1?Math.log(t)/q:1,w=Math.max(5,y*1.08),E=Math.max(2,Math.ceil(t*1.15*2)/2),S=b=>m+b/w*h,l=b=>u+v-(b-1)/(E-1)*v;i.strokeStyle="#242e38",i.fillStyle="#8a98a6",i.font="11px ui-monospace, monospace",i.lineWidth=1;for(let b=0;b<=4;b+=1){let T=1+(E-1)*b/4,C=l(T);i.beginPath(),i.moveTo(m,C),i.lineTo(a-p,C),i.stroke(),i.fillText(`${T.toFixed(1)}×`,6,C+4)}for(let b=0;b<=4;b+=1){let T=w*b/4,C=S(T);i.beginPath(),i.moveTo(C,u),i.lineTo(C,u+v),i.strokeStyle="#1b232c",i.stroke(),i.fillStyle="#5b6773",i.fillText(`${T.toFixed(0)}s`,C-10,r-12)}let c=n.status==="crashed"?"#e05a5a":n.status==="cashed"?"#4ec27b":"#e0b64a";i.beginPath();let f=180;for(let b=0;b<=f;b+=1){let T=y*b/f,C=Math.min(E,Math.exp(q*T)),ue=S(T),pe=l(C);b===0?i.moveTo(ue,pe):i.lineTo(ue,pe)}i.strokeStyle=c,i.lineWidth=2.4,i.stroke();let M=S(y),N=l(Math.min(E,t));i.beginPath(),i.arc(M,N,4.5,0,Math.PI*2),i.fillStyle=c,i.fill(),i.fillStyle=c,i.font="600 12px ui-monospace, monospace",i.fillText(`${t.toFixed(2)}×`,Math.min(M+8,a-70),Math.max(u+12,N-10))}async function Xe(e,n,t){let s=new Uint8Array(e.length/2);for(let u=0;u<s.length;u+=1)s[u]=Number.parseInt(e.slice(u*2,u*2+2),16);let a=await crypto.subtle.digest("SHA-256",s),d=[...new Uint8Array(a)].map(u=>u.toString(16).padStart(2,"0")).join("")===n.toLowerCase(),i=0;for(let u=0;u<8;u+=1)i=i*256+s[u];i/=2**64;let m=Math.max(1,Math.min(1e3,(1-.01)/(1-i))),p=Math.abs(m-t)<1e-6;return[d,p]}function Ge(e){let n=e.details??{};switch(e.game){case"slots":return String(n.win_type??"spin");case"roulette":return`${n.number??"?"} ${n.color??""}`.trim();case"blackjack":return String(n.result??"");case"crash":{let t=String(n.result??""),s=n.crash_point;return s==null?t:`${t} @ ${Number(s).toFixed(2)}×`}default:return""}}function Te(e,n){H(e);let t=n.records??[];if(t.length===0){let m=o("div","board");m.append(o("h2",void 0,"No plays yet"),o("p","muted","Your settled plays will appear here.")),e.append(m);return}let s=o("div","table-wrap"),a=o("table"),r=o("thead"),d=o("tr");for(let m of["Time","Game","Bet","Result","Net","Balance"])d.append(o("th",void 0,m));r.append(d);let i=o("tbody");for(let m of t){let p=o("tr");p.append(o("td","mono",be(String(m.at??"")))),p.append(o("td",void 0,String(m.game??""))),p.append(o("td","num",L(Number(m.bet??0)))),p.append(o("td","muted",Ge(m)));let u=Number(m.winnings??0)-Number(m.bet??0),g=o("td",`num ${u>0?"pos":u<0?"neg":""}`,u===0?"0 Kr":me(u));p.append(g),p.append(o("td","num",L(Number(m.balance_after??0)))),i.append(p)}a.append(r,i),s.append(a),e.append(s)}var Ke="#b0313a",Ue="#151b21",Ee="#106b45",Ze="#0d5c39",Je="#09472c",qe="#1f7d52",Qe="#ecf7f0",et="#e0b64a";function tt(e){return e===0?"green":[1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36].includes(e)?"red":"black"}function nt(e,n){let s=Math.max(38,e*.055),a=Math.max(34,e*.05),r=Math.max(30,n*.07),d=6,i=e-28-s-a,m=i/12,p=(n-28-r*2-d*2)/3,u=14+s,g=14,h=g+p*3+d,v=h+r+d,y=[];for(let l=0;l<12;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);y.push({x:u+l*m,y:g+c*p,w:m,h:p,target:{bet:"straight",number:f},label:String(f),kind:"number",number:f})}y.push({x:14,y:g,w:s,h:p*3,target:{bet:"straight",number:0},label:"0",kind:"zero",number:0});for(let l=0;l<3;l+=1){let c=2-l;y.push({x:u+i,y:g+l*p,w:a,h:p,target:{bet:"column",index:c},label:"2:1",kind:"column"})}for(let l=0;l<3;l+=1)y.push({x:u+i/3*l,y:h,w:i/3,h:r,target:{bet:"dozen",index:l},label:["1st 12","2nd 12","3rd 12"][l],kind:"dozen"});[{kind:"low",label:"1–18"},{kind:"even",label:"EVEN"},{kind:"red",label:"RED"},{kind:"black",label:"BLACK"},{kind:"odd",label:"ODD"},{kind:"high",label:"19–36"}].forEach((l,c)=>{y.push({x:u+i/6*c,y:v,w:i/6,h:r,target:{bet:"outside",kind:l.kind},label:l.label,kind:"outside"})});let E=[],S=l=>{let c=y.find(f=>f.kind==="number"&&f.number===l);return c?{x:c.x+c.w/2,y:c.y+c.h/2}:null};for(let l=0;l<11;l+=1)for(let c=0;c<2;c+=1){let f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+(c+1)*p,radius:12,rank:0,target:{bet:"corner",numbers:[f,f-1,f+3,f+2]}})}for(let l=0;l<12;l+=1)for(let c=0;c<2;c+=1){let f=l*3+(3-c),M=S(f);M&&E.push({x:M.x,y:g+(c+1)*p,radius:10,rank:1,target:{bet:"split",a:f,b:f-1}})}for(let l=0;l<11;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+c*p+p/2,radius:10,rank:1,target:{bet:"split",a:f,b:f+3}})}for(let l=0;l<3;l+=1){let c=3-l;E.push({x:u,y:g+l*p+p/2,radius:10,rank:1,target:{bet:"split",a:0,b:c}})}return{width:e,height:n,cellW:m,cellH:p,zeroX:14,zeroW:s,gridX:u,gridY:g,gridW:i,columnX:u+i,dozensY:h,outsideY:v,outsideH:r,zones:y,hotspots:E}}function Me(e,n,t,s,a,r){let d=Math.min(r,s/2,a/2);e.beginPath(),e.moveTo(n+d,t),e.arcTo(n+s,t,n+s,t+a,d),e.arcTo(n+s,t+a,n,t+a,d),e.arcTo(n,t+a,n,t,d),e.arcTo(n,t,n+s,t,d),e.closePath()}function Ce(e,n){let t=e.clientWidth||800,s=e.clientHeight||540,a=window.devicePixelRatio||1;(e.width!==Math.round(t*a)||e.height!==Math.round(s*a))&&(e.width=Math.round(t*a),e.height=Math.round(s*a));let r=e.getContext("2d"),d=nt(t,s);if(!r)return d;r.setTransform(a,0,0,a,0,0),r.clearRect(0,0,t,s),r.fillStyle=Je,Me(r,0,0,t,s,10),r.fill();for(let i of d.zones){let m=Ze;i.kind==="number"?m=i.number===0?Ee:tt(i.number)==="red"?Ke:Ue:i.kind==="zero"&&(m=Ee),r.fillStyle=m,r.strokeStyle=qe,r.lineWidth=1,Me(r,i.x+1,i.y+1,i.w-2,i.h-2,3),r.fill(),r.stroke(),r.fillStyle=Qe,r.textAlign="center",r.textBaseline="middle",i.kind==="number"||i.kind==="zero"?r.font=`700 ${Math.min(24,d.cellH*.3)}px ui-monospace, monospace`:r.font=`600 ${Math.min(12,d.outsideH*.4)}px ui-monospace, monospace`,r.fillText(i.label,i.x+i.w/2,i.y+i.h/2)}return st(r,d,n),d}function Se(e){return JSON.stringify(e)}function ot(e,n){let t=Se(n),s=e.zones.find(a=>Se(a.target)===t);if(s)return{x:s.x+s.w/2,y:s.y+s.h/2};if(n?.bet==="split"||n?.bet==="corner"){let a=n.bet==="split"?[n.a,n.b]:n.numbers,r=e.zones.filter(d=>(d.kind==="number"||d.kind==="zero")&&a.includes(d.number));if(r.length>0)return{x:r.reduce((d,i)=>d+i.x+i.w/2,0)/r.length,y:r.reduce((d,i)=>d+i.y+i.h/2,0)/r.length}}return null}function st(e,n,t){let s=t.bets?.chips??[];for(let a of s){let r=ot(n,a.target);if(!r)continue;let d=Math.max(12,Math.min(18,n.cellW*.26));e.beginPath(),e.arc(r.x,r.y,d,0,Math.PI*2),e.fillStyle=et,e.fill(),e.strokeStyle="#7a5a12",e.lineWidth=2,e.stroke(),e.fillStyle="#221a05",e.textAlign="center",e.textBaseline="middle",e.font=`700 ${d>15?11:10}px ui-monospace, monospace`,e.fillText(String(a.amount),r.x,r.y+.5)}}function Ne(e,n,t,s){H(e),H(n);let a=t.phase==="spinning";e.append(o("h2",void 0,"Roulette")),e.append(o("p","note","European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back."));let r=o("div","roulette-wrap"),d=o("canvas","roulette-canvas");if(r.append(d),t.phase==="result"&&t.number!==null&&t.number!==void 0){let l=o("div",`wheel-result ${t.color??""}`);l.append(o("div","label","Result")),l.append(o("div","num",String(t.number)));let c=Number(t.winnings??0);l.append(o("div","note",c>0?`Won ${L(c)}`:"No win")),r.append(l)}e.append(r);let m=Ce(d,t),p=(l,c)=>{let f=d.getBoundingClientRect(),M=l-f.left,N=c-f.top,b=null;for(let T of m.hotspots){let C=Math.hypot(M-T.x,N-T.y);C>T.radius||(!b||T.rank<b.rank||T.rank===b.rank&&C<Math.hypot(M-b.x,N-b.y))&&(b=T)}if(b)return{x:b.x-b.radius,y:b.y-b.radius,w:b.radius*2,h:b.radius*2,target:b.target,label:"",kind:"number"};for(let T=m.zones.length-1;T>=0;T-=1){let C=m.zones[T];if(M>=C.x&&M<=C.x+C.w&&N>=C.y&&N<=C.y+C.h)return C}return null};d.addEventListener("click",l=>{if(a)return;let c=p(l.clientX,l.clientY);c&&s({PlaceBetAt:c.target})}),d.addEventListener("contextmenu",l=>{if(l.preventDefault(),a)return;let c=p(l.clientX,l.clientY);c&&s({RemoveBetAt:c.target})}),d.addEventListener("mousemove",l=>{let c=p(l.clientX,l.clientY);d.style.cursor=c&&!a?"pointer":"default"}),new ResizeObserver(()=>{m=Ce(d,t)}).observe(d),n.append(o("div","label","Roulette")),n.append(o("p","note","Chips sit on the felt. The total is reserved when you spin.")),n.append(o("div","label","Chip"));let u=o("div","chip-row");for(let l of[10,25,50,100,500]){let c=o("button",`chip-btn ${Number(t.chip)===l?"active":""}`,String(l));c.disabled=a,c.addEventListener("click",()=>s({SetChip:l})),u.append(c)}n.append(u);let g=(t.bets?.chips??[]).reduce((l,c)=>l+Number(c.amount??0),0),h=o("div","stat");if(h.append(o("span","muted","On the felt"),o("strong","mono",L(g))),n.append(h),t.balance!==null&&t.balance!==void 0){let l=o("div","stat");l.append(o("span","muted","Balance"),o("strong","mono",L(Number(t.balance)))),n.append(l)}t.error&&n.append(o("p","error-text",String(t.error)));let v=o("p","note");if(a)v.textContent="Spinning…";else if(t.phase==="result"){let l=Number(t.winnings??0);v.textContent=`${t.number} ${t.color} · ${l>0?`won ${L(l)}`:"no win"}`,v.className=l>0?"note win-text":"note"}else v.textContent=g>0?"Ready to spin.":"Place at least one chip.";n.append(v);let y=o("div","actions"),w=o("button","btn primary wide big",a?"SPINNING…":"SPIN WHEEL");w.disabled=a||g===0,w.addEventListener("click",()=>s("Spin"));let E=o("button","btn wide","Clear bets");E.disabled=a||g===0,E.addEventListener("click",()=>s("ClearBets"));let S=o("button","btn ghost wide","Leave table");S.disabled=a,S.addEventListener("click",()=>{a||s("Leave")}),y.append(w,E,S),n.append(y)}var Q=5,ee=3,D="CLOGDS",rt=["top","middle","bottom","V","inverted V"],oe=0,I,te=null,ne=null;function at(){oe=Date.now()+900}function it(e){return e.phase==="spinning"||Date.now()<oe}function se(e,n,t,s){te=t,ne=e,I!==void 0&&(window.clearInterval(I),I=void 0);let a=it(t);H(e),H(n);let r=o("h2",void 0,"Slots"),d=o("p","note","Match three or more symbols from the left. Five paylines.");e.append(r,d);let i=o("div","reels"),m=[],p=String(t.reels??"");for(let c=0;c<Q;c+=1){let f=o("div","reel");for(let M=0;M<ee;M+=1){let N=c*ee+M,b=a?D[(N*3+Math.floor(Date.now()/70))%D.length]:p.length===Q*ee?p[N]:p.length===Q?p[c]:D[(N*5+c)%D.length],T=o("div","tile",b);T.classList.add(`sym-${b}`),a&&T.classList.add("spinning"),!a&&(t.win_cells??[]).includes(N)&&T.classList.add("win"),m.push(T),f.append(T)}i.append(f)}e.append(i);let u=o("div","win-lines");if(!a&&t.phase==="result"){let c=Number(t.winnings??0);if(c>0){let f=(t.win_lines??[]).map(M=>`${M} (${rt[M-1]??"line"})`).join(" · ");u.textContent=`${t.win_type==="mega"?"MEGA WIN":t.win_type==="big"?"BIG WIN":"WIN"} ${L(c)}${f?` · ${f}`:""}`,u.classList.add("win-text")}else u.textContent="No win — spin again."}else a?u.textContent="Spinning…":u.textContent="Pick a bet and spin.";if(e.append(u),a){let c=0;I=window.setInterval(()=>{c+=1;for(let f=0;f<m.length;f+=1){let M=D[(f*3+c)%D.length];m[f].textContent=M,m[f].className=`tile spinning sym-${M}`}Date.now()>=oe&&te&&ne&&(window.clearInterval(I),I=void 0,se(ne,n,te,s))},70)}n.append(o("div","label","Slots")),n.append(o("p","note","5 reels × 3 rows, 5 paylines."));let g=o("div","row"),h=o("button","btn small","−10"),v=o("strong","mono",L(Number(t.bet??0))),y=o("button","btn small","+10");h.disabled=a,y.disabled=a,h.addEventListener("click",()=>s({AdjustBet:-10})),y.addEventListener("click",()=>s({AdjustBet:10})),g.append(h,v,y),n.append(o("div","label","Bet"),g),t.error&&n.append(o("p","error-text",String(t.error)));let w=o("p","note");if(a)w.textContent="Spinning…";else if(t.phase==="result"){let c=Number(t.winnings??0);w.textContent=c>0?`Won ${L(c)}`:"No win this spin.",c>0&&w.classList.add("win-text")}else w.textContent="Ready when you are.";n.append(w);let E=o("div","actions"),S=o("button","btn primary wide big",a?"SPINNING…":"SPIN");S.disabled=a,S.addEventListener("click",()=>{at(),s("Spin")});let l=o("button","btn ghost wide","Leave machine");l.disabled=a,l.addEventListener("click",()=>s("Leave")),E.append(S,l),n.append(E)}var lt=k("auth-screen"),ct=k("lobby-screen"),dt=k("game-screen"),ut=k("history-screen"),He=k("session-bar"),pt=k("account-name"),mt=k("account-balance"),re=k("game-grid"),X=k("game-board"),G=k("game-side"),ht=k("game-title"),$e=k("game-badge"),ft=k("history-body"),bt=k("auth-form"),Ae=k("auth-user"),U=k("auth-pass"),F=k("auth-error"),A=k("auth-submit"),Pe=k("tab-login"),We=k("tab-register"),K=k("reconnect-overlay"),Be=k("reconnect-message"),Re=k("replaced-overlay"),x=null,$=null,ce=null,_="login",ae=null,De=0,ie=0,le=new Map([["slots",10],["roulette",10],["blackjack",10],["crash",10]]),Ie={slots:"Slots",roulette:"Roulette",blackjack:"Blackjack",crash:"Crash"},gt={slots:"Five reels, five paylines. Match symbols from the left.",roulette:"European single-zero wheel with inside and outside bets.",blackjack:"Six-deck shoe. Blackjack pays 3:2.",crash:"A rising multiplier. Cash out before it crashes."},yt=location.pathname.includes("/play/")?location.pathname.slice(0,location.pathname.indexOf("/play/")):"",kt=`${location.protocol==="https:"?"wss":"ws"}://${location.host}${yt}/ws`,Z=new j(kt,{onHello(e){De=e.time-Date.now(),$=e.user??null,$e.textContent=`v${e.version}`},onView(e){x=e,typeof e.balance=="number"&&(ce=e.balance),e.screen==="lobby"&&"user"in e&&($=e.user??null,$?U.value="":ce=null),ie=0,K.hidden=!0,Fe(),$&&x.balance===null&&ae!==$&&(ae=$,Z.send("Info")),$||(ae=null)},onNotice(e){e.startsWith("Connected to:")||e.startsWith("Fingerprint:")||e.startsWith("User:")||e.startsWith("Account:")||e.startsWith("Balance:")||(J(e,he(e)),e.startsWith("Account created")&&(de("login"),U.value="",Ae.focus()),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account")},onError(e){J(e,"bad"),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"},onReplaced(){Re.hidden=!1},onPong(){},onOpen(){K.hidden=!0},onClosed(e){if(Re.hidden!==!1){if(!e){Be.textContent="The connection could not be restored. Reload the page to try again.",K.hidden=!1;return}ie+=1,Be.textContent=ie>1?"Still trying to reach the game server…":"Trying to reach the game server…",K.hidden=!1}}});function B(e){Z.send(e)}function Lt(){return Date.now()+De}function de(e){_=e,Pe.classList.toggle("active",e==="login"),We.classList.toggle("active",e==="register"),A.textContent=e==="login"?"Log in":"Create account",U.autocomplete=e==="login"?"current-password":"new-password",F.textContent=""}function V(e){lt.hidden=e!=="auth",ct.hidden=e!=="lobby",dt.hidden=e!=="game",ut.hidden=e!=="history"}function xt(e){mt.textContent=L(e??null)}function Fe(){if(!x)return;let e=$;if(e?(He.hidden=!1,pt.textContent=String(e)):He.hidden=!0,xt(ce),!e){V("auth"),F.textContent=x.login_error?String(x.login_error):"",x.screen==="lobby"&&x.connected===!1&&(F.textContent="The game server is not reachable yet. Retrying…");return}switch(x.screen){case"lobby":V("lobby"),wt(x);break;case"history":V("history"),Te(ft,x);break;case"slots":case"roulette":case"blackjack":case"crash":V("game"),ht.textContent=Ie[x.screen]??x.screen,$e.textContent=vt(x),x.screen==="slots"?se(X,G,x,B):x.screen==="roulette"?Ne(X,G,x,B):x.screen==="blackjack"?ke(X,G,x,B):we(X,G,x,B,{now:Lt});break;default:V("auth")}}function vt(e){return e.screen==="crash"||e.screen==="blackjack"?String(e.status??""):e.screen==="slots"||e.screen==="roulette"?String(e.phase??""):""}function wt(e){re.replaceChildren();let n=e.games??[];for(let[t,s]of n){let a=o("article",`game-card ${t}`);a.append(o("h3",void 0,s||Ie[t]||t)),a.append(o("p",void 0,gt[t]??"Play a round."));let r=o("div","row wrap"),d=o("label","field");d.style.flex="1",d.append(o("span","label","Bet"));let i=o("input","input bet-input");i.type="number",i.min="1",i.step="10",i.value=String(le.get(t)??10),i.addEventListener("change",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));i.value=String(p),le.set(t,p)}),d.append(i);let m=o("button","btn primary",t==="roulette"?"Open table":"Play");m.addEventListener("click",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));le.set(t,p),B({Play:{game:t,bet:p}})}),r.append(d,m),a.append(r),re.append(a)}n.length===0&&re.append(o("p","muted","The server did not advertise any games."))}Pe.addEventListener("click",()=>de("login"));We.addEventListener("click",()=>de("register"));bt.addEventListener("submit",async e=>{e.preventDefault();let n=Ae.value.trim(),t=U.value;if(!n||!t){F.textContent="Enter a username and a password.";return}A.disabled=!0,A.textContent="Working…",F.textContent="";try{let s=await fe(t);B(_==="login"?{Login:{user:n,credential:s}}:{Register:{user:n,credential:s}})}catch{F.textContent="This browser cannot hash the password (needs a secure context).",A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"}});k("logout").addEventListener("click",()=>B("Logout"));k("show-history").addEventListener("click",()=>B({History:{limit:50}}));k("refresh-lobby").addEventListener("click",()=>B("Info"));k("history-back").addEventListener("click",()=>B("ShowLobby"));k("leave-game").addEventListener("click",()=>{!x||(x.screen==="blackjack"&&x.status==="player"||x.screen==="crash"&&x.status==="running")&&!window.confirm("Leaving now forfeits the active bet. Continue?")||B("Leave")});k("reload-replaced").addEventListener("click",()=>location.reload());k("reload-reconnect").addEventListener("click",()=>location.reload());window.addEventListener("resize",()=>{window.clearTimeout(window.__resizeTimer),window.__resizeTimer=window.setTimeout(()=>{x&&Fe()},140)});window.setInterval(()=>{$&&Z.ping()},25e3);Z.connect();})();
diff --git a/web/play/build.mjs b/web/play/build.mjs
new file mode 100644
index 0000000..40af074
--- /dev/null
+++ b/web/play/build.mjs
@@ -0,0 +1,22 @@
1import { build, context } from "esbuild";
2
3const watch = process.argv.includes("--watch");
4
5const options = {
6 entryPoints: ["src/main.ts"],
7 bundle: true,
8 outfile: "app.js",
9 format: "iife",
10 target: ["es2020"],
11 minify: !watch,
12 sourcemap: watch ? "inline" : false,
13 charset: "utf8",
14 logLevel: "info",
15};
16
17if (watch) {
18 const ctx = await context(options);
19 await ctx.watch();
20} else {
21 await build(options);
22}
diff --git a/web/play/index.html b/web/play/index.html
new file mode 100644
index 0000000..e9d4b79
--- /dev/null
+++ b/web/play/index.html
@@ -0,0 +1,112 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Play — żagań.bet</title>
7<meta name="description" content="Play slots, roulette, blackjack and crash with play credits. No real money.">
8<meta name="color-scheme" content="dark">
9<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; base-uri 'none'; form-action 'self'">
10<link rel="stylesheet" href="style.css">
11</head>
12<body>
13<div class="shell">
14 <header class="topbar">
15 <a class="brand" href="/">żagań<span>.bet</span></a>
16 <span class="tagline">Play credits · no real money</span>
17 <span class="spacer"></span>
18 <div id="session-bar" class="session" hidden>
19 <span class="who">Signed in as <strong id="account-name"></strong></span>
20 <span id="account-balance" class="balance">— Kr</span>
21 <button id="logout" class="btn ghost small" type="button">Log out</button>
22 </div>
23 </header>
24
25 <main>
26 <section id="auth-screen" hidden>
27 <div class="auth-wrap">
28 <div class="auth-card">
29 <h1>Play żagań.bet</h1>
30 <p class="muted" style="margin: 6px 0 20px">Play-money casino. Your balance is play credit; nothing here is real money.</p>
31 <div class="auth-tabs" role="tablist">
32 <button id="tab-login" class="auth-tab active" type="button" role="tab">Log in</button>
33 <button id="tab-register" class="auth-tab" type="button" role="tab">Register</button>
34 </div>
35 <form id="auth-form">
36 <label class="field">
37 <span class="label">Username</span>
38 <input id="auth-user" class="input" type="text" autocomplete="username" maxlength="32" required>
39 </label>
40 <label class="field">
41 <span class="label">Password</span>
42 <input id="auth-pass" class="input" type="password" autocomplete="current-password" required>
43 </label>
44 <p id="auth-error" class="error-text" style="margin: 0; min-height: 18px"></p>
45 <button id="auth-submit" class="btn primary wide big" type="submit">Log in</button>
46 </form>
47 <p class="auth-foot">Registration gives you 1 000 Kr of play credit. No deposits, no withdrawals, no personal data beyond a username.</p>
48 </div>
49 </div>
50 </section>
51
52 <section id="lobby-screen" hidden>
53 <div class="lobby-head">
54 <div>
55 <h1>Lobby</h1>
56 <p class="muted" style="margin: 4px 0 0">Choose a game. Bets are play credits.</p>
57 </div>
58 <span class="spacer"></span>
59 <button id="show-history" class="btn ghost" type="button">Play history</button>
60 <button id="refresh-lobby" class="btn ghost" type="button">Refresh</button>
61 </div>
62 <div id="game-grid" class="game-grid"></div>
63 </section>
64
65 <section id="game-screen" hidden>
66 <div class="game-head">
67 <button id="leave-game" class="btn ghost" type="button">← Lobby</button>
68 <h1 id="game-title">Game</h1>
69 <span class="spacer"></span>
70 <span id="game-badge" class="label"></span>
71 </div>
72 <div class="game-body">
73 <div id="game-board" class="board"></div>
74 <aside id="game-side" class="sidebar"></aside>
75 </div>
76 </section>
77
78 <section id="history-screen" hidden>
79 <div class="lobby-head">
80 <div>
81 <h1>Play history</h1>
82 <p class="muted" style="margin: 4px 0 0">The last plays on your account.</p>
83 </div>
84 <span class="spacer"></span>
85 <button id="history-back" class="btn ghost" type="button">← Lobby</button>
86 </div>
87 <div id="history-body"></div>
88 </section>
89 </main>
90</div>
91
92<div id="toasts" aria-live="polite"></div>
93
94<div id="replaced-overlay" class="overlay" hidden>
95 <div class="modal">
96 <h2>Opened in another tab</h2>
97 <p>This game was opened somewhere else. Only one tab can play a session at a time.</p>
98 <button id="reload-replaced" class="btn primary" type="button">Reload this tab</button>
99 </div>
100</div>
101
102<div id="reconnect-overlay" class="overlay" hidden>
103 <div class="modal">
104 <h2>Connection lost</h2>
105 <p id="reconnect-message">Trying to reach the game server…</p>
106 <button id="reload-reconnect" class="btn ghost" type="button">Reload</button>
107 </div>
108</div>
109
110<script src="app.js" defer></script>
111</body>
112</html>
diff --git a/web/play/package-lock.json b/web/play/package-lock.json
new file mode 100644
index 0000000..831527f
--- /dev/null
+++ b/web/play/package-lock.json
@@ -0,0 +1,1031 @@
1{
2 "name": "zagan-bet-play",
3 "version": "5.0.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "zagan-bet-play",
9 "version": "5.0.0",
10 "devDependencies": {
11 "esbuild": "^0.28.0",
12 "jsdom": "^30.1.3",
13 "typescript": "^5.9.0"
14 }
15 },
16 "node_modules/@asamuzakjp/css-color": {
17 "version": "7.1.9",
18 "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
19 "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
20 "dev": true,
21 "license": "MIT",
22 "dependencies": {
23 "@csstools/css-calc": "^3.4.3",
24 "@csstools/css-color-parser": "^4.2.6",
25 "@csstools/css-parser-algorithms": "^4.0.2",
26 "@csstools/css-tokenizer": "^4.0.2",
27 "lru-cache": "^11.5.3"
28 },
29 "engines": {
30 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
31 }
32 },
33 "node_modules/@asamuzakjp/dom-selector": {
34 "version": "10.0.5",
35 "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
36 "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
37 "dev": true,
38 "license": "MIT",
39 "dependencies": {
40 "bidi-js": "^1.1.0",
41 "css-tree": "^3.2.1",
42 "is-potential-custom-element-name": "^1.0.1",
43 "lru-cache": "^11.5.3"
44 },
45 "engines": {
46 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
47 }
48 },
49 "node_modules/@bramus/specificity": {
50 "version": "2.4.3",
51 "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
52 "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
53 "dev": true,
54 "license": "MIT",
55 "dependencies": {
56 "css-tree": "^3.2.1"
57 },
58 "bin": {
59 "specificity": "bin/cli.js"
60 }
61 },
62 "node_modules/@csstools/color-helpers": {
63 "version": "6.1.2",
64 "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
65 "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
66 "dev": true,
67 "funding": [
68 {
69 "type": "github",
70 "url": "https://github.com/sponsors/csstools"
71 },
72 {
73 "type": "opencollective",
74 "url": "https://opencollective.com/csstools"
75 }
76 ],
77 "license": "MIT-0",
78 "engines": {
79 "node": ">=20.19.0"
80 }
81 },
82 "node_modules/@csstools/css-calc": {
83 "version": "3.4.3",
84 "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
85 "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
86 "dev": true,
87 "funding": [
88 {
89 "type": "github",
90 "url": "https://github.com/sponsors/csstools"
91 },
92 {
93 "type": "opencollective",
94 "url": "https://opencollective.com/csstools"
95 }
96 ],
97 "license": "MIT",
98 "engines": {
99 "node": ">=20.19.0"
100 },
101 "peerDependencies": {
102 "@csstools/css-parser-algorithms": "^4.0.2",
103 "@csstools/css-tokenizer": "^4.0.2"
104 }
105 },
106 "node_modules/@csstools/css-color-parser": {
107 "version": "4.2.7",
108 "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
109 "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
110 "dev": true,
111 "funding": [
112 {
113 "type": "github",
114 "url": "https://github.com/sponsors/csstools"
115 },
116 {
117 "type": "opencollective",
118 "url": "https://opencollective.com/csstools"
119 }
120 ],
121 "license": "MIT",
122 "dependencies": {
123 "@csstools/color-helpers": "^6.1.2",
124 "@csstools/css-calc": "^3.4.3"
125 },
126 "engines": {
127 "node": ">=20.19.0"
128 },
129 "peerDependencies": {
130 "@csstools/css-parser-algorithms": "^4.0.2",
131 "@csstools/css-tokenizer": "^4.0.2"
132 }
133 },
134 "node_modules/@csstools/css-parser-algorithms": {
135 "version": "4.0.2",
136 "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
137 "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
138 "dev": true,
139 "funding": [
140 {
141 "type": "github",
142 "url": "https://github.com/sponsors/csstools"
143 },
144 {
145 "type": "opencollective",
146 "url": "https://opencollective.com/csstools"
147 }
148 ],
149 "license": "MIT",
150 "engines": {
151 "node": ">=20.19.0"
152 },
153 "peerDependencies": {
154 "@csstools/css-tokenizer": "^4.0.2"
155 }
156 },
157 "node_modules/@csstools/css-syntax-patches-for-csstree": {
158 "version": "1.1.16",
159 "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
160 "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
161 "dev": true,
162 "funding": [
163 {
164 "type": "github",
165 "url": "https://github.com/sponsors/csstools"
166 },
167 {
168 "type": "opencollective",
169 "url": "https://opencollective.com/csstools"
170 }
171 ],
172 "license": "MIT-0",
173 "peerDependencies": {
174 "css-tree": "^3.2.1"
175 },
176 "peerDependenciesMeta": {
177 "css-tree": {
178 "optional": true
179 }
180 }
181 },
182 "node_modules/@csstools/css-tokenizer": {
183 "version": "4.0.2",
184 "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
185 "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
186 "dev": true,
187 "funding": [
188 {
189 "type": "github",
190 "url": "https://github.com/sponsors/csstools"
191 },
192 {
193 "type": "opencollective",
194 "url": "https://opencollective.com/csstools"
195 }
196 ],
197 "license": "MIT",
198 "engines": {
199 "node": ">=20.19.0"
200 }
201 },
202 "node_modules/@esbuild/aix-ppc64": {
203 "version": "0.28.2",
204 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
205 "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
206 "cpu": [
207 "ppc64"
208 ],
209 "dev": true,
210 "license": "MIT",
211 "optional": true,
212 "os": [
213 "aix"
214 ],
215 "engines": {
216 "node": ">=18"
217 }
218 },
219 "node_modules/@esbuild/android-arm": {
220 "version": "0.28.2",
221 "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
222 "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
223 "cpu": [
224 "arm"
225 ],
226 "dev": true,
227 "license": "MIT",
228 "optional": true,
229 "os": [
230 "android"
231 ],
232 "engines": {
233 "node": ">=18"
234 }
235 },
236 "node_modules/@esbuild/android-arm64": {
237 "version": "0.28.2",
238 "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
239 "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
240 "cpu": [
241 "arm64"
242 ],
243 "dev": true,
244 "license": "MIT",
245 "optional": true,
246 "os": [
247 "android"
248 ],
249 "engines": {
250 "node": ">=18"
251 }
252 },
253 "node_modules/@esbuild/android-x64": {
254 "version": "0.28.2",
255 "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
256 "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
257 "cpu": [
258 "x64"
259 ],
260 "dev": true,
261 "license": "MIT",
262 "optional": true,
263 "os": [
264 "android"
265 ],
266 "engines": {
267 "node": ">=18"
268 }
269 },
270 "node_modules/@esbuild/darwin-arm64": {
271 "version": "0.28.2",
272 "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
273 "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
274 "cpu": [
275 "arm64"
276 ],
277 "dev": true,
278 "license": "MIT",
279 "optional": true,
280 "os": [
281 "darwin"
282 ],
283 "engines": {
284 "node": ">=18"
285 }
286 },
287 "node_modules/@esbuild/darwin-x64": {
288 "version": "0.28.2",
289 "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
290 "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
291 "cpu": [
292 "x64"
293 ],
294 "dev": true,
295 "license": "MIT",
296 "optional": true,
297 "os": [
298 "darwin"
299 ],
300 "engines": {
301 "node": ">=18"
302 }
303 },
304 "node_modules/@esbuild/freebsd-arm64": {
305 "version": "0.28.2",
306 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
307 "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
308 "cpu": [
309 "arm64"
310 ],
311 "dev": true,
312 "license": "MIT",
313 "optional": true,
314 "os": [
315 "freebsd"
316 ],
317 "engines": {
318 "node": ">=18"
319 }
320 },
321 "node_modules/@esbuild/freebsd-x64": {
322 "version": "0.28.2",
323 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
324 "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
325 "cpu": [
326 "x64"
327 ],
328 "dev": true,
329 "license": "MIT",
330 "optional": true,
331 "os": [
332 "freebsd"
333 ],
334 "engines": {
335 "node": ">=18"
336 }
337 },
338 "node_modules/@esbuild/linux-arm": {
339 "version": "0.28.2",
340 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
341 "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
342 "cpu": [
343 "arm"
344 ],
345 "dev": true,
346 "license": "MIT",
347 "optional": true,
348 "os": [
349 "linux"
350 ],
351 "engines": {
352 "node": ">=18"
353 }
354 },
355 "node_modules/@esbuild/linux-arm64": {
356 "version": "0.28.2",
357 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
358 "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
359 "cpu": [
360 "arm64"
361 ],
362 "dev": true,
363 "license": "MIT",
364 "optional": true,
365 "os": [
366 "linux"
367 ],
368 "engines": {
369 "node": ">=18"
370 }
371 },
372 "node_modules/@esbuild/linux-ia32": {
373 "version": "0.28.2",
374 "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
375 "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
376 "cpu": [
377 "ia32"
378 ],
379 "dev": true,
380 "license": "MIT",
381 "optional": true,
382 "os": [
383 "linux"
384 ],
385 "engines": {
386 "node": ">=18"
387 }
388 },
389 "node_modules/@esbuild/linux-loong64": {
390 "version": "0.28.2",
391 "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
392 "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
393 "cpu": [
394 "loong64"
395 ],
396 "dev": true,
397 "license": "MIT",
398 "optional": true,
399 "os": [
400 "linux"
401 ],
402 "engines": {
403 "node": ">=18"
404 }
405 },
406 "node_modules/@esbuild/linux-mips64el": {
407 "version": "0.28.2",
408 "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
409 "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
410 "cpu": [
411 "mips64el"
412 ],
413 "dev": true,
414 "license": "MIT",
415 "optional": true,
416 "os": [
417 "linux"
418 ],
419 "engines": {
420 "node": ">=18"
421 }
422 },
423 "node_modules/@esbuild/linux-ppc64": {
424 "version": "0.28.2",
425 "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
426 "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
427 "cpu": [
428 "ppc64"
429 ],
430 "dev": true,
431 "license": "MIT",
432 "optional": true,
433 "os": [
434 "linux"
435 ],
436 "engines": {
437 "node": ">=18"
438 }
439 },
440 "node_modules/@esbuild/linux-riscv64": {
441 "version": "0.28.2",
442 "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
443 "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
444 "cpu": [
445 "riscv64"
446 ],
447 "dev": true,
448 "license": "MIT",
449 "optional": true,
450 "os": [
451 "linux"
452 ],
453 "engines": {
454 "node": ">=18"
455 }
456 },
457 "node_modules/@esbuild/linux-s390x": {
458 "version": "0.28.2",
459 "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
460 "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
461 "cpu": [
462 "s390x"
463 ],
464 "dev": true,
465 "license": "MIT",
466 "optional": true,
467 "os": [
468 "linux"
469 ],
470 "engines": {
471 "node": ">=18"
472 }
473 },
474 "node_modules/@esbuild/linux-x64": {
475 "version": "0.28.2",
476 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
477 "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
478 "cpu": [
479 "x64"
480 ],
481 "dev": true,
482 "license": "MIT",
483 "optional": true,
484 "os": [
485 "linux"
486 ],
487 "engines": {
488 "node": ">=18"
489 }
490 },
491 "node_modules/@esbuild/netbsd-arm64": {
492 "version": "0.28.2",
493 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
494 "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
495 "cpu": [
496 "arm64"
497 ],
498 "dev": true,
499 "license": "MIT",
500 "optional": true,
501 "os": [
502 "netbsd"
503 ],
504 "engines": {
505 "node": ">=18"
506 }
507 },
508 "node_modules/@esbuild/netbsd-x64": {
509 "version": "0.28.2",
510 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
511 "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
512 "cpu": [
513 "x64"
514 ],
515 "dev": true,
516 "license": "MIT",
517 "optional": true,
518 "os": [
519 "netbsd"
520 ],
521 "engines": {
522 "node": ">=18"
523 }
524 },
525 "node_modules/@esbuild/openbsd-arm64": {
526 "version": "0.28.2",
527 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
528 "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
529 "cpu": [
530 "arm64"
531 ],
532 "dev": true,
533 "license": "MIT",
534 "optional": true,
535 "os": [
536 "openbsd"
537 ],
538 "engines": {
539 "node": ">=18"
540 }
541 },
542 "node_modules/@esbuild/openbsd-x64": {
543 "version": "0.28.2",
544 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
545 "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
546 "cpu": [
547 "x64"
548 ],
549 "dev": true,
550 "license": "MIT",
551 "optional": true,
552 "os": [
553 "openbsd"
554 ],
555 "engines": {
556 "node": ">=18"
557 }
558 },
559 "node_modules/@esbuild/openharmony-arm64": {
560 "version": "0.28.2",
561 "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
562 "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
563 "cpu": [
564 "arm64"
565 ],
566 "dev": true,
567 "license": "MIT",
568 "optional": true,
569 "os": [
570 "openharmony"
571 ],
572 "engines": {
573 "node": ">=18"
574 }
575 },
576 "node_modules/@esbuild/sunos-x64": {
577 "version": "0.28.2",
578 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
579 "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
580 "cpu": [
581 "x64"
582 ],
583 "dev": true,
584 "license": "MIT",
585 "optional": true,
586 "os": [
587 "sunos"
588 ],
589 "engines": {
590 "node": ">=18"
591 }
592 },
593 "node_modules/@esbuild/win32-arm64": {
594 "version": "0.28.2",
595 "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
596 "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
597 "cpu": [
598 "arm64"
599 ],
600 "dev": true,
601 "license": "MIT",
602 "optional": true,
603 "os": [
604 "win32"
605 ],
606 "engines": {
607 "node": ">=18"
608 }
609 },
610 "node_modules/@esbuild/win32-ia32": {
611 "version": "0.28.2",
612 "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
613 "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
614 "cpu": [
615 "ia32"
616 ],
617 "dev": true,
618 "license": "MIT",
619 "optional": true,
620 "os": [
621 "win32"
622 ],
623 "engines": {
624 "node": ">=18"
625 }
626 },
627 "node_modules/@esbuild/win32-x64": {
628 "version": "0.28.2",
629 "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
630 "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
631 "cpu": [
632 "x64"
633 ],
634 "dev": true,
635 "license": "MIT",
636 "optional": true,
637 "os": [
638 "win32"
639 ],
640 "engines": {
641 "node": ">=18"
642 }
643 },
644 "node_modules/@exodus/bytes": {
645 "version": "1.16.0",
646 "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
647 "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
648 "dev": true,
649 "license": "MIT",
650 "engines": {
651 "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
652 },
653 "peerDependencies": {
654 "@noble/hashes": "^1.8.0 || ^2.0.0"
655 },
656 "peerDependenciesMeta": {
657 "@noble/hashes": {
658 "optional": true
659 }
660 }
661 },
662 "node_modules/bidi-js": {
663 "version": "1.1.0",
664 "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
665 "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
666 "dev": true,
667 "license": "MIT",
668 "dependencies": {
669 "require-from-string": "^2.0.2"
670 }
671 },
672 "node_modules/css-tree": {
673 "version": "3.2.1",
674 "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
675 "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
676 "dev": true,
677 "license": "MIT",
678 "dependencies": {
679 "mdn-data": "2.27.1",
680 "source-map-js": "^1.2.1"
681 },
682 "engines": {
683 "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
684 }
685 },
686 "node_modules/data-urls": {
687 "version": "8.0.0",
688 "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
689 "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
690 "dev": true,
691 "license": "MIT",
692 "dependencies": {
693 "whatwg-mimetype": "^5.0.0",
694 "whatwg-url": "^17.1.2"
695 },
696 "engines": {
697 "node": "^22.14.0 || >=24.0.0"
698 }
699 },
700 "node_modules/decimal.js": {
701 "version": "10.6.0",
702 "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
703 "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
704 "dev": true,
705 "license": "MIT"
706 },
707 "node_modules/entities": {
708 "version": "8.1.0",
709 "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
710 "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
711 "dev": true,
712 "license": "BSD-2-Clause",
713 "engines": {
714 "node": ">=20.19.0"
715 },
716 "funding": {
717 "url": "https://github.com/fb55/entities?sponsor=1"
718 }
719 },
720 "node_modules/esbuild": {
721 "version": "0.28.2",
722 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
723 "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
724 "dev": true,
725 "hasInstallScript": true,
726 "license": "MIT",
727 "bin": {
728 "esbuild": "bin/esbuild"
729 },
730 "engines": {
731 "node": ">=18"
732 },
733 "optionalDependencies": {
734 "@esbuild/aix-ppc64": "0.28.2",
735 "@esbuild/android-arm": "0.28.2",
736 "@esbuild/android-arm64": "0.28.2",
737 "@esbuild/android-x64": "0.28.2",
738 "@esbuild/darwin-arm64": "0.28.2",
739 "@esbuild/darwin-x64": "0.28.2",
740 "@esbuild/freebsd-arm64": "0.28.2",
741 "@esbuild/freebsd-x64": "0.28.2",
742 "@esbuild/linux-arm": "0.28.2",
743 "@esbuild/linux-arm64": "0.28.2",
744 "@esbuild/linux-ia32": "0.28.2",
745 "@esbuild/linux-loong64": "0.28.2",
746 "@esbuild/linux-mips64el": "0.28.2",
747 "@esbuild/linux-ppc64": "0.28.2",
748 "@esbuild/linux-riscv64": "0.28.2",
749 "@esbuild/linux-s390x": "0.28.2",
750 "@esbuild/linux-x64": "0.28.2",
751 "@esbuild/netbsd-arm64": "0.28.2",
752 "@esbuild/netbsd-x64": "0.28.2",
753 "@esbuild/openbsd-arm64": "0.28.2",
754 "@esbuild/openbsd-x64": "0.28.2",
755 "@esbuild/openharmony-arm64": "0.28.2",
756 "@esbuild/sunos-x64": "0.28.2",
757 "@esbuild/win32-arm64": "0.28.2",
758 "@esbuild/win32-ia32": "0.28.2",
759 "@esbuild/win32-x64": "0.28.2"
760 }
761 },
762 "node_modules/html-encoding-sniffer": {
763 "version": "7.0.0",
764 "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
765 "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
766 "dev": true,
767 "license": "MIT",
768 "dependencies": {
769 "@exodus/bytes": "^1.15.1"
770 },
771 "engines": {
772 "node": "^22.13.0 || >=24.0.0"
773 }
774 },
775 "node_modules/is-potential-custom-element-name": {
776 "version": "1.0.1",
777 "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
778 "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
779 "dev": true,
780 "license": "MIT"
781 },
782 "node_modules/jsdom": {
783 "version": "30.1.3",
784 "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
785 "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
786 "dev": true,
787 "license": "MIT",
788 "dependencies": {
789 "@asamuzakjp/css-color": "^7.1.3",
790 "@asamuzakjp/dom-selector": "^10.0.3",
791 "@bramus/specificity": "^2.4.2",
792 "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
793 "@exodus/bytes": "^1.16.0",
794 "css-tree": "^3.2.1",
795 "data-urls": "^8.0.0",
796 "decimal.js": "^10.6.0",
797 "html-encoding-sniffer": "^7.0.0",
798 "is-potential-custom-element-name": "^1.0.1",
799 "lru-cache": "^11.5.3",
800 "parse5": "^8.0.1",
801 "saxes": "^6.0.0",
802 "tough-cookie": "^6.0.2",
803 "undici": "^8.12.1",
804 "w3c-xmlserializer": "^6.0.0",
805 "webidl-conversions": "^8.0.1",
806 "whatwg-mimetype": "^5.0.0",
807 "whatwg-url": "^17.2.0",
808 "xml-name-validator": "^5.0.0"
809 },
810 "engines": {
811 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
812 },
813 "peerDependencies": {
814 "canvas": "^3.2.3"
815 },
816 "peerDependenciesMeta": {
817 "canvas": {
818 "optional": true
819 }
820 }
821 },
822 "node_modules/lru-cache": {
823 "version": "11.5.3",
824 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
825 "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
826 "dev": true,
827 "license": "BlueOak-1.0.0",
828 "engines": {
829 "node": "20 || >=22"
830 }
831 },
832 "node_modules/mdn-data": {
833 "version": "2.27.1",
834 "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
835 "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
836 "dev": true,
837 "license": "CC0-1.0"
838 },
839 "node_modules/parse5": {
840 "version": "8.0.1",
841 "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
842 "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
843 "dev": true,
844 "license": "MIT",
845 "dependencies": {
846 "entities": "^8.0.0"
847 },
848 "funding": {
849 "url": "https://github.com/inikulin/parse5?sponsor=1"
850 }
851 },
852 "node_modules/punycode": {
853 "version": "2.3.1",
854 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
855 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
856 "dev": true,
857 "license": "MIT",
858 "engines": {
859 "node": ">=6"
860 }
861 },
862 "node_modules/require-from-string": {
863 "version": "2.0.2",
864 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
865 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
866 "dev": true,
867 "license": "MIT",
868 "engines": {
869 "node": ">=0.10.0"
870 }
871 },
872 "node_modules/saxes": {
873 "version": "6.0.0",
874 "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
875 "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
876 "dev": true,
877 "license": "ISC",
878 "dependencies": {
879 "xmlchars": "^2.2.0"
880 },
881 "engines": {
882 "node": ">=v12.22.7"
883 }
884 },
885 "node_modules/source-map-js": {
886 "version": "1.2.2",
887 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
888 "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
889 "dev": true,
890 "license": "BSD-3-Clause",
891 "engines": {
892 "node": ">=0.10.0"
893 }
894 },
895 "node_modules/tldts": {
896 "version": "7.4.19",
897 "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
898 "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
899 "dev": true,
900 "license": "MIT",
901 "dependencies": {
902 "tldts-core": "^7.4.19"
903 },
904 "bin": {
905 "tldts": "bin/cli.js"
906 }
907 },
908 "node_modules/tldts-core": {
909 "version": "7.4.20",
910 "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
911 "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
912 "dev": true,
913 "license": "MIT"
914 },
915 "node_modules/tough-cookie": {
916 "version": "6.0.2",
917 "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
918 "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
919 "dev": true,
920 "license": "BSD-3-Clause",
921 "dependencies": {
922 "tldts": "^7.0.5"
923 },
924 "engines": {
925 "node": ">=16"
926 }
927 },
928 "node_modules/tr46": {
929 "version": "7.0.0",
930 "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
931 "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
932 "dev": true,
933 "license": "MIT",
934 "dependencies": {
935 "punycode": "^2.3.1"
936 },
937 "engines": {
938 "node": "^22.14.0 || >=24.0.0"
939 }
940 },
941 "node_modules/typescript": {
942 "version": "5.9.3",
943 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
944 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
945 "dev": true,
946 "license": "Apache-2.0",
947 "bin": {
948 "tsc": "bin/tsc",
949 "tsserver": "bin/tsserver"
950 },
951 "engines": {
952 "node": ">=14.17"
953 }
954 },
955 "node_modules/undici": {
956 "version": "8.12.3",
957 "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
958 "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
959 "dev": true,
960 "license": "MIT",
961 "engines": {
962 "node": ">=22.19.0"
963 }
964 },
965 "node_modules/w3c-xmlserializer": {
966 "version": "6.0.0",
967 "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
968 "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
969 "dev": true,
970 "license": "MIT",
971 "dependencies": {
972 "xml-name-validator": "^5.0.0"
973 },
974 "engines": {
975 "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
976 }
977 },
978 "node_modules/webidl-conversions": {
979 "version": "8.0.1",
980 "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
981 "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
982 "dev": true,
983 "license": "BSD-2-Clause",
984 "engines": {
985 "node": ">=20"
986 }
987 },
988 "node_modules/whatwg-mimetype": {
989 "version": "5.0.0",
990 "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
991 "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
992 "dev": true,
993 "license": "MIT",
994 "engines": {
995 "node": ">=20"
996 }
997 },
998 "node_modules/whatwg-url": {
999 "version": "17.3.0",
1000 "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
1001 "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
1002 "dev": true,
1003 "license": "MIT",
1004 "dependencies": {
1005 "@exodus/bytes": "^1.16.0",
1006 "tr46": "^7.0.0",
1007 "webidl-conversions": "^8.0.1"
1008 },
1009 "engines": {
1010 "node": "^22.14.0 || >=24.0.0"
1011 }
1012 },
1013 "node_modules/xml-name-validator": {
1014 "version": "5.0.0",
1015 "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
1016 "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
1017 "dev": true,
1018 "license": "Apache-2.0",
1019 "engines": {
1020 "node": ">=18"
1021 }
1022 },
1023 "node_modules/xmlchars": {
1024 "version": "2.2.0",
1025 "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
1026 "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
1027 "dev": true,
1028 "license": "MIT"
1029 }
1030 }
1031}
diff --git a/web/play/package.json b/web/play/package.json
new file mode 100644
index 0000000..3e3cebf
--- /dev/null
+++ b/web/play/package.json
@@ -0,0 +1,20 @@
1{
2 "name": "zagan-bet-play",
3 "private": true,
4 "version": "5.0.0",
5 "type": "module",
6 "scripts": {
7 "build": "node build.mjs",
8 "watch": "node build.mjs --watch",
9 "typecheck": "tsc --noEmit",
10 "test": "node test/smoke.mjs"
11 },
12 "devDependencies": {
13 "esbuild": "^0.28.0",
14 "jsdom": "^30.1.3",
15 "typescript": "^5.9.0"
16 },
17 "engines": {
18 "node": ">=20"
19 }
20}
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts
new file mode 100644
index 0000000..3537f45
--- /dev/null
+++ b/web/play/src/blackjack.ts
@@ -0,0 +1,152 @@
1import type { View } from "./types";
2import { cardFace, clear, money, node } from "./dom";
3
4function cardElement(card: string): HTMLElement {
5 if (card === "?") {
6 const back = node("div", "card back");
7 back.append(node("span", "rank", "?"), node("span", "suit", ""));
8 return back;
9 }
10 const face = cardFace(card);
11 const element = node("div", "card");
12 if (face.red) element.classList.add("red");
13 element.append(node("span", "rank", face.rank), node("span", "suit", face.suit));
14 return element;
15}
16
17function handBlock(label: string, cards: string[], total: number): HTMLElement {
18 const block = node("div", "hand");
19 block.append(node("div", "label", label));
20 const row = node("div", "cards");
21 for (const card of cards) row.append(cardElement(card));
22 const sum = node("div", "total");
23 sum.append(document.createTextNode("Total "), node("strong", undefined, String(total)));
24 block.append(row, sum);
25 return block;
26}
27
28function outcome(view: View, hand: any): string {
29 const result = String(hand?.result ?? "");
30 const winnings = Number(hand?.winnings ?? 0);
31 switch (result) {
32 case "blackjack":
33 return `Blackjack — won ${money(winnings)}`;
34 case "win":
35 return `You win — ${money(winnings)}`;
36 case "push":
37 return `Push — ${money(winnings)} returned`;
38 case "bust":
39 return "Bust — the bet is lost";
40 case "lose":
41 return "Dealer wins — the bet is lost";
42 case "forfeit":
43 return "Hand forfeited — the bet is lost";
44 case "playing":
45 return view.status === "player" ? "Your move." : "Dealing…";
46 default:
47 return "Ready to deal.";
48 }
49}
50
51export function renderBlackjack(
52 board: HTMLElement,
53 side: HTMLElement,
54 view: View,
55 send: (command: unknown) => void,
56): void {
57 clear(board);
58 clear(side);
59
60 const hand = view.hand;
61 const playerPhase = hand?.phase === "player" || view.status === "player";
62 const done = hand?.phase === "done" || view.status === "done";
63 const canDeal = !hand || done;
64
65 board.append(node("h2", undefined, "Blackjack"));
66 board.append(
67 node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."),
68 );
69
70 if (hand) {
71 board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0)));
72 board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0)));
73 } else {
74 const empty = node("div", "outcome");
75 empty.textContent = "Press DEAL to start a hand.";
76 board.append(empty);
77 }
78
79 if (done || (hand && !playerPhase)) {
80 const result = node("div", "outcome");
81 result.textContent = outcome(view, hand);
82 if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") {
83 result.classList.add("win-text");
84 }
85 board.append(result);
86 }
87
88 if (view.error) board.append(node("p", "error-text", String(view.error)));
89
90 // Sidebar
91 side.append(node("div", "label", "Blackjack"));
92 const bet = Number(hand?.bet ?? view.bet ?? 0);
93 const balance = view.balance ?? view.hand?.balance;
94 side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet."));
95
96 if (canDeal) {
97 const betRow = node("div", "row");
98 const minus = node("button", "btn small", "−10");
99 const amount = node("strong", "mono", money(bet));
100 const plus = node("button", "btn small", "+10");
101 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
102 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
103 betRow.append(minus, amount, plus);
104 side.append(node("div", "label", "Bet"), betRow);
105 } else {
106 const stat = node("div", "stat");
107 stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet)));
108 side.append(stat);
109 }
110
111 if (balance !== undefined) {
112 const stat = node("div", "stat");
113 stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance))));
114 side.append(stat);
115 }
116
117 const actions = node("div", "actions");
118 if (playerPhase) {
119 const actionsRow = node("div", "row wrap");
120 for (const action of hand?.actions ?? []) {
121 const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE";
122 const button =
123 action === "stand"
124 ? node("button", "btn primary", label)
125 : node("button", "btn", label);
126 button.addEventListener("click", () => {
127 if (action === "hit") send("Hit");
128 else if (action === "stand") send("Stand");
129 else send("Double");
130 });
131 actionsRow.append(button);
132 }
133 actions.append(actionsRow);
134 const forfeit = node("button", "btn danger wide", "Forfeit hand");
135 forfeit.addEventListener("click", () => {
136 if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit");
137 });
138 actions.append(forfeit);
139 } else if (canDeal) {
140 const deal = node("button", "btn primary wide big", "DEAL");
141 deal.addEventListener("click", () => send("Deal"));
142 actions.append(deal);
143 }
144
145 const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table");
146 leave.addEventListener("click", () => {
147 if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return;
148 send("Leave");
149 });
150 actions.append(leave);
151 side.append(actions);
152}
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts
new file mode 100644
index 0000000..6f3c8dd
--- /dev/null
+++ b/web/play/src/crash.ts
@@ -0,0 +1,365 @@
1import type { View } from "./types";
2import { clear, money, node, shortHash } from "./dom";
3
4const GROWTH = Math.LN2 / 5;
5
6type Clock = { now: () => number };
7
8type Parts = {
9 board: HTMLElement;
10 side: HTMLElement;
11 number: HTMLElement;
12 meta: HTMLElement;
13 verify: HTMLElement;
14 canvas: HTMLCanvasElement;
15 bet: HTMLElement;
16 potential: HTMLElement;
17 balance: HTMLElement;
18 status: HTMLElement;
19 lastCrash: HTMLElement;
20 error: HTMLElement;
21 primary: HTMLButtonElement;
22 leave: HTMLButtonElement;
23 minus: HTMLButtonElement;
24 plus: HTMLButtonElement;
25};
26
27let parts: Parts | null = null;
28let current: View | null = null;
29let clock: Clock | null = null;
30let sendCommand: ((command: unknown) => void) | null = null;
31let frameHandle: number | undefined;
32let verifyToken = 0;
33let lastStatus = "";
34
35function build(
36 board: HTMLElement,
37 side: HTMLElement,
38 send: (command: unknown) => void,
39 now: Clock,
40): Parts {
41 clear(board);
42 clear(side);
43 sendCommand = send;
44 clock = now;
45
46 board.append(node("h2", undefined, "Crash"));
47 board.append(
48 node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."),
49 );
50
51 const wrap = node("div", "crash-wrap");
52 const number = node("div", "crash-number", "1.00×");
53 const meta = node("div", "crash-meta");
54 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement;
55 const verify = node("div", "verify", "Waiting for a round commitment.");
56 wrap.append(number, meta, canvas, verify);
57 board.append(wrap);
58
59 side.append(node("div", "label", "Crash"));
60 side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends."));
61
62 const betRow = node("div", "row");
63 const minus = node("button", "btn small", "−10") as HTMLButtonElement;
64 const bet = node("strong", "mono", "—");
65 const plus = node("button", "btn small", "+10") as HTMLButtonElement;
66 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 }));
67 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 }));
68 betRow.append(minus, bet, plus);
69 side.append(node("div", "label", "Bet"), betRow);
70
71 const potential = node("strong", "mono", "—");
72 const balance = node("strong", "mono", "—");
73 for (const [label, value] of [
74 ["Potential", potential],
75 ["Balance", balance],
76 ] as const) {
77 const row = node("div", "stat");
78 row.append(node("span", "muted", label), value);
79 side.append(row);
80 }
81
82 const status = node("p", "note", "Ready.");
83 const lastCrash = node("p", "note", "");
84 const error = node("p", "error-text");
85 side.append(status, lastCrash, error);
86
87 const actions = node("div", "actions");
88 const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement;
89 const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement;
90 primary.addEventListener("click", () => {
91 if (current?.status === "running") sendCommand?.("CashOut");
92 else sendCommand?.("Start");
93 });
94 leave.addEventListener("click", () => {
95 if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return;
96 sendCommand?.("Leave");
97 });
98 actions.append(primary, leave);
99 side.append(actions);
100
101 return {
102 board,
103 side,
104 number,
105 meta,
106 verify,
107 canvas,
108 bet,
109 potential,
110 balance,
111 status,
112 lastCrash,
113 error,
114 primary,
115 leave,
116 minus,
117 plus,
118 };
119}
120
121export function renderCrash(
122 board: HTMLElement,
123 side: HTMLElement,
124 view: View,
125 send: (command: unknown) => void,
126 now: Clock,
127): void {
128 if (!parts || parts.board !== board) {
129 parts = build(board, side, send, now);
130 lastStatus = "";
131 }
132 const p = parts;
133 sendCommand = send;
134 clock = now;
135 current = view;
136
137 if (view.status !== lastStatus) {
138 if (view.status === "starting" || view.status === "running") {
139 if (lastStatus !== "running") {
140 p.verify.className = "verify";
141 p.verify.textContent = "Round committed; seed hidden until the round ends.";
142 }
143 }
144 lastStatus = String(view.status);
145 }
146
147 const running = view.status === "running";
148 const starting = view.status === "starting";
149 const live = running || starting;
150
151 p.bet.textContent = money(Number(view.bet ?? 0));
152 p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0));
153 p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance));
154 p.error.textContent = view.error ? String(view.error) : "";
155
156 p.minus.disabled = live;
157 p.plus.disabled = live;
158 p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START";
159 p.primary.disabled = starting;
160 p.primary.className = running ? "btn primary wide big" : "btn primary wide big";
161 p.leave.textContent = running ? "Forfeit round" : "Leave table";
162
163 if (running) {
164 p.status.textContent = "Rising — cash out before the crash.";
165 p.status.className = "note win-text";
166 } else if (starting) {
167 p.status.textContent = "Starting the round…";
168 p.status.className = "note";
169 } else if (view.status === "cashed") {
170 p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`;
171 p.status.className = "note win-text";
172 } else if (view.status === "crashed") {
173 p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`;
174 p.status.className = "note error-text";
175 } else if (view.status === "forfeit") {
176 p.status.textContent = "Round forfeited.";
177 p.status.className = "note error-text";
178 } else {
179 p.status.textContent = "Ready. Start a round when you want.";
180 p.status.className = "note";
181 }
182
183 if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") {
184 p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`;
185 }
186
187 const commit = view.commit ? String(view.commit) : "";
188 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet";
189
190 if (view.seed && commit) {
191 const token = ++verifyToken;
192 const seed = String(view.seed);
193 const crashPoint = Number(view.crash_point ?? 0);
194 void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => {
195 if (token !== verifyToken || !parts) return;
196 const ok = commitOk && pointOk;
197 parts.verify.className = `verify ${ok ? "ok" : "bad"}`;
198 parts.verify.textContent = ok
199 ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed`
200 : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`;
201 });
202 } else if (!live && !commit) {
203 p.verify.className = "verify";
204 p.verify.textContent = "Waiting for a round commitment.";
205 }
206
207 if (frameHandle !== undefined) {
208 cancelAnimationFrame(frameHandle);
209 frameHandle = undefined;
210 }
211 if (running) {
212 const loop = () => {
213 if (!parts || current?.status !== "running") {
214 frameHandle = undefined;
215 return;
216 }
217 update(parts, current, now);
218 frameHandle = requestAnimationFrame(loop);
219 };
220 frameHandle = requestAnimationFrame(loop);
221 } else {
222 update(p, view, now);
223 }
224}
225
226function currentMultiplier(view: View, now: Clock): number {
227 if (view.status === "running") {
228 const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000);
229 const local = Math.min(1000, Math.exp(GROWTH * elapsed));
230 return Math.max(local, Number(view.multiplier ?? 1));
231 }
232 if (view.status === "cashed") return Number(view.multiplier ?? 1);
233 if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1);
234 return 1;
235}
236
237function update(p: Parts, view: View, now: Clock): void {
238 const multiplier = currentMultiplier(view, now);
239 p.number.textContent = `${multiplier.toFixed(2)}×`;
240 p.number.className = `crash-number ${
241 view.status === "running"
242 ? "running"
243 : view.status === "cashed"
244 ? "cashed"
245 : view.status === "crashed"
246 ? "crashed"
247 : ""
248 }`;
249 p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier));
250 drawChart(p.canvas, view, multiplier, now);
251}
252
253function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void {
254 const width = canvas.clientWidth || 600;
255 const height = canvas.clientHeight || 360;
256 const dpr = window.devicePixelRatio || 1;
257 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
258 canvas.width = Math.round(width * dpr);
259 canvas.height = Math.round(height * dpr);
260 }
261 const ctx = canvas.getContext("2d");
262 if (!ctx) return;
263 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
264 ctx.clearRect(0, 0, width, height);
265
266 const padL = 54;
267 const padR = 16;
268 const padT = 18;
269 const padB = 34;
270 const plotW = Math.max(1, width - padL - padR);
271 const plotH = Math.max(1, height - padT - padB);
272
273 const elapsed =
274 view.status === "running"
275 ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000)
276 : multiplier > 1
277 ? Math.log(multiplier) / GROWTH
278 : 1;
279 const xMax = Math.max(5, elapsed * 1.08);
280 const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2);
281 const x = (seconds: number) => padL + (seconds / xMax) * plotW;
282 const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH;
283
284 ctx.strokeStyle = "#242e38";
285 ctx.fillStyle = "#8a98a6";
286 ctx.font = "11px ui-monospace, monospace";
287 ctx.lineWidth = 1;
288 for (let step = 0; step <= 4; step += 1) {
289 const value = 1 + ((yMax - 1) * step) / 4;
290 const py = y(value);
291 ctx.beginPath();
292 ctx.moveTo(padL, py);
293 ctx.lineTo(width - padR, py);
294 ctx.stroke();
295 ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4);
296 }
297 for (let step = 0; step <= 4; step += 1) {
298 const seconds = (xMax * step) / 4;
299 const px = x(seconds);
300 ctx.beginPath();
301 ctx.moveTo(px, padT);
302 ctx.lineTo(px, padT + plotH);
303 ctx.strokeStyle = "#1b232c";
304 ctx.stroke();
305 ctx.fillStyle = "#5b6773";
306 ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12);
307 }
308
309 const line =
310 view.status === "crashed"
311 ? "#e05a5a"
312 : view.status === "cashed"
313 ? "#4ec27b"
314 : "#e0b64a";
315
316 ctx.beginPath();
317 const samples = 180;
318 for (let index = 0; index <= samples; index += 1) {
319 const seconds = (elapsed * index) / samples;
320 const value = Math.min(yMax, Math.exp(GROWTH * seconds));
321 const px = x(seconds);
322 const py = y(value);
323 if (index === 0) ctx.moveTo(px, py);
324 else ctx.lineTo(px, py);
325 }
326 ctx.strokeStyle = line;
327 ctx.lineWidth = 2.4;
328 ctx.stroke();
329
330 const endX = x(elapsed);
331 const endY = y(Math.min(yMax, multiplier));
332 ctx.beginPath();
333 ctx.arc(endX, endY, 4.5, 0, Math.PI * 2);
334 ctx.fillStyle = line;
335 ctx.fill();
336
337 ctx.fillStyle = line;
338 ctx.font = "600 12px ui-monospace, monospace";
339 ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10));
340}
341
342async function verifyReveal(
343 seed: string,
344 commit: string,
345 crashPoint: number,
346): Promise<[boolean, boolean]> {
347 const bytes = new Uint8Array(seed.length / 2);
348 for (let index = 0; index < bytes.length; index += 1) {
349 bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16);
350 }
351 const digest = await crypto.subtle.digest("SHA-256", bytes);
352 const actual = [...new Uint8Array(digest)]
353 .map((byte) => byte.toString(16).padStart(2, "0"))
354 .join("");
355 const commitOk = actual === commit.toLowerCase();
356
357 let uniform = 0;
358 for (let index = 0; index < 8; index += 1) {
359 uniform = uniform * 256 + bytes[index];
360 }
361 uniform /= 2 ** 64;
362 const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform)));
363 const pointOk = Math.abs(point - crashPoint) < 1e-6;
364 return [commitOk, pointOk];
365}
diff --git a/web/play/src/dom.ts b/web/play/src/dom.ts
new file mode 100644
index 0000000..d8ed212
--- /dev/null
+++ b/web/play/src/dom.ts
@@ -0,0 +1,104 @@
1export function byId<T extends HTMLElement = HTMLElement>(id: string): T {
2 const node = document.getElementById(id);
3 if (!node) throw new Error(`missing #${id}`);
4 return node as T;
5}
6
7export function node<K extends keyof HTMLElementTagNameMap>(
8 tag: K,
9 className?: string,
10 text?: string,
11): HTMLElementTagNameMap[K] {
12 const element = document.createElement(tag);
13 if (className) element.className = className;
14 if (text !== undefined) element.textContent = text;
15 return element;
16}
17
18export function clear(element: HTMLElement): void {
19 element.replaceChildren();
20}
21
22export function groupDigits(amount: number): string {
23 const negative = amount < 0;
24 const digits = String(Math.abs(Math.trunc(amount)));
25 let grouped = "";
26 for (let index = 0; index < digits.length; index += 1) {
27 if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009";
28 grouped += digits[index];
29 }
30 return negative ? `-${grouped}` : grouped;
31}
32
33export function money(amount: number | null | undefined): string {
34 if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr";
35 return `${groupDigits(amount)} Kr`;
36}
37
38export function signedMoney(amount: number): string {
39 return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`;
40}
41
42export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void {
43 const root = document.getElementById("toasts");
44 if (!root || !text) return;
45 const item = node("div", `toast ${tone}`, text);
46 root.append(item);
47 window.setTimeout(() => {
48 item.style.opacity = "0";
49 item.style.transition = "opacity 220ms ease";
50 window.setTimeout(() => item.remove(), 240);
51 }, 4200);
52 while (root.children.length > 4) root.firstElementChild?.remove();
53}
54
55export function toneFor(text: string): "info" | "good" | "bad" {
56 const lowered = text.toLowerCase();
57 if (
58 text.startsWith("ERROR") ||
59 lowered.includes("failed") ||
60 lowered.includes("lost") ||
61 lowered.includes("invalid") ||
62 lowered.includes("forfeit")
63 ) {
64 return "bad";
65 }
66 if (
67 text.startsWith("Logged in") ||
68 text.startsWith("Account created") ||
69 lowered.includes("won") ||
70 lowered.includes("cashed")
71 ) {
72 return "good";
73 }
74 return "info";
75}
76
77export async function sha256Hex(text: string): Promise<string> {
78 const bytes = new TextEncoder().encode(text);
79 const digest = await crypto.subtle.digest("SHA-256", bytes);
80 return [...new Uint8Array(digest)]
81 .map((byte) => byte.toString(16).padStart(2, "0"))
82 .join("");
83}
84
85export function shortHash(hash: string, keep = 10): string {
86 if (hash.length <= keep * 2) return hash;
87 return `${hash.slice(0, keep)}…${hash.slice(-keep)}`;
88}
89
90export function formatTime(raw: string): string {
91 // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC.
92 const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z");
93 if (Number.isNaN(parsed)) return raw;
94 return new Date(parsed).toLocaleString();
95}
96
97export function cardFace(card: string): { rank: string; suit: string; red: boolean } {
98 if (card === "?" || !card) return { rank: "?", suit: "", red: false };
99 const suit = card.slice(-1);
100 const rank = card.slice(0, -1);
101 const glyph =
102 suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit;
103 return { rank, suit: glyph, red: suit === "D" || suit === "H" };
104}
diff --git a/web/play/src/history.ts b/web/play/src/history.ts
new file mode 100644
index 0000000..9e7a564
--- /dev/null
+++ b/web/play/src/history.ts
@@ -0,0 +1,60 @@
1import type { View } from "./types";
2import { clear, formatTime, money, node, signedMoney } from "./dom";
3
4function summary(record: any): string {
5 const details = record.details ?? {};
6 switch (record.game) {
7 case "slots":
8 return String(details.win_type ?? "spin");
9 case "roulette":
10 return `${details.number ?? "?"} ${details.color ?? ""}`.trim();
11 case "blackjack":
12 return String(details.result ?? "");
13 case "crash": {
14 const result = String(details.result ?? "");
15 const point = details.crash_point;
16 return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`;
17 }
18 default:
19 return "";
20 }
21}
22
23export function renderHistory(root: HTMLElement, view: View): void {
24 clear(root);
25 const records: any[] = view.records ?? [];
26 if (records.length === 0) {
27 const empty = node("div", "board");
28 empty.append(
29 node("h2", undefined, "No plays yet"),
30 node("p", "muted", "Your settled plays will appear here."),
31 );
32 root.append(empty);
33 return;
34 }
35
36 const wrap = node("div", "table-wrap");
37 const table = node("table");
38 const head = node("thead");
39 const headRow = node("tr");
40 for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) {
41 headRow.append(node("th", undefined, label));
42 }
43 head.append(headRow);
44 const body = node("tbody");
45 for (const record of records) {
46 const row = node("tr");
47 row.append(node("td", "mono", formatTime(String(record.at ?? ""))));
48 row.append(node("td", undefined, String(record.game ?? "")));
49 row.append(node("td", "num", money(Number(record.bet ?? 0))));
50 row.append(node("td", "muted", summary(record)));
51 const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0);
52 const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net));
53 row.append(netCell);
54 row.append(node("td", "num", money(Number(record.balance_after ?? 0))));
55 body.append(row);
56 }
57 table.append(head, body);
58 wrap.append(table);
59 root.append(wrap);
60}
diff --git a/web/play/src/main.ts b/web/play/src/main.ts
new file mode 100644
index 0000000..fd55250
--- /dev/null
+++ b/web/play/src/main.ts
@@ -0,0 +1,323 @@
1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom";
2import { Gateway } from "./net";
3import { renderBlackjack } from "./blackjack";
4import { renderCrash } from "./crash";
5import { renderHistory } from "./history";
6import { renderRoulette } from "./roulette";
7import { renderSlots } from "./slots";
8import type { View } from "./types";
9
10const authScreen = byId("auth-screen");
11const lobbyScreen = byId("lobby-screen");
12const gameScreen = byId("game-screen");
13const historyScreen = byId("history-screen");
14const sessionBar = byId("session-bar");
15const accountName = byId("account-name");
16const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board");
19const gameSide = byId("game-side");
20const gameTitle = byId("game-title");
21const gameBadge = byId("game-badge");
22const historyBody = byId("history-body");
23const authForm = byId<HTMLFormElement>("auth-form");
24const authUser = byId<HTMLInputElement>("auth-user");
25const authPass = byId<HTMLInputElement>("auth-pass");
26const authError = byId("auth-error");
27const authSubmit = byId<HTMLButtonElement>("auth-submit");
28const tabLogin = byId<HTMLButtonElement>("tab-login");
29const tabRegister = byId<HTMLButtonElement>("tab-register");
30const reconnectOverlay = byId("reconnect-overlay");
31const reconnectMessage = byId("reconnect-message");
32const replacedOverlay = byId("replaced-overlay");
33
34let view: View | null = null;
35let currentUser: string | null = null;
36let lastBalance: number | null = null;
37let authMode: "login" | "register" = "login";
38let infoFor: string | null = null;
39let serverOffset = 0;
40let reconnectAttempts = 0;
41const bets = new Map<string, number>([
42 ["slots", 10],
43 ["roulette", 10],
44 ["blackjack", 10],
45 ["crash", 10],
46]);
47
48const titles: Record<string, string> = {
49 slots: "Slots",
50 roulette: "Roulette",
51 blackjack: "Blackjack",
52 crash: "Crash",
53};
54
55const descriptions: Record<string, string> = {
56 slots: "Five reels, five paylines. Match symbols from the left.",
57 roulette: "European single-zero wheel with inside and outside bets.",
58 blackjack: "Six-deck shoe. Blackjack pays 3:2.",
59 crash: "A rising multiplier. Cash out before it crashes.",
60};
61
62const basePath = location.pathname.includes("/play/")
63 ? location.pathname.slice(0, location.pathname.indexOf("/play/"))
64 : "";
65const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`;
66
67const gateway = new Gateway(wsUrl, {
68 onHello(hello) {
69 serverOffset = hello.time - Date.now();
70 currentUser = hello.user ?? null;
71 gameBadge.textContent = `v${hello.version}`;
72 },
73 onView(next) {
74 view = next;
75 // Game and history views carry no account field; the lobby is the source
76 // of truth for who is signed in. A null lobby user is a logout or an
77 // expired session.
78 if (typeof next.balance === "number") lastBalance = next.balance;
79 if (next.screen === "lobby" && "user" in next) {
80 currentUser = (next.user as string | null) ?? null;
81 if (currentUser) authPass.value = "";
82 else lastBalance = null;
83 }
84 reconnectAttempts = 0;
85 reconnectOverlay.hidden = true;
86 render();
87 if (currentUser && view.balance === null && infoFor !== currentUser) {
88 infoFor = currentUser;
89 gateway.send("Info");
90 }
91 if (!currentUser) infoFor = null;
92 },
93 onNotice(text) {
94 if (
95 text.startsWith("Connected to:") ||
96 text.startsWith("Fingerprint:") ||
97 text.startsWith("User:") ||
98 text.startsWith("Account:") ||
99 text.startsWith("Balance:")
100 ) {
101 return;
102 }
103 toast(text, toneFor(text));
104 if (text.startsWith("Account created")) {
105 setAuthMode("login");
106 authPass.value = "";
107 authUser.focus();
108 }
109 authSubmit.disabled = false;
110 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
111 },
112 onError(text) {
113 toast(text, "bad");
114 authSubmit.disabled = false;
115 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
116 },
117 onReplaced() {
118 replacedOverlay.hidden = false;
119 },
120 onPong() {
121 /* The gateway answered; the socket is alive. */
122 },
123 onOpen() {
124 reconnectOverlay.hidden = true;
125 },
126 onClosed(willRetry) {
127 if (replacedOverlay.hidden === false) return;
128 if (!willRetry) {
129 reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again.";
130 reconnectOverlay.hidden = false;
131 return;
132 }
133 reconnectAttempts += 1;
134 reconnectMessage.textContent =
135 reconnectAttempts > 1
136 ? "Still trying to reach the game server…"
137 : "Trying to reach the game server…";
138 reconnectOverlay.hidden = false;
139 },
140});
141
142function send(command: unknown): void {
143 gateway.send(command);
144}
145
146function serverNow(): number {
147 return Date.now() + serverOffset;
148}
149
150function setAuthMode(mode: "login" | "register"): void {
151 authMode = mode;
152 tabLogin.classList.toggle("active", mode === "login");
153 tabRegister.classList.toggle("active", mode === "register");
154 authSubmit.textContent = mode === "login" ? "Log in" : "Create account";
155 authPass.autocomplete = mode === "login" ? "current-password" : "new-password";
156 authError.textContent = "";
157}
158
159function show(screen: "auth" | "lobby" | "game" | "history"): void {
160 authScreen.hidden = screen !== "auth";
161 lobbyScreen.hidden = screen !== "lobby";
162 gameScreen.hidden = screen !== "game";
163 historyScreen.hidden = screen !== "history";
164}
165
166function setBalance(balance: number | null | undefined): void {
167 accountBalance.textContent = money(balance ?? null);
168}
169
170function render(): void {
171 if (!view) return;
172 const user = currentUser;
173 if (user) {
174 sessionBar.hidden = false;
175 accountName.textContent = String(user);
176 } else {
177 sessionBar.hidden = true;
178 }
179
180 setBalance(lastBalance);
181
182 if (!user) {
183 show("auth");
184 authError.textContent = view.login_error ? String(view.login_error) : "";
185 if (view.screen === "lobby" && view.connected === false) {
186 authError.textContent = "The game server is not reachable yet. Retrying…";
187 }
188 return;
189 }
190
191 switch (view.screen) {
192 case "lobby":
193 show("lobby");
194 renderLobby(view);
195 break;
196 case "history":
197 show("history");
198 renderHistory(historyBody, view);
199 break;
200 case "slots":
201 case "roulette":
202 case "blackjack":
203 case "crash":
204 show("game");
205 gameTitle.textContent = titles[view.screen] ?? view.screen;
206 gameBadge.textContent = badge(view);
207 if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send);
208 else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send);
209 else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send);
210 else renderCrash(gameBoard, gameSide, view, send, { now: serverNow });
211 break;
212 default:
213 show("auth");
214 }
215}
216
217function badge(view: View): string {
218 if (view.screen === "crash") return String(view.status ?? "");
219 if (view.screen === "blackjack") return String(view.status ?? "");
220 if (view.screen === "slots") return String(view.phase ?? "");
221 if (view.screen === "roulette") return String(view.phase ?? "");
222 return "";
223}
224
225function renderLobby(lobby: View): void {
226 gameGrid.replaceChildren();
227 const list: [string, string][] = lobby.games ?? [];
228 for (const [id, name] of list) {
229 const card = node("article", `game-card ${id}`);
230 card.append(node("h3", undefined, name || titles[id] || id));
231 card.append(node("p", undefined, descriptions[id] ?? "Play a round."));
232
233 const row = node("div", "row wrap");
234 const label = node("label", "field");
235 label.style.flex = "1";
236 label.append(node("span", "label", "Bet"));
237 const input = node("input", "input bet-input") as HTMLInputElement;
238 input.type = "number";
239 input.min = "1";
240 input.step = "10";
241 input.value = String(bets.get(id) ?? 10);
242 input.addEventListener("change", () => {
243 const value = Math.max(1, Math.floor(Number(input.value) || 1));
244 input.value = String(value);
245 bets.set(id, value);
246 });
247 label.append(input);
248
249 const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play");
250 play.addEventListener("click", () => {
251 const bet = Math.max(1, Math.floor(Number(input.value) || 1));
252 bets.set(id, bet);
253 send({ Play: { game: id, bet } });
254 });
255 row.append(label, play);
256 card.append(row);
257 gameGrid.append(card);
258 }
259
260 if (list.length === 0) {
261 gameGrid.append(node("p", "muted", "The server did not advertise any games."));
262 }
263}
264
265// Auth tabs and form -------------------------------------------------------
266tabLogin.addEventListener("click", () => setAuthMode("login"));
267tabRegister.addEventListener("click", () => setAuthMode("register"));
268
269authForm.addEventListener("submit", async (event) => {
270 event.preventDefault();
271 const user = authUser.value.trim();
272 const password = authPass.value;
273 if (!user || !password) {
274 authError.textContent = "Enter a username and a password.";
275 return;
276 }
277 authSubmit.disabled = true;
278 authSubmit.textContent = "Working…";
279 authError.textContent = "";
280 try {
281 const credential = await sha256Hex(password);
282 if (authMode === "login") {
283 send({ Login: { user, credential } });
284 } else {
285 send({ Register: { user, credential } });
286 }
287 } catch {
288 authError.textContent = "This browser cannot hash the password (needs a secure context).";
289 authSubmit.disabled = false;
290 authSubmit.textContent = authMode === "login" ? "Log in" : "Create account";
291 }
292});
293
294// Chrome and navigation ----------------------------------------------------
295byId("logout").addEventListener("click", () => send("Logout"));
296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
297byId("refresh-lobby").addEventListener("click", () => send("Info"));
298byId("history-back").addEventListener("click", () => send("ShowLobby"));
299byId("leave-game").addEventListener("click", () => {
300 if (!view) return;
301 const live =
302 (view.screen === "blackjack" && view.status === "player") ||
303 (view.screen === "crash" && view.status === "running");
304 if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return;
305 send("Leave");
306});
307byId("reload-replaced").addEventListener("click", () => location.reload());
308byId("reload-reconnect").addEventListener("click", () => location.reload());
309
310window.addEventListener("resize", () => {
311 window.clearTimeout((window as any).__resizeTimer);
312 (window as any).__resizeTimer = window.setTimeout(() => {
313 if (view) render();
314 }, 140);
315});
316
317// A periodic ping keeps the gateway session visibly alive and refreshes the
318// server clock estimate without changing game state.
319window.setInterval(() => {
320 if (currentUser) gateway.ping();
321}, 25_000);
322
323gateway.connect();
diff --git a/web/play/src/net.ts b/web/play/src/net.ts
new file mode 100644
index 0000000..0a963ba
--- /dev/null
+++ b/web/play/src/net.ts
@@ -0,0 +1,120 @@
1import type { Handlers } from "./types";
2
3const MAX_ATTEMPTS = 6;
4
5/**
6 * Browser side of the gateway socket.
7 *
8 * The cookie is HttpOnly and handled by the browser; this class never reads or
9 * stores a credential. A transient close reconnects inside the gateway's grace
10 * window; a deliberate takeover stops for good.
11 */
12export class Gateway {
13 private socket: WebSocket | null = null;
14 private stopped = false;
15 private replaced = false;
16 private attempts = 0;
17 private timer: number | undefined;
18
19 constructor(
20 private readonly url: string,
21 private readonly handlers: Handlers,
22 ) {}
23
24 connect(): void {
25 if (this.stopped) return;
26 this.clearTimer();
27
28 let socket: WebSocket;
29 try {
30 socket = new WebSocket(this.url);
31 } catch {
32 const retrying = this.scheduleReconnect();
33 if (!retrying) this.handlers.onClosed(false);
34 return;
35 }
36 this.socket = socket;
37
38 socket.addEventListener("open", () => {
39 this.attempts = 0;
40 this.handlers.onOpen();
41 });
42
43 socket.addEventListener("message", (event) => {
44 let message: any;
45 try {
46 message = JSON.parse(String(event.data));
47 } catch {
48 return;
49 }
50 switch (message.type) {
51 case "hello":
52 this.handlers.onHello(message);
53 break;
54 case "view":
55 this.handlers.onView(message.view);
56 break;
57 case "notice":
58 this.handlers.onNotice(String(message.text ?? ""));
59 break;
60 case "error":
61 this.handlers.onError(String(message.text ?? ""));
62 break;
63 case "replaced":
64 this.replaced = true;
65 this.handlers.onReplaced();
66 break;
67 case "pong":
68 this.handlers.onPong();
69 break;
70 }
71 });
72
73 socket.addEventListener("close", () => {
74 const wasThisSocket = this.socket === socket;
75 if (wasThisSocket) this.socket = null;
76 if (this.replaced || this.stopped) {
77 this.handlers.onClosed(false);
78 return;
79 }
80 const retrying = this.scheduleReconnect();
81 this.handlers.onClosed(retrying);
82 });
83
84 socket.addEventListener("error", () => {
85 // The close event follows; reconnect is scheduled there.
86 });
87 }
88
89 send(command: unknown): void {
90 if (this.socket?.readyState !== WebSocket.OPEN) return;
91 this.socket.send(JSON.stringify({ type: "command", command }));
92 }
93
94 ping(): void {
95 if (this.socket?.readyState === WebSocket.OPEN) {
96 this.socket.send(JSON.stringify({ type: "ping" }));
97 }
98 }
99
100 close(): void {
101 this.stopped = true;
102 this.clearTimer();
103 this.socket?.close();
104 }
105
106 private scheduleReconnect(): boolean {
107 if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false;
108 this.attempts += 1;
109 const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000);
110 this.timer = window.setTimeout(() => this.connect(), delay);
111 return true;
112 }
113
114 private clearTimer(): void {
115 if (this.timer !== undefined) {
116 window.clearTimeout(this.timer);
117 this.timer = undefined;
118 }
119 }
120}
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts
new file mode 100644
index 0000000..cbb7c6e
--- /dev/null
+++ b/web/play/src/roulette.ts
@@ -0,0 +1,474 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4type Zone = {
5 x: number;
6 y: number;
7 w: number;
8 h: number;
9 target: any;
10 label: string;
11 kind: "number" | "zero" | "dozen" | "column" | "outside";
12 number?: number;
13};
14
15type Hotspot = {
16 x: number;
17 y: number;
18 radius: number;
19 target: any;
20 rank: number;
21};
22
23type Layout = {
24 width: number;
25 height: number;
26 cellW: number;
27 cellH: number;
28 zeroX: number;
29 zeroW: number;
30 gridX: number;
31 gridY: number;
32 gridW: number;
33 columnX: number;
34 dozensY: number;
35 outsideY: number;
36 outsideH: number;
37 zones: Zone[];
38 hotspots: Hotspot[];
39};
40
41const RED = "#b0313a";
42const BLACK = "#151b21";
43const ZERO = "#106b45";
44const FELT = "#0d5c39";
45const FELT_DARK = "#09472c";
46const FELT_LINE = "#1f7d52";
47const TEXT = "#ecf7f0";
48const GOLD = "#e0b64a";
49
50function colorOf(number: number): string {
51 if (number === 0) return "green";
52 const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];
53 return reds.includes(number) ? "red" : "black";
54}
55
56function layout(width: number, height: number): Layout {
57 const pad = 14;
58 const zeroW = Math.max(38, width * 0.055);
59 const columnW = Math.max(34, width * 0.05);
60 const outsideH = Math.max(30, height * 0.07);
61 const gap = 6;
62 const gridW = width - pad * 2 - zeroW - columnW;
63 const cellW = gridW / 12;
64 const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3;
65 const gridX = pad + zeroW;
66 const gridY = pad;
67 const dozensY = gridY + cellH * 3 + gap;
68 const outsideY = dozensY + outsideH + gap;
69 const zones: Zone[] = [];
70
71 for (let column = 0; column < 12; column += 1) {
72 for (let row = 0; row < 3; row += 1) {
73 const number = column * 3 + (3 - row);
74 zones.push({
75 x: gridX + column * cellW,
76 y: gridY + row * cellH,
77 w: cellW,
78 h: cellH,
79 target: { bet: "straight", number },
80 label: String(number),
81 kind: "number",
82 number,
83 });
84 }
85 }
86
87 zones.push({
88 x: pad,
89 y: gridY,
90 w: zeroW,
91 h: cellH * 3,
92 target: { bet: "straight", number: 0 },
93 label: "0",
94 kind: "zero",
95 number: 0,
96 });
97
98 for (let top = 0; top < 3; top += 1) {
99 const index = 2 - top;
100 zones.push({
101 x: gridX + gridW,
102 y: gridY + top * cellH,
103 w: columnW,
104 h: cellH,
105 target: { bet: "column", index },
106 label: "2:1",
107 kind: "column",
108 });
109 }
110
111 for (let index = 0; index < 3; index += 1) {
112 zones.push({
113 x: gridX + (gridW / 3) * index,
114 y: dozensY,
115 w: gridW / 3,
116 h: outsideH,
117 target: { bet: "dozen", index },
118 label: ["1st 12", "2nd 12", "3rd 12"][index],
119 kind: "dozen",
120 });
121 }
122
123 const outside = [
124 { kind: "low", label: "1–18" },
125 { kind: "even", label: "EVEN" },
126 { kind: "red", label: "RED" },
127 { kind: "black", label: "BLACK" },
128 { kind: "odd", label: "ODD" },
129 { kind: "high", label: "19–36" },
130 ];
131 outside.forEach((item, index) => {
132 zones.push({
133 x: gridX + (gridW / 6) * index,
134 y: outsideY,
135 w: gridW / 6,
136 h: outsideH,
137 target: { bet: "outside", kind: item.kind },
138 label: item.label,
139 kind: "outside",
140 });
141 });
142
143 // Split and corner hotspots sit on the lines between cells, like the real
144 // felt (and the desktop fine lattice). Corners rank ahead of splits.
145 const hotspots: Hotspot[] = [];
146 const cellCenter = (number: number) => {
147 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number);
148 return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null;
149 };
150
151 for (let column = 0; column < 11; column += 1) {
152 for (let row = 0; row < 2; row += 1) {
153 const top = column * 3 + (3 - row);
154 hotspots.push({
155 x: gridX + (column + 1) * cellW,
156 y: gridY + (row + 1) * cellH,
157 radius: 12,
158 rank: 0,
159 target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] },
160 });
161 }
162 }
163
164 for (let column = 0; column < 12; column += 1) {
165 for (let row = 0; row < 2; row += 1) {
166 const top = column * 3 + (3 - row);
167 const center = cellCenter(top);
168 if (!center) continue;
169 hotspots.push({
170 x: center.x,
171 y: gridY + (row + 1) * cellH,
172 radius: 10,
173 rank: 1,
174 target: { bet: "split", a: top, b: top - 1 },
175 });
176 }
177 }
178
179 for (let column = 0; column < 11; column += 1) {
180 for (let row = 0; row < 3; row += 1) {
181 const left = column * 3 + (3 - row);
182 hotspots.push({
183 x: gridX + (column + 1) * cellW,
184 y: gridY + row * cellH + cellH / 2,
185 radius: 10,
186 rank: 1,
187 target: { bet: "split", a: left, b: left + 3 },
188 });
189 }
190 }
191
192 for (let row = 0; row < 3; row += 1) {
193 const number = 3 - row;
194 hotspots.push({
195 x: gridX,
196 y: gridY + row * cellH + cellH / 2,
197 radius: 10,
198 rank: 1,
199 target: { bet: "split", a: 0, b: number },
200 });
201 }
202
203 return {
204 width,
205 height,
206 cellW,
207 cellH,
208 zeroX: pad,
209 zeroW,
210 gridX,
211 gridY,
212 gridW,
213 columnX: gridX + gridW,
214 dozensY,
215 outsideY,
216 outsideH,
217 zones,
218 hotspots,
219 };
220}
221
222function roundRect(
223 ctx: CanvasRenderingContext2D,
224 x: number,
225 y: number,
226 w: number,
227 h: number,
228 radius: number,
229): void {
230 const r = Math.min(radius, w / 2, h / 2);
231 ctx.beginPath();
232 ctx.moveTo(x + r, y);
233 ctx.arcTo(x + w, y, x + w, y + h, r);
234 ctx.arcTo(x + w, y + h, x, y + h, r);
235 ctx.arcTo(x, y + h, x, y, r);
236 ctx.arcTo(x, y, x + w, y, r);
237 ctx.closePath();
238}
239
240function draw(canvas: HTMLCanvasElement, view: View): Layout {
241 const width = canvas.clientWidth || 800;
242 const height = canvas.clientHeight || 540;
243 const dpr = window.devicePixelRatio || 1;
244 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
245 canvas.width = Math.round(width * dpr);
246 canvas.height = Math.round(height * dpr);
247 }
248 const ctx = canvas.getContext("2d");
249 const map = layout(width, height);
250 if (!ctx) return map;
251 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
252 ctx.clearRect(0, 0, width, height);
253 ctx.fillStyle = FELT_DARK;
254 roundRect(ctx, 0, 0, width, height, 10);
255 ctx.fill();
256
257 for (const zone of map.zones) {
258 let fill = FELT;
259 if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK;
260 else if (zone.kind === "zero") fill = ZERO;
261 ctx.fillStyle = fill;
262 ctx.strokeStyle = FELT_LINE;
263 ctx.lineWidth = 1;
264 roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3);
265 ctx.fill();
266 ctx.stroke();
267
268 ctx.fillStyle = TEXT;
269 ctx.textAlign = "center";
270 ctx.textBaseline = "middle";
271 if (zone.kind === "number" || zone.kind === "zero") {
272 ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`;
273 } else {
274 ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`;
275 }
276 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2);
277 }
278
279 drawChips(ctx, map, view);
280 return map;
281}
282
283function targetKey(target: any): string {
284 return JSON.stringify(target);
285}
286
287function centerFor(layout: Layout, target: any): { x: number; y: number } | null {
288 const key = targetKey(target);
289 const exact = layout.zones.find((zone) => targetKey(zone.target) === key);
290 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 };
291
292 // Split and corner bets are reachable from the desktop's fine lattice. A
293 // browser session can still show them: average the covered number cells.
294 if (target?.bet === "split" || target?.bet === "corner") {
295 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers;
296 const cells = layout.zones.filter(
297 (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!),
298 );
299 if (cells.length > 0) {
300 return {
301 x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length,
302 y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length,
303 };
304 }
305 }
306 return null;
307}
308
309function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void {
310 const chips: any[] = view.bets?.chips ?? [];
311 for (const chip of chips) {
312 const center = centerFor(map, chip.target);
313 if (!center) continue;
314 const radius = Math.max(12, Math.min(18, map.cellW * 0.26));
315 ctx.beginPath();
316 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
317 ctx.fillStyle = GOLD;
318 ctx.fill();
319 ctx.strokeStyle = "#7a5a12";
320 ctx.lineWidth = 2;
321 ctx.stroke();
322 ctx.fillStyle = "#221a05";
323 ctx.textAlign = "center";
324 ctx.textBaseline = "middle";
325 ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`;
326 ctx.fillText(String(chip.amount), center.x, center.y + 0.5);
327 }
328}
329
330export function renderRoulette(
331 board: HTMLElement,
332 side: HTMLElement,
333 view: View,
334 send: (command: unknown) => void,
335): void {
336 clear(board);
337 clear(side);
338
339 const spinning = view.phase === "spinning";
340 board.append(node("h2", undefined, "Roulette"));
341 board.append(
342 node(
343 "p",
344 "note",
345 "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.",
346 ),
347 );
348
349 const wrap = node("div", "roulette-wrap");
350 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement;
351 wrap.append(canvas);
352
353 if (view.phase === "result" && view.number !== null && view.number !== undefined) {
354 const result = node("div", `wheel-result ${view.color ?? ""}`);
355 result.append(node("div", "label", "Result"));
356 result.append(node("div", "num", String(view.number)));
357 const winnings = Number(view.winnings ?? 0);
358 result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win"));
359 wrap.append(result);
360 }
361 board.append(wrap);
362
363 // The canvas must be in the document before its client size is read.
364 const map = draw(canvas, view);
365 let current = map;
366
367 const hit = (clientX: number, clientY: number): Zone | null => {
368 const rect = canvas.getBoundingClientRect();
369 const x = clientX - rect.left;
370 const y = clientY - rect.top;
371
372 let best: Hotspot | null = null;
373 for (const hotspot of current.hotspots) {
374 const distance = Math.hypot(x - hotspot.x, y - hotspot.y);
375 if (distance > hotspot.radius) continue;
376 if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) {
377 best = hotspot;
378 }
379 }
380 if (best) {
381 return {
382 x: best.x - best.radius,
383 y: best.y - best.radius,
384 w: best.radius * 2,
385 h: best.radius * 2,
386 target: best.target,
387 label: "",
388 kind: "number",
389 };
390 }
391
392 for (let index = current.zones.length - 1; index >= 0; index -= 1) {
393 const zone = current.zones[index];
394 if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) {
395 return zone;
396 }
397 }
398 return null;
399 };
400
401 canvas.addEventListener("click", (event) => {
402 if (spinning) return;
403 const zone = hit(event.clientX, event.clientY);
404 if (zone) send({ PlaceBetAt: zone.target });
405 });
406 canvas.addEventListener("contextmenu", (event) => {
407 event.preventDefault();
408 if (spinning) return;
409 const zone = hit(event.clientX, event.clientY);
410 if (zone) send({ RemoveBetAt: zone.target });
411 });
412 canvas.addEventListener("mousemove", (event) => {
413 const zone = hit(event.clientX, event.clientY);
414 canvas.style.cursor = zone && !spinning ? "pointer" : "default";
415 });
416 new ResizeObserver(() => {
417 current = draw(canvas, view);
418 }).observe(canvas);
419
420 // Sidebar
421 side.append(node("div", "label", "Roulette"));
422 side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin."));
423
424 side.append(node("div", "label", "Chip"));
425 const chipRow = node("div", "chip-row");
426 for (const value of [10, 25, 50, 100, 500]) {
427 const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value));
428 button.disabled = spinning;
429 button.addEventListener("click", () => send({ SetChip: value }));
430 chipRow.append(button);
431 }
432 side.append(chipRow);
433
434 const total = (view.bets?.chips ?? []).reduce(
435 (sum: number, chip: any) => sum + Number(chip.amount ?? 0),
436 0,
437 );
438 const totalRow = node("div", "stat");
439 totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total)));
440 side.append(totalRow);
441
442 if (view.balance !== null && view.balance !== undefined) {
443 const balanceRow = node("div", "stat");
444 balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance))));
445 side.append(balanceRow);
446 }
447
448 if (view.error) side.append(node("p", "error-text", String(view.error)));
449
450 const status = node("p", "note");
451 if (spinning) status.textContent = "Spinning…";
452 else if (view.phase === "result") {
453 const winnings = Number(view.winnings ?? 0);
454 status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`;
455 status.className = winnings > 0 ? "note win-text" : "note";
456 } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip.";
457 side.append(status);
458
459 const actions = node("div", "actions");
460 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL");
461 spin.disabled = spinning || total === 0;
462 spin.addEventListener("click", () => send("Spin"));
463 const clearBets = node("button", "btn wide", "Clear bets");
464 clearBets.disabled = spinning || total === 0;
465 clearBets.addEventListener("click", () => send("ClearBets"));
466 const leave = node("button", "btn ghost wide", "Leave table");
467 leave.disabled = spinning;
468 leave.addEventListener("click", () => {
469 if (spinning) return;
470 send("Leave");
471 });
472 actions.append(spin, clearBets, leave);
473 side.append(actions);
474}
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts
new file mode 100644
index 0000000..dba1e25
--- /dev/null
+++ b/web/play/src/slots.ts
@@ -0,0 +1,144 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4const REELS = 5;
5const ROWS = 3;
6const SYMBOLS = "CLOGDS";
7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"];
8
9let spinUntil = 0;
10let animation: number | undefined;
11let latest: View | null = null;
12let latestBoard: HTMLElement | null = null;
13
14export function requestSlotsSpin(): void {
15 spinUntil = Date.now() + 900;
16}
17
18function spinning(view: View): boolean {
19 return view.phase === "spinning" || Date.now() < spinUntil;
20}
21
22export function renderSlots(
23 board: HTMLElement,
24 side: HTMLElement,
25 view: View,
26 send: (command: unknown) => void,
27): void {
28 latest = view;
29 latestBoard = board;
30 if (animation !== undefined) {
31 window.clearInterval(animation);
32 animation = undefined;
33 }
34
35 const active = spinning(view);
36 clear(board);
37 clear(side);
38
39 const title = node("h2", undefined, "Slots");
40 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
41 board.append(title, note);
42
43 const reels = node("div", "reels");
44 const tiles: HTMLElement[] = [];
45 const flat = String(view.reels ?? "");
46 for (let column = 0; column < REELS; column += 1) {
47 const reel = node("div", "reel");
48 for (let row = 0; row < ROWS; row += 1) {
49 const index = column * ROWS + row;
50 const symbol = active
51 ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length]
52 : flat.length === REELS * ROWS
53 ? flat[index]
54 : flat.length === REELS
55 ? flat[column]
56 : SYMBOLS[(index * 5 + column) % SYMBOLS.length];
57 const tile = node("div", "tile", symbol);
58 tile.classList.add(`sym-${symbol}`);
59 if (active) tile.classList.add("spinning");
60 if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win");
61 tiles.push(tile);
62 reel.append(tile);
63 }
64 reels.append(reel);
65 }
66 board.append(reels);
67
68 const lines = node("div", "win-lines");
69 if (!active && view.phase === "result") {
70 const winnings = Number(view.winnings ?? 0);
71 if (winnings > 0) {
72 const names = (view.win_lines ?? [])
73 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
74 .join(" · ");
75 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
76 lines.classList.add("win-text");
77 } else {
78 lines.textContent = "No win — spin again.";
79 }
80 } else if (active) {
81 lines.textContent = "Spinning…";
82 } else {
83 lines.textContent = "Pick a bet and spin.";
84 }
85 board.append(lines);
86
87 if (active) {
88 let tick = 0;
89 animation = window.setInterval(() => {
90 tick += 1;
91 for (let index = 0; index < tiles.length; index += 1) {
92 const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length];
93 tiles[index].textContent = symbol;
94 tiles[index].className = `tile spinning sym-${symbol}`;
95 }
96 if (Date.now() >= spinUntil && latest && latestBoard) {
97 window.clearInterval(animation);
98 animation = undefined;
99 renderSlots(latestBoard, side, latest, send);
100 }
101 }, 70);
102 }
103
104 // Sidebar
105 side.append(node("div", "label", "Slots"));
106 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines."));
107
108 const betRow = node("div", "row");
109 const minus = node("button", "btn small", "−10");
110 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
111 const plus = node("button", "btn small", "+10");
112 minus.disabled = active;
113 plus.disabled = active;
114 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
115 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
116 betRow.append(minus, bet, plus);
117 side.append(node("div", "label", "Bet"), betRow);
118
119 if (view.error) {
120 side.append(node("p", "error-text", String(view.error)));
121 }
122
123 const status = node("p", "note");
124 if (active) status.textContent = "Spinning…";
125 else if (view.phase === "result") {
126 const winnings = Number(view.winnings ?? 0);
127 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin.";
128 if (winnings > 0) status.classList.add("win-text");
129 } else status.textContent = "Ready when you are.";
130 side.append(status);
131
132 const actions = node("div", "actions");
133 const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN");
134 spin.disabled = active;
135 spin.addEventListener("click", () => {
136 requestSlotsSpin();
137 send("Spin");
138 });
139 const leave = node("button", "btn ghost wide", "Leave machine");
140 leave.disabled = active;
141 leave.addEventListener("click", () => send("Leave"));
142 actions.append(spin, leave);
143 side.append(actions);
144}
diff --git a/web/play/src/types.ts b/web/play/src/types.ts
new file mode 100644
index 0000000..e594a0b
--- /dev/null
+++ b/web/play/src/types.ts
@@ -0,0 +1,27 @@
1export type View = {
2 screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash";
3 [key: string]: any;
4};
5
6export type Hello = {
7 type: "hello";
8 version: string;
9 grace_seconds: number;
10 /** Signed-in user restored after a refresh, if any. */
11 user?: string | null;
12 /** Estimated game-server Unix time in milliseconds. */
13 time: number;
14};
15
16export type Send = (command: unknown) => void;
17
18export type Handlers = {
19 onHello: (hello: Hello) => void;
20 onView: (view: View) => void;
21 onNotice: (text: string) => void;
22 onError: (text: string) => void;
23 onReplaced: () => void;
24 onPong: () => void;
25 onOpen: () => void;
26 onClosed: (willRetry: boolean) => void;
27};
diff --git a/web/play/style.css b/web/play/style.css
new file mode 100644
index 0000000..20842d2
--- /dev/null
+++ b/web/play/style.css
@@ -0,0 +1,407 @@
1:root {
2 --bg: #0a0e12;
3 --surface: #11171d;
4 --raised: #171f27;
5 --hover: #1d2731;
6 --line: #242e38;
7 --text: #d9e2ea;
8 --dim: #8a98a6;
9 --faint: #5b6773;
10 --accent: #4fb8c9;
11 --win: #4ec27b;
12 --loss: #e05a5a;
13 --gold: #e0b64a;
14 --felt: #0d5c39;
15 --felt-dark: #09472c;
16 --felt-line: #1f7d52;
17 --felt-text: #ecf7f0;
18 --red: #b0313a;
19 --black: #151b21;
20 --zero: #106b45;
21 --radius-sm: 4px;
22 --radius-md: 8px;
23 --radius-lg: 12px;
24 --sans: "Open Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
25 --mono: "Hack", ui-monospace, "SFMono-Regular", "Cascadia Code", monospace;
26}
27
28* { box-sizing: border-box; }
29html, body { height: 100%; }
30body {
31 margin: 0;
32 background:
33 radial-gradient(1100px 600px at 80% -10%, rgba(79, 184, 201, 0.06), transparent 60%),
34 var(--bg);
35 color: var(--text);
36 font-family: var(--sans);
37 font-size: 14px;
38 line-height: 1.5;
39 -webkit-font-smoothing: antialiased;
40}
41[hidden] { display: none !important; }
42
43a { color: var(--accent); text-decoration: none; }
44a:hover { text-decoration: underline; }
45button, input { font: inherit; color: inherit; }
46button { cursor: pointer; }
47button:disabled { cursor: not-allowed; opacity: 0.45; }
48
49.shell { min-height: 100%; display: flex; flex-direction: column; }
50
51.topbar {
52 display: flex;
53 align-items: center;
54 gap: 16px;
55 min-height: 64px;
56 padding: 0 24px;
57 border-bottom: 1px solid var(--line);
58 background: rgba(10, 14, 18, 0.86);
59 backdrop-filter: blur(10px);
60 position: sticky;
61 top: 0;
62 z-index: 20;
63}
64.brand {
65 color: var(--text);
66 font-family: var(--mono);
67 font-size: 19px;
68 letter-spacing: 0.04em;
69 white-space: nowrap;
70}
71.brand span { color: var(--win); }
72.brand:hover { text-decoration: none; }
73.tagline {
74 color: var(--faint);
75 font-family: var(--mono);
76 font-size: 11px;
77 text-transform: uppercase;
78 letter-spacing: 0.12em;
79}
80.spacer { flex: 1; }
81.session { display: flex; align-items: center; gap: 12px; }
82.session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; }
83.session .who strong { color: var(--text); font-weight: 600; }
84.balance {
85 color: var(--win);
86 font-family: var(--mono);
87 font-size: 16px;
88 padding: 4px 10px;
89 border: 1px solid var(--line);
90 border-radius: var(--radius-sm);
91 background: var(--surface);
92 white-space: nowrap;
93}
94
95main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; }
96
97h1, h2, h3 { margin: 0; font-weight: 600; }
98h1 { font-size: 26px; letter-spacing: -0.01em; }
99h2 { font-size: 18px; }
100.muted { color: var(--dim); }
101.faint { color: var(--faint); }
102.mono { font-family: var(--mono); }
103.label {
104 font-family: var(--mono);
105 font-size: 10px;
106 text-transform: uppercase;
107 letter-spacing: 0.16em;
108 color: var(--dim);
109}
110.error-text { color: var(--loss); }
111.win-text { color: var(--win); }
112
113.btn {
114 border: 1px solid var(--line);
115 background: var(--raised);
116 color: var(--text);
117 padding: 9px 14px;
118 border-radius: var(--radius-md);
119 line-height: 1.2;
120 transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
121}
122.btn:hover:not(:disabled) { background: var(--hover); border-color: #33404d; }
123.btn:active:not(:disabled) { transform: translateY(1px); }
124.btn.primary {
125 background: var(--win);
126 border-color: var(--win);
127 color: #06120b;
128 font-weight: 700;
129}
130.btn.primary:hover:not(:disabled) { filter: brightness(1.08); }
131.btn.accent {
132 background: rgba(79, 184, 201, 0.14);
133 border-color: var(--accent);
134 color: var(--accent);
135 font-weight: 600;
136}
137.btn.danger {
138 background: rgba(224, 90, 90, 0.12);
139 border-color: rgba(224, 90, 90, 0.55);
140 color: var(--loss);
141}
142.btn.ghost { background: transparent; }
143.btn.small { padding: 5px 9px; font-size: 12px; }
144.btn.wide { width: 100%; }
145.btn.big { padding: 13px 18px; font-size: 15px; }
146
147.input {
148 width: 100%;
149 background: var(--bg);
150 border: 1px solid var(--line);
151 border-radius: var(--radius-md);
152 padding: 10px 12px;
153 outline: none;
154}
155.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 184, 201, 0.18); }
156.field { display: grid; gap: 6px; }
157.row { display: flex; align-items: center; gap: 10px; }
158.row.wrap { flex-wrap: wrap; }
159.hr { height: 1px; background: var(--line); border: 0; margin: 18px 0; }
160
161/* Auth */
162.auth-wrap { display: grid; place-items: center; min-height: 52vh; }
163.auth-card {
164 width: min(430px, 100%);
165 background: var(--surface);
166 border: 1px solid var(--line);
167 border-radius: var(--radius-lg);
168 padding: 26px;
169 box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
170}
171.auth-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg); padding: 4px; border-radius: var(--radius-md); }
172.auth-tab { flex: 1; border: 0; background: transparent; color: var(--dim); padding: 8px; border-radius: var(--radius-sm); }
173.auth-tab.active { background: var(--raised); color: var(--text); }
174.auth-card form { display: grid; gap: 14px; }
175.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); }
176
177/* Lobby */
178.lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; }
179.lobby-head .spacer { flex: 1; }
180.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
181.game-card {
182 background: var(--surface);
183 border: 1px solid var(--line);
184 border-radius: var(--radius-lg);
185 padding: 18px;
186 display: flex;
187 flex-direction: column;
188 gap: 12px;
189 min-height: 178px;
190 position: relative;
191 overflow: hidden;
192}
193.game-card::before {
194 content: "";
195 position: absolute;
196 inset: 0 auto 0 0;
197 width: 3px;
198 background: var(--accent);
199 opacity: 0.7;
200}
201.game-card.crash::before { background: var(--gold); }
202.game-card.roulette::before { background: var(--felt-line); }
203.game-card.blackjack::before { background: var(--loss); }
204.game-card h3 { font-size: 17px; }
205.game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; }
206.game-card .row { justify-content: space-between; }
207.bet-input { width: 92px; }
208
209/* Game shell */
210.game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
211.game-head .spacer { flex: 1; }
212.game-body {
213 display: grid;
214 grid-template-columns: minmax(0, 1fr) 320px;
215 gap: 16px;
216 align-items: start;
217}
218.board {
219 background: var(--surface);
220 border: 1px solid var(--line);
221 border-radius: var(--radius-lg);
222 padding: 18px;
223 min-height: 560px;
224 overflow: hidden;
225}
226.sidebar {
227 background: var(--surface);
228 border: 1px solid var(--line);
229 border-radius: var(--radius-lg);
230 padding: 18px;
231 display: flex;
232 flex-direction: column;
233 gap: 14px;
234 position: sticky;
235 top: 84px;
236}
237.sidebar .actions { margin-top: auto; display: grid; gap: 8px; }
238.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
239.sidebar .stat strong { font-family: var(--mono); font-weight: 600; }
240.note { color: var(--dim); font-size: 12px; }
241.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
242.chip-btn {
243 font-family: var(--mono);
244 font-size: 12px;
245 border-radius: 999px;
246 border: 1px solid var(--line);
247 background: var(--raised);
248 padding: 5px 10px;
249}
250.chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); }
251
252/* Slots */
253.reels {
254 display: grid;
255 grid-template-columns: repeat(5, minmax(60px, 1fr));
256 gap: 10px;
257 max-width: 760px;
258 margin: 24px auto 0;
259}
260.reel { display: grid; gap: 8px; }
261.tile {
262 aspect-ratio: 1 / 0.92;
263 display: grid;
264 place-items: center;
265 border-radius: var(--radius-md);
266 border: 1px solid var(--line);
267 background: var(--bg);
268 font-family: var(--mono);
269 font-size: clamp(26px, 5vw, 54px);
270 font-weight: 700;
271 color: var(--dim);
272 transition: color 90ms ease, border-color 90ms ease, background 90ms ease;
273}
274.tile.sym-C { color: #c8d1da; }
275.tile.sym-L { color: #7cc4d4; }
276.tile.sym-O { color: #d8c27a; }
277.tile.sym-G { color: #83d09a; }
278.tile.sym-D { color: #d48d8d; }
279.tile.sym-S { color: #e0b64a; }
280.tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); }
281.tile.spinning { color: var(--faint); }
282.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; }
283
284/* Cards */
285.hand { margin: 16px 0; }
286.hand .label { margin-bottom: 8px; }
287.cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; }
288.card {
289 width: 76px;
290 height: 108px;
291 border-radius: 9px;
292 background: #f2f5f7;
293 color: #121820;
294 border: 1px solid #cfd8df;
295 padding: 8px;
296 display: flex;
297 flex-direction: column;
298 justify-content: space-between;
299 font-family: var(--mono);
300 font-weight: 700;
301 box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
302}
303.card.red { color: #a51f2d; }
304.card.back { background: repeating-linear-gradient(135deg, #1d2731, #1d2731 6px, #26333f 6px, #26333f 12px); border-color: #33404d; }
305.card .rank { font-size: 22px; line-height: 1; }
306.card .suit { font-size: 22px; align-self: flex-end; }
307.card.back .rank, .card.back .suit { color: var(--faint); }
308.total { color: var(--dim); font-family: var(--mono); font-size: 12px; }
309.total strong { color: var(--text); font-size: 15px; }
310.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); }
311
312/* Crash */
313.crash-wrap { position: relative; }
314.crash-number {
315 font-family: var(--mono);
316 font-size: clamp(44px, 9vw, 86px);
317 line-height: 1;
318 letter-spacing: -0.04em;
319}
320.crash-number.running { color: var(--gold); }
321.crash-number.crashed { color: var(--loss); }
322.crash-number.cashed { color: var(--win); }
323.crash-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
324.verify { margin-top: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--bg); font-size: 11px; color: var(--dim); word-break: break-all; }
325.verify.ok { border-color: rgba(78, 194, 123, 0.4); color: var(--win); }
326.verify.bad { border-color: rgba(224, 90, 90, 0.5); color: var(--loss); }
327
328/* Roulette */
329.roulette-wrap { position: relative; }
330canvas { display: block; width: 100%; }
331.roulette-canvas { height: 540px; }
332.crash-canvas { height: 360px; }
333.wheel-result {
334 position: absolute;
335 top: 16px;
336 right: 16px;
337 padding: 8px 12px;
338 border-radius: var(--radius-md);
339 border: 1px solid var(--line);
340 background: rgba(10, 14, 18, 0.86);
341 font-family: var(--mono);
342}
343.wheel-result .num { font-size: 26px; font-weight: 700; }
344.wheel-result.red .num { color: #f06a74; }
345.wheel-result.black .num { color: #dfe8ef; }
346.wheel-result.green .num { color: #66d49a; }
347
348/* History */
349.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); }
350table { width: 100%; border-collapse: collapse; background: var(--surface); }
351th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 13px; white-space: nowrap; }
352th { color: var(--dim); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; }
353tr:last-child td { border-bottom: 0; }
354td.num { font-family: var(--mono); }
355td.pos { color: var(--win); }
356td.neg { color: var(--loss); }
357
358/* Toasts and overlays */
359#toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); }
360.toast {
361 background: var(--surface);
362 border: 1px solid var(--line);
363 border-left: 3px solid var(--accent);
364 border-radius: var(--radius-md);
365 padding: 10px 12px;
366 font-size: 13px;
367 box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
368 animation: toast-in 160ms ease-out;
369}
370.toast.good { border-left-color: var(--win); }
371.toast.bad { border-left-color: var(--loss); }
372@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } }
373.overlay {
374 position: fixed;
375 inset: 0;
376 background: rgba(5, 8, 11, 0.78);
377 display: grid;
378 place-items: center;
379 z-index: 60;
380 padding: 24px;
381}
382.modal {
383 width: min(440px, 100%);
384 background: var(--surface);
385 border: 1px solid var(--line);
386 border-radius: var(--radius-lg);
387 padding: 24px;
388 text-align: center;
389}
390.modal p { color: var(--dim); }
391
392@media (max-width: 900px) {
393 .game-body { grid-template-columns: 1fr; }
394 .sidebar { position: static; }
395 .tagline { display: none; }
396}
397@media (max-width: 620px) {
398 main { padding: 18px 14px 54px; }
399 .topbar { padding: 0 14px; gap: 10px; }
400 .session .who { display: none; }
401 .balance { font-size: 14px; }
402 .reels { gap: 6px; }
403 .tile { font-size: clamp(18px, 7vw, 34px); }
404 .card { width: 58px; height: 84px; }
405 .crash-canvas { height: 260px; }
406 .roulette-canvas { height: 400px; }
407}
diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs
new file mode 100644
index 0000000..c1274bc
--- /dev/null
+++ b/web/play/test/smoke.mjs
@@ -0,0 +1,294 @@
1import fs from "node:fs";
2import { webcrypto } from "node:crypto";
3import { JSDOM, VirtualConsole } from "jsdom";
4
5const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8")
6 .replace('<script src="app.js" defer></script>', "");
7const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8");
8
9const failures = [];
10const virtualConsole = new VirtualConsole();
11virtualConsole.on("jsdomError", (error) => failures.push(String(error)));
12
13const dom = new JSDOM(html, {
14 url: "http://127.0.0.1:8899/play/",
15 runScripts: "outside-only",
16 pretendToBeVisual: true,
17 virtualConsole,
18});
19const { window } = dom;
20
21Object.defineProperty(window, "crypto", { value: webcrypto });
22window.devicePixelRatio = 1;
23window.confirm = () => true;
24
25class MockWebSocket {
26 static OPEN = 1;
27 static instances = [];
28 readyState = MockWebSocket.OPEN;
29 sent = [];
30 listeners = new Map();
31
32 constructor(url) {
33 this.url = url;
34 MockWebSocket.instances.push(this);
35 queueMicrotask(() => this.emit("open", {}));
36 }
37
38 addEventListener(type, listener) {
39 const list = this.listeners.get(type) ?? [];
40 list.push(listener);
41 this.listeners.set(type, list);
42 }
43
44 send(raw) {
45 this.sent.push(JSON.parse(raw));
46 }
47
48 close() {
49 this.emit("close", {});
50 }
51
52 emit(type, event) {
53 for (const listener of this.listeners.get(type) ?? []) listener(event);
54 }
55
56 message(message) {
57 this.emit("message", { data: JSON.stringify(message) });
58 }
59}
60window.WebSocket = MockWebSocket;
61window.ResizeObserver = class {
62 observe() {}
63 disconnect() {}
64};
65window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0);
66window.cancelAnimationFrame = (handle) => window.clearTimeout(handle);
67
68const context = new Proxy(
69 {},
70 {
71 get(target, key) {
72 if (key === "measureText") return () => ({ width: 10 });
73 if (key in target) return target[key];
74 return () => undefined;
75 },
76 set(target, key, value) {
77 target[key] = value;
78 return true;
79 },
80 },
81);
82window.HTMLCanvasElement.prototype.getContext = function getContext() {
83 return context;
84};
85
86window.eval(bundle);
87
88const document = window.document;
89const byId = (id) => document.getElementById(id);
90const assert = (condition, message) => {
91 if (!condition) failures.push(message);
92};
93
94const socket = MockWebSocket.instances[0];
95assert(socket, "the app opened a WebSocket");
96assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`);
97
98const hello = {
99 type: "hello",
100 version: "5.0.0",
101 grace_seconds: 60,
102 time: Date.now(),
103};
104const lobby = {
105 type: "view",
106 view: {
107 screen: "lobby",
108 connected: true,
109 user: null,
110 account: null,
111 balance: null,
112 games: [
113 ["blackjack", "Blackjack"],
114 ["crash", "Crash"],
115 ["roulette", "Roulette"],
116 ["slots", "Slots"],
117 ],
118 login_error: null,
119 },
120};
121socket.message(hello);
122socket.message(lobby);
123
124assert(byId("auth-screen").hidden === false, "auth is shown before login");
125assert(byId("session-bar").hidden === true, "no session bar before login");
126
127byId("auth-user").value = "alice";
128byId("auth-pass").value = "secret";
129byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
130
131await new Promise((resolve) => setTimeout(resolve, 10));
132const login = socket.sent.find((message) => message.command?.Login);
133assert(login, "the login form sends a Login command");
134assert(login.command.Login.user === "alice", "the username is sent");
135assert(
136 /^[0-9a-f]{64}$/.test(login.command.Login.credential),
137 "the password is sent as a SHA-256 hex digest",
138);
139
140socket.message({
141 type: "view",
142 view: {
143 screen: "lobby",
144 connected: true,
145 user: "alice",
146 account: "1",
147 balance: null,
148 games: lobby.view.games,
149 login_error: null,
150 },
151});
152assert(
153 socket.sent.some((message) => message.command === "Info"),
154 "the app asks for the balance after login",
155);
156socket.message({
157 type: "view",
158 view: {
159 screen: "lobby",
160 connected: true,
161 user: "alice",
162 account: "1",
163 balance: 1000,
164 games: lobby.view.games,
165 login_error: null,
166 },
167});
168assert(byId("auth-screen").hidden === true, "auth is hidden after login");
169assert(byId("lobby-screen").hidden === false, "the lobby is shown after login");
170assert(byId("account-name").textContent === "alice", "the account name is shown");
171assert(byId("account-balance").textContent.includes("1"), "the balance is shown");
172assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render");
173socket.message({
174 type: "view",
175 view: {
176 screen: "slots",
177 bet: 10,
178 balance: 1000,
179 reels: "CLOGDSCLOGDSLLO".split(""),
180 win_cells: [0, 1, 2],
181 win_lines: [1],
182 win_type: "win",
183 winnings: 20,
184 phase: "result",
185 error: null,
186 },
187});
188assert(byId("game-screen").hidden === false, "the game screen is shown");
189assert(document.querySelectorAll(".reel").length === 5, "five reels render");
190assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render");
191assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action");
192
193socket.message({
194 type: "view",
195 view: {
196 screen: "roulette",
197 bet_amount: 10,
198 balance: 1000,
199 chip: 25,
200 bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] },
201 cursor: { Lattice: { x: 1, y: 5 } },
202 phase: "result",
203 number: 5,
204 color: "red",
205 winnings: 900,
206 error: null,
207 },
208});
209assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders");
210assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders");
211assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders");
212
213socket.message({
214 type: "view",
215 view: {
216 screen: "blackjack",
217 status: "player",
218 balance: 990,
219 bet: 10,
220 hand: {
221 session_id: "bj1",
222 phase: "player",
223 player: ["10S", "6H"],
224 dealer: ["9D", "?"],
225 player_total: 16,
226 dealer_total: 9,
227 bet: 10,
228 base_bet: 10,
229 balance: 990,
230 actions: ["hit", "stand", "double"],
231 result: "playing",
232 winnings: 0,
233 },
234 error: null,
235 },
236});
237assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render");
238assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render");
239assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase");
240assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders");
241
242socket.message({
243 type: "view",
244 view: {
245 screen: "crash",
246 status: "running",
247 balance: 990,
248 bet: 10,
249 multiplier: 1.2,
250 potential: 12,
251 crash_point: null,
252 payout: 0,
253 started_at: Date.now(),
254 commit: "ab".repeat(32),
255 seed: null,
256 error: null,
257 },
258});
259assert(document.querySelector(".crash-canvas"), "the crash canvas renders");
260assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders");
261assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running");
262assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment");
263
264socket.message({
265 type: "view",
266 view: {
267 screen: "history",
268 records: [
269 {
270 game: "slots",
271 bet: 10,
272 winnings: 20,
273 balance_after: 1010,
274 at: "2026-11-08 03:00:00",
275 details: { win_type: "win" },
276 },
277 ],
278 },
279});
280assert(byId("history-screen").hidden === false, "the history screen renders");
281assert(document.querySelector("table tbody tr"), "history rows render");
282
283socket.message({ type: "replaced" });
284assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab");
285
286await new Promise((resolve) => setTimeout(resolve, 30));
287dom.window.close();
288
289if (failures.length > 0) {
290 console.error("web smoke test failed:");
291 for (const failure of failures) console.error(` - ${failure}`);
292 process.exit(1);
293}
294console.log("web smoke test passed");
diff --git a/web/play/tsconfig.json b/web/play/tsconfig.json
new file mode 100644
index 0000000..9814d9c
--- /dev/null
+++ b/web/play/tsconfig.json
@@ -0,0 +1,13 @@
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "noEmit": true,
8 "lib": ["ES2020", "DOM", "DOM.Iterable"],
9 "skipLibCheck": true,
10 "forceConsistentCasingInFileNames": true
11 },
12 "include": ["src"]
13}
diff --git a/webd/Cargo.toml b/webd/Cargo.toml
new file mode 100644
index 0000000..8698404
--- /dev/null
+++ b/webd/Cargo.toml
@@ -0,0 +1,13 @@
1[package]
2name = "zagan-bet-webd"
3version = "5.0.0"
4edition = "2024"
5license = "GPL-3.0-or-later"
6
7[dependencies]
8zagan-bet = { path = "..", default-features = false }
9tungstenite = "0.21"
10serde = { version = "1.0", features = ["derive"] }
11serde_json = "1.0"
12rand = "0.8"
13rusqlite = { version = "0.29", features = ["bundled"] }
diff --git a/webd/README.md b/webd/README.md
new file mode 100644
index 0000000..ac95fdb
--- /dev/null
+++ b/webd/README.md
@@ -0,0 +1,118 @@
1# `webd` — browser gateway
2
3The browser cannot open the game's TCP/TLS stream, and the rules must not be
4reimplemented in JavaScript. `webd` is a server-side frontend: one blocking
5thread per browser session, one `ClientCore`, and one ordinary client TLS
6connection to the game server. nginx terminates TLS and proxies `/ws` here.
7
8It is a workspace member like `gui/`. Its dependencies never enter the TUI,
9GUI or server build graph.
10
11## Build
12
13```sh
14cargo build -p zagan-bet-webd --release
15```
16
17## Configuration
18
19| Variable | Default | Meaning |
20| --- | --- | --- |
21| `ZAGAN_WEB_BIND` | `127.0.0.1:8890` | Loopback address nginx proxies to |
22| `ZAGAN_WEB_ORIGIN` | `https://zagan.2137697.xyz` | Exact `Origin` allowed |
23| `ZAGAN_UPSTREAM` | `127.0.0.1:11525` | Game server `host:port` |
24| `ZAGAN_UPSTREAM_FINGERPRINT` | **required** | SPKI SHA-256, 64 lowercase hex |
25| `ZAGAN_GRACE_SECONDS` | `60` | Session survives this long without a socket |
26| `ZAGAN_WEB_COOKIE_SECURE` | on for `https://` origin | `Secure` on the cookie |
27
28The fingerprint is the same one the desktop client shows:
29
30```sh
31openssl x509 -in cert.pem -pubkey -noout \
32 | openssl pkey -pubin -outform DER \
33 | sha256sum
34```
35
36There is no trust-on-first-use and no unsafe mode. A mismatch refuses to
37connect and logs both fingerprints. Run the gateway on the same host as the
38game server (or pin the remote key and accept the network hop).
39
40## systemd
41
42Install `zagan-bet-webd` under `/usr/local/bin/`, then:
43
44```ini
45# /etc/systemd/system/zagan-bet-webd.service
46[Unit]
47Description=zagan-bet browser gateway
48After=network-online.target
49Wants=network-online.target
50
51[Service]
52Type=simple
53User=zagan
54Group=zagan
55EnvironmentFile=/etc/zagan-bet-webd.env
56ExecStart=/usr/local/bin/zagan-bet-webd
57Restart=on-failure
58RestartSec=2
59NoNewPrivileges=true
60PrivateTmp=true
61ProtectSystem=strict
62ProtectHome=true
63
64[Install]
65WantedBy=multi-user.target
66```
67
68```sh
69sudo install -m 0755 target/release/zagan-bet-webd /usr/local/bin/
70sudo install -d -m 0755 /etc
71sudo tee /etc/zagan-bet-webd.env >/dev/null <<'ENV'
72ZAGAN_UPSTREAM=127.0.0.1:11525
73ZAGAN_UPSTREAM_FINGERPRINT=<64 hex>
74ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz
75ZAGAN_WEB_BIND=127.0.0.1:8890
76ENV
77sudo systemctl daemon-reload
78sudo systemctl enable --now zagan-bet-webd
79```
80
81nginx needs:
82
83```nginx
84location = /ws {
85 proxy_pass http://127.0.0.1:8890;
86 proxy_http_version 1.1;
87 proxy_set_header Upgrade $http_upgrade;
88 proxy_set_header Connection "upgrade";
89 proxy_set_header Host $host;
90 proxy_read_timeout 3600s;
91 proxy_send_timeout 3600s;
92}
93```
94
95## Behaviour
96
97- The WebSocket handshake sets an HttpOnly, SameSite=Strict session cookie.
98 There is no credential storage in the browser or gateway.
99- A browser close starts the grace window. Upstream stays connected, crash
100 ticks are still applied to the core, and a reconnect with the same cookie
101 gets the current `View`. Expiry closes upstream; the server forfeits a live
102 hand, exactly like a desktop disconnect.
103- A second socket with the same cookie takes over: the old tab receives
104 `{"type":"replaced"}` and is closed.
105- A gateway restart loses live sessions, so their rounds are forfeited. There
106 is no protocol-level resume.
107- The browser sends `{"type":"command","command":...}` and receives
108 `view`/`notice`/`error`/`replaced`/`hello`/`pong` JSON. It never sees the
109 game wire or a certificate.
110
111## Security notes
112
113- Bind loopback. Do not expose the gateway directly.
114- Keep `ZAGAN_WEB_ORIGIN` exact. The cookie is SameSite=Strict, and the
115 `Origin` check is a second lock on cross-site WebSocket attempts.
116- The gateway is play-money only in v1. No payments, KYC or real-money copy.
117- `ZAGAN_UPSTREAM_FINGERPRINT` is the browser's credential path: a wrong or
118 missing pin must fail closed.
diff --git a/webd/src/config.rs b/webd/src/config.rs
new file mode 100644
index 0000000..d3ada8f
--- /dev/null
+++ b/webd/src/config.rs
@@ -0,0 +1,95 @@
1//! Gateway configuration from the environment.
2//!
3//! The upstream key is mandatory. There is no trust-on-first-use and no unsafe
4//! mode: the browser's credentials ride on this connection.
5
6use std::env;
7use std::time::Duration;
8
9pub const DEFAULT_BIND: &str = "127.0.0.1:8890";
10pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525";
11pub const DEFAULT_GRACE_SECONDS: u64 = 60;
12pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz";
13
14#[derive(Debug, Clone)]
15pub struct Config {
16 /// Loopback address nginx proxies `/ws` to.
17 pub bind: String,
18 /// Exact browser origin allowed on the WebSocket handshake.
19 pub origin: String,
20 /// Game server address, `host:port`.
21 pub upstream: String,
22 /// Lowercase hex SHA-256 of the upstream certificate's SPKI.
23 pub fingerprint: String,
24 /// How long a session survives without a browser socket.
25 pub grace: Duration,
26 /// `Secure` on the session cookie. Off only for local http testing.
27 pub cookie_secure: bool,
28}
29
30impl Config {
31 pub fn from_env() -> Result<Self, String> {
32 let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND);
33 let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN);
34 if !origin.starts_with("http://") && !origin.starts_with("https://") {
35 return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string());
36 }
37 let origin = origin.trim_end_matches('/').to_string();
38
39 let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM);
40 let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?;
41 let fingerprint = fingerprint.trim().to_ascii_lowercase();
42 if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
43 return Err(
44 "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI"
45 .to_string(),
46 );
47 }
48
49 let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") {
50 Ok(raw) => raw
51 .trim()
52 .parse::<u64>()
53 .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?,
54 Err(_) => DEFAULT_GRACE_SECONDS,
55 };
56
57 let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") {
58 Ok(raw) => matches!(
59 raw.trim().to_ascii_lowercase().as_str(),
60 "1" | "true" | "yes"
61 ),
62 Err(_) => origin.starts_with("https://"),
63 };
64
65 Ok(Self {
66 bind,
67 origin,
68 upstream,
69 fingerprint,
70 grace: Duration::from_secs(grace_seconds),
71 cookie_secure,
72 })
73 }
74}
75
76fn value(name: &str, default: &str) -> String {
77 env::var(name)
78 .ok()
79 .map(|value| value.trim().to_string())
80 .filter(|value| !value.is_empty())
81 .unwrap_or_else(|| default.to_string())
82}
83
84fn required(name: &str) -> Result<String, String> {
85 env::var(name)
86 .ok()
87 .map(|value| value.trim().to_string())
88 .filter(|value| !value.is_empty())
89 .ok_or_else(|| {
90 format!(
91 "{name} is required: the gateway pins the upstream key. \
92 Use the SPKI fingerprint the desktop client shows."
93 )
94 })
95}
diff --git a/webd/src/main.rs b/webd/src/main.rs
new file mode 100644
index 0000000..c2cf0ff
--- /dev/null
+++ b/webd/src/main.rs
@@ -0,0 +1,61 @@
1//! Browser gateway for zagan-bet.
2//!
3//! nginx terminates TLS, serves the static app and proxies `/ws` here. This
4//! process owns one upstream TLS connection and one `ClientCore` per browser
5//! session, so the browser never speaks the game wire protocol and never sees
6//! a certificate.
7
8mod config;
9mod session;
10
11use std::net::TcpListener;
12use std::sync::Arc;
13use std::thread;
14
15use config::Config;
16
17fn main() {
18 let config = match Config::from_env() {
19 Ok(config) => config,
20 Err(error) => {
21 eprintln!("webd: {error}");
22 std::process::exit(2);
23 }
24 };
25
26 let listener = match TcpListener::bind(&config.bind) {
27 Ok(listener) => listener,
28 Err(error) => {
29 eprintln!("webd: could not bind {}: {error}", config.bind);
30 std::process::exit(1);
31 }
32 };
33
34 println!(
35 "webd listening on ws://{} for origin {}; upstream {} pinned to {}; grace {}s",
36 config.bind,
37 config.origin,
38 config.upstream,
39 config.fingerprint,
40 config.grace.as_secs()
41 );
42
43 let registry = session::new_registry();
44 for stream in listener.incoming() {
45 let stream = match stream {
46 Ok(stream) => stream,
47 Err(error) => {
48 eprintln!("webd: accept failed: {error}");
49 continue;
50 }
51 };
52 let _ = stream.set_nodelay(true);
53 let config = config.clone();
54 let registry = Arc::clone(&registry);
55 thread::spawn(move || {
56 if let Err(error) = session::accept(stream, config, registry) {
57 eprintln!("webd: {error}");
58 }
59 });
60 }
61}
diff --git a/webd/src/session.rs b/webd/src/session.rs
new file mode 100644
index 0000000..8a20721
--- /dev/null
+++ b/webd/src/session.rs
@@ -0,0 +1,623 @@
1//! One gateway session: a browser socket, one upstream TLS connection and one
2//! `ClientCore`.
3//!
4//! The browser sends `Command` JSON and renders `View` JSON. All game state,
5//! protocol framing and balance arithmetic stay in the Rust core.
6
7use std::collections::HashMap;
8use std::net::TcpStream;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex, mpsc};
11use std::thread;
12use std::time::{Duration, Instant};
13
14use rusqlite::Connection;
15use serde::{Deserialize, Serialize};
16use tungstenite::error::Error as WsError;
17use tungstenite::http::HeaderValue;
18use tungstenite::http::header::ORIGIN;
19use tungstenite::protocol::{WebSocket, WebSocketConfig};
20use tungstenite::{Message, accept_hdr_with_config};
21
22use zagan_bet::client::core::{ClientCore, Effect};
23use zagan_bet::client::database;
24use zagan_bet::client::transport::{self, TlsStream};
25use zagan_bet::client::view::{Command, View};
26use zagan_bet::protocol::Packet;
27use zagan_bet::version::VERSION;
28
29use crate::config::Config;
30
31const COOKIE_NAME: &str = "zagan_session";
32/// On HTTPS, use the `__Host-` prefix so a sibling origin cannot set a
33/// domain-scoped cookie and fixate the session.
34const HOST_COOKIE_NAME: &str = "__Host-zagan_session";
35const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
36const POLL: Duration = Duration::from_millis(10);
37
38pub enum SocketEvent {
39 Attach(WebSocket<TcpStream>),
40}
41
42/// Session id → generation and the thread that owns it.
43///
44/// The generation lets an ending thread remove its own entry without deleting
45/// a replacement session that reused the same cookie.
46pub type Registry = Arc<Mutex<HashMap<String, (u64, mpsc::Sender<SocketEvent>)>>>;
47
48static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
49
50pub fn new_registry() -> Registry {
51 Arc::new(Mutex::new(HashMap::new()))
52}
53
54/// Complete the WebSocket handshake, then attach this socket to an existing
55/// session or start one.
56pub fn accept(stream: TcpStream, config: Config, registry: Registry) -> Result<(), String> {
57 let mut session_id = None;
58 let origin = config.origin.clone();
59 let secure = config.cookie_secure;
60
61 let ws_config = WebSocketConfig {
62 // A command or view is a few KiB at most; keep a compromised page from
63 // making the gateway buffer tens of megabytes.
64 max_message_size: Some(64 * 1024),
65 max_frame_size: Some(64 * 1024),
66 ..WebSocketConfig::default()
67 };
68 let mut ws = accept_hdr_with_config(
69 stream,
70 |request: &tungstenite::handshake::server::Request,
71 mut response: tungstenite::handshake::server::Response| {
72 if let Some(header) = request.headers().get(ORIGIN) {
73 if header.to_str().ok() != Some(origin.as_str()) {
74 return Err(tungstenite::http::Response::builder()
75 .status(403)
76 .body(Some("origin not allowed".to_string()))
77 .expect("static response"));
78 }
79 }
80
81 let found = request
82 .headers()
83 .get("cookie")
84 .and_then(|value| value.to_str().ok())
85 .and_then(cookie_id);
86 let (id, fresh) = match found {
87 Some(id) => (id, false),
88 None => (random_id(), true),
89 };
90 if fresh {
91 let mut cookie = format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict");
92 if secure {
93 cookie.push_str("; Secure");
94 }
95 if let Ok(value) = HeaderValue::from_str(&cookie) {
96 response.headers_mut().append("set-cookie", value);
97 }
98 }
99 session_id = Some(id);
100 Ok(response)
101 },
102 Some(ws_config),
103 )
104 .map_err(|error| format!("WebSocket handshake failed: {error}"))?;
105
106 let id = session_id.ok_or_else(|| "handshake produced no session id".to_string())?;
107 ws.get_ref()
108 .set_nonblocking(true)
109 .map_err(|error| format!("could not make the browser socket nonblocking: {error}"))?;
110
111 let (sender, receiver) = mpsc::channel();
112 let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
113 {
114 let mut sessions = registry.lock().map_err(|_| "session registry poisoned")?;
115 if let Some((_, existing)) = sessions.get(&id).cloned() {
116 match existing.send(SocketEvent::Attach(ws)) {
117 Ok(()) => return Ok(()),
118 Err(mpsc::SendError(SocketEvent::Attach(returned))) => {
119 sessions.remove(&id);
120 ws = returned;
121 }
122 }
123 }
124 sessions.insert(id.clone(), (generation, sender.clone()));
125 }
126
127 thread::spawn(move || {
128 let session = match Session::new(id.clone(), config) {
129 Ok(session) => Some(session),
130 Err(error) => {
131 eprintln!("[webd {id}] could not start: {error}");
132 None
133 }
134 };
135 if let Some(mut session) = session {
136 session.attach(ws);
137 session.poll_loop(&receiver);
138 }
139 if let Ok(mut sessions) = registry.lock() {
140 let own = sessions
141 .get(&id)
142 .map(|(generation_now, _)| *generation_now == generation)
143 .unwrap_or(false);
144 if own {
145 sessions.remove(&id);
146 }
147 }
148 eprintln!("[webd {id}] session ended");
149 });
150
151 Ok(())
152}
153
154fn cookie_id(raw: &str) -> Option<String> {
155 for pair in raw.split(';') {
156 let (name, value) = pair.split_once('=')?;
157 if matches!(name.trim(), COOKIE_NAME | HOST_COOKIE_NAME) {
158 let value = value.trim();
159 if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
160 return Some(value.to_ascii_lowercase());
161 }
162 return None;
163 }
164 }
165 None
166}
167
168fn random_id() -> String {
169 use rand::RngCore;
170 let mut bytes = [0u8; 32];
171 rand::thread_rng().fill_bytes(&mut bytes);
172 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
173}
174
175#[derive(Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum ClientMessage {
178 Command { command: Command },
179 Ping,
180}
181
182#[derive(Serialize)]
183#[serde(tag = "type", rename_all = "snake_case")]
184enum ServerMessage<'a> {
185 Hello {
186 version: &'a str,
187 grace_seconds: u64,
188 /// Signed-in user, so a refresh during a game can skip the login card.
189 user: Option<&'a str>,
190 /// Estimated game-server Unix time, milliseconds.
191 time: u64,
192 },
193 View {
194 view: &'a View,
195 },
196 Notice {
197 text: &'a str,
198 },
199 Error {
200 text: &'a str,
201 },
202 Replaced,
203 Pong,
204}
205
206struct Session {
207 id: String,
208 config: Config,
209 core: ClientCore,
210 /// In-memory trust store seeded with the pinned fingerprint. No player
211 /// credentials are ever stored here.
212 trust: Connection,
213 upstream: Option<TlsStream>,
214 ws: Option<WebSocket<TcpStream>>,
215 pending_id: Option<u64>,
216 pending_since: Option<Instant>,
217 grace_until: Option<Instant>,
218 drop_upstream: bool,
219}
220
221impl Session {
222 fn new(id: String, config: Config) -> Result<Self, String> {
223 let trust = Connection::open_in_memory()
224 .map_err(|error| format!("could not open the trust store: {error}"))?;
225 database::init_db(&trust);
226 database::save_fingerprint(&trust, &config.upstream, &config.fingerprint)
227 .map_err(|error| format!("could not pin the upstream key: {error}"))?;
228
229 Ok(Self {
230 id,
231 config,
232 core: ClientCore::new(),
233 trust,
234 upstream: None,
235 ws: None,
236 pending_id: None,
237 pending_since: None,
238 grace_until: None,
239 drop_upstream: false,
240 })
241 }
242
243 fn attach(&mut self, ws: WebSocket<TcpStream>) {
244 if let Some(mut old) = self.ws.take() {
245 send_json(&mut old, &ServerMessage::Replaced);
246 close_socket(&mut old);
247 }
248 eprintln!("[webd {}] browser attached", self.id);
249 self.ws = Some(ws);
250 self.grace_until = None;
251 let mut ws = self.ws.take().expect("just attached");
252 if let Err(text) = self.ensure_upstream() {
253 send_json(&mut ws, &ServerMessage::Error { text: &text });
254 }
255 send_json(
256 &mut ws,
257 &ServerMessage::Hello {
258 version: VERSION,
259 grace_seconds: self.config.grace.as_secs(),
260 user: self.core.user(),
261 time: self.core.server_now_ms(),
262 },
263 );
264 self.send_view(&mut ws);
265 self.ws = Some(ws);
266 }
267
268 fn poll_loop(&mut self, receiver: &mpsc::Receiver<SocketEvent>) {
269 loop {
270 match receiver.recv_timeout(POLL) {
271 Ok(SocketEvent::Attach(ws)) => self.attach(ws),
272 Err(mpsc::RecvTimeoutError::Timeout) => {}
273 Err(mpsc::RecvTimeoutError::Disconnected) => break,
274 }
275
276 self.pump_browser();
277 self.pump_upstream();
278 self.check_request_timeout();
279
280 if self.ws.is_some() {
281 self.grace_until = None;
282 } else if let Some(deadline) = self.grace_until {
283 if Instant::now() >= deadline {
284 break;
285 }
286 } else {
287 self.grace_until = Some(Instant::now() + self.config.grace);
288 }
289 }
290
291 // Dropping the TLS stream closes upstream; the server forfeits any
292 // live hand, exactly like a desktop client disconnecting.
293 self.upstream = None;
294 eprintln!("[webd {}] grace expired", self.id);
295 }
296
297 fn pump_browser(&mut self) {
298 let Some(mut ws) = self.ws.take() else {
299 return;
300 };
301 let mut keep = true;
302
303 loop {
304 match ws.read() {
305 Ok(Message::Text(text)) => {
306 if !self.handle_browser_text(&text, &mut ws) {
307 keep = false;
308 break;
309 }
310 }
311 Ok(Message::Close(_)) => {
312 keep = false;
313 break;
314 }
315 Ok(Message::Ping(payload)) => {
316 let _ = send_message(&mut ws, Message::Pong(payload));
317 }
318 Ok(Message::Binary(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => {}
319 Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => break,
320 Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => {
321 keep = false;
322 break;
323 }
324 Err(_) => {
325 keep = false;
326 break;
327 }
328 }
329 }
330
331 if keep {
332 self.ws = Some(ws);
333 } else {
334 self.grace_until = Some(Instant::now() + self.config.grace);
335 self.pending_id = None;
336 self.pending_since = None;
337 }
338 }
339
340 fn handle_browser_text(&mut self, text: &str, ws: &mut WebSocket<TcpStream>) -> bool {
341 let message = match serde_json::from_str::<ClientMessage>(text) {
342 Ok(message) => message,
343 Err(error) => {
344 send_json(
345 ws,
346 &ServerMessage::Error {
347 text: &format!("Malformed message: {error}"),
348 },
349 );
350 return true;
351 }
352 };
353
354 match message {
355 ClientMessage::Ping => send_json(ws, &ServerMessage::Pong),
356 ClientMessage::Command { command } => {
357 if let Err(text) = self.ensure_upstream() {
358 send_json(ws, &ServerMessage::Error { text: &text });
359 self.send_view(ws);
360 return true;
361 }
362 let effects = self.core.command(command);
363 self.apply_effects(effects, Some(ws));
364 }
365 }
366 true
367 }
368
369 fn ensure_upstream(&mut self) -> Result<(), String> {
370 if self.upstream.is_some() {
371 return Ok(());
372 }
373
374 let mut trust = |_prompt: &transport::TrustPrompt| false;
375 match transport::connect(&self.config.upstream, &self.trust, false, &mut trust) {
376 Ok(connected) => {
377 self.core.set_connected(true);
378 self.core.set_server_time(connected.server_time);
379 self.core.set_host_info(
380 Some(self.config.upstream.clone()),
381 Some(self.config.fingerprint.clone()),
382 );
383 self.upstream = Some(connected.stream);
384 eprintln!(
385 "[webd {}] upstream connected: {} ({}), ping {:.1}ms",
386 self.id,
387 self.config.upstream,
388 connected.server_version,
389 connected.elapsed.as_secs_f64() * 1000.0
390 );
391 Ok(())
392 }
393 Err(error) => {
394 self.core.reset_after_disconnect();
395 Err(format!("Upstream connection failed: {error}"))
396 }
397 }
398 }
399
400 fn pump_upstream(&mut self) {
401 let Some(mut upstream) = self.upstream.take() else {
402 return;
403 };
404 let mut ws = self.ws.take();
405 let mut disconnected = None;
406
407 loop {
408 match upstream.read_available_packet() {
409 Ok(Some(Packet::Event { body, .. })) => {
410 if let Some(effects) = self.core.handle_async_line(&body) {
411 self.apply_effects(effects, ws.as_mut());
412 }
413 }
414 Ok(Some(Packet::Reply { id, body })) => {
415 if self.pending_id == Some(id) {
416 self.pending_id = None;
417 self.pending_since = None;
418 }
419 let effects = self.core.handle_reply_id(id, &body);
420 self.apply_effects(effects, ws.as_mut());
421 }
422 Ok(None) => break,
423 Err(error) => {
424 disconnected = Some(error.to_string());
425 break;
426 }
427 }
428 }
429
430 if self.drop_upstream {
431 self.drop_upstream = false;
432 drop(upstream);
433 } else if disconnected.is_none() {
434 self.upstream = Some(upstream);
435 }
436
437 if let Some(message) = disconnected {
438 eprintln!("[webd {}] upstream lost: {message}", self.id);
439 self.core.reset_after_disconnect();
440 self.pending_id = None;
441 self.pending_since = None;
442 if let Some(ws) = ws.as_mut() {
443 send_json(
444 ws,
445 &ServerMessage::Error {
446 text: "Connection to the game server was lost",
447 },
448 );
449 self.send_view(ws);
450 }
451 }
452
453 self.ws = ws;
454 }
455
456 fn check_request_timeout(&mut self) {
457 let Some(sent) = self.pending_since else {
458 return;
459 };
460 if sent.elapsed() < REQUEST_TIMEOUT {
461 return;
462 }
463
464 let id = self.pending_id.take();
465 self.pending_since = None;
466 self.upstream = None;
467 let mut ws = self.ws.take();
468 if let Some(id) = id {
469 let effects = self.core.handle_reply_id(id, "ERROR Request timed out");
470 self.apply_effects(effects, ws.as_mut());
471 if let Some(ws) = ws.as_mut() {
472 send_json(
473 ws,
474 &ServerMessage::Notice {
475 text: "Request timed out. Disconnected.",
476 },
477 );
478 }
479 }
480 self.core.reset_after_disconnect();
481 if let Some(ws) = ws.as_mut() {
482 self.send_view(ws);
483 }
484 self.ws = ws;
485 }
486
487 fn apply_effects(&mut self, effects: Vec<Effect>, mut ws: Option<&mut WebSocket<TcpStream>>) {
488 for effect in effects {
489 match effect {
490 Effect::Print(lines) => {
491 // A multi-line print is a terminal table; the structured
492 // `View` carries that data to the browser.
493 if lines.len() == 1 {
494 if let (Some(socket), Some(line)) = (ws.as_deref_mut(), lines.first()) {
495 send_json(socket, &ServerMessage::Notice { text: line });
496 }
497 }
498 }
499 Effect::SendRequest { id, payload } => {
500 self.pending_id = Some(id);
501 self.pending_since = Some(Instant::now());
502 match self.ensure_upstream() {
503 Ok(()) => {
504 if let Some(upstream) = self.upstream.as_mut() {
505 if let Err(error) = upstream.write_request(id, None, &payload) {
506 let text = format!("Could not send request: {error}");
507 if let Some(socket) = ws.as_deref_mut() {
508 send_json(socket, &ServerMessage::Error { text: &text });
509 }
510 self.drop_upstream = true;
511 self.upstream = None;
512 }
513 }
514 }
515 Err(text) => {
516 self.pending_id = None;
517 self.pending_since = None;
518 if let Some(socket) = ws.as_deref_mut() {
519 send_json(socket, &ServerMessage::Error { text: &text });
520 }
521 }
522 }
523 }
524 Effect::Disconnect => {
525 self.upstream = None;
526 self.drop_upstream = true;
527 self.core.reset_after_disconnect();
528 }
529 Effect::Render(_) | Effect::ClearRegion | Effect::Quit => {}
530 }
531 }
532
533 if let Some(socket) = ws {
534 self.send_view(socket);
535 }
536 }
537
538 fn send_view(&self, ws: &mut WebSocket<TcpStream>) {
539 let Some(view) = self.core.view() else {
540 return;
541 };
542 send_json(ws, &ServerMessage::View { view: &view });
543 }
544}
545
546fn send_message(ws: &mut WebSocket<TcpStream>, message: Message) -> Result<(), WsError> {
547 // The read side is nonblocking; writes are small and should not fail
548 // half-way because a browser is briefly not draining its socket.
549 let _ = ws.get_ref().set_nonblocking(false);
550 let result = ws.send(message);
551 let _ = ws.get_ref().set_nonblocking(true);
552 result
553}
554
555fn send_json(ws: &mut WebSocket<TcpStream>, message: &ServerMessage<'_>) {
556 let Ok(text) = serde_json::to_string(message) else {
557 return;
558 };
559 let _ = send_message(ws, Message::Text(text));
560}
561
562fn close_socket(ws: &mut WebSocket<TcpStream>) {
563 let _ = ws.get_ref().set_nonblocking(false);
564 let _ = ws.close(None);
565 let _ = ws.get_ref().set_nonblocking(true);
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use zagan_bet::client::view::{LobbyView, View};
572
573 #[test]
574 fn reads_only_well_formed_session_cookies() {
575 let id = "a".repeat(64);
576 assert_eq!(
577 cookie_id(&format!("other=1; {COOKIE_NAME}={id}; theme=dark")),
578 Some(id)
579 );
580 assert_eq!(cookie_id(&format!("{COOKIE_NAME}=short")), None);
581 assert_eq!(cookie_id("other=1"), None);
582 assert_eq!(cookie_id(""), None);
583 }
584
585 #[test]
586 fn parses_a_browser_command_and_rejects_nonsense() {
587 let message: ClientMessage = serde_json::from_str(
588 r#"{"type":"command","command":{"Login":{"user":"alice","credential":"abc"}}}"#,
589 )
590 .expect("login command");
591 match message {
592 ClientMessage::Command { command } => {
593 assert_eq!(
594 command,
595 Command::Login {
596 user: "alice".to_string(),
597 credential: "abc".to_string()
598 }
599 );
600 }
601 _ => panic!("expected a command"),
602 }
603
604 assert!(serde_json::from_str::<ClientMessage>("{}").is_err());
605 assert!(serde_json::from_str::<ClientMessage>(r#"{"type":"nope"}"#).is_err());
606 }
607
608 #[test]
609 fn serializes_a_view_for_the_browser() {
610 let view = View::Lobby(LobbyView {
611 connected: true,
612 user: Some("alice".to_string()),
613 account: Some("1".to_string()),
614 balance: Some(1000),
615 games: vec![("slots".to_string(), "Slots".to_string())],
616 login_error: None,
617 });
618 let json = serde_json::to_string(&ServerMessage::View { view: &view }).unwrap();
619 assert!(json.contains(r#""type":"view""#));
620 assert!(json.contains(r#""screen":"lobby""#));
621 assert!(json.contains(r#""user":"alice""#));
622 }
623}