From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- .gitignore | 3 + AGENTS.md | 65 +- CONTEXT.md | 76 ++ Cargo.toml | 4 +- docs/GUI-PLAN.md | 5 +- docs/PROTOCOL-IDEAS.md | 170 +++++ docs/TESTING.md | 52 +- docs/WEB-PLAN.md | 129 ++++ docs/adr/0001-gui-frontend-architecture.md | 3 +- docs/adr/0003-web-gateway.md | 85 +++ docs/adr/0004-protocol-v5.md | 119 ++++ gui/Cargo.toml | 2 +- gui/src/app.rs | 19 +- gui/src/net.rs | 17 +- scripts/deploy.sh | 13 +- src/bin/client/connection.rs | 6 +- src/bin/client/repl.rs | 95 +-- src/bin/server/handler.rs | 47 +- src/bin/server/tls.rs | 27 +- src/client/core.rs | 182 +++-- src/client/games/manager.rs | 21 +- src/client/games/roulette.rs | 9 +- src/client/transport.rs | 171 +++-- src/client/view.rs | 60 +- src/crash.rs | 54 ++ src/protocol.rs | 226 +++++- src/server/core.rs | 81 ++- src/server/games/crash.rs | 76 +- src/version.rs | 34 +- tests/client_core.rs | 151 ++-- tests/crash.rs | 39 ++ tests/protocol.rs | 150 ++-- tests/server_core.rs | 121 +++- tests/version.rs | 57 +- tests/web_types.rs | 138 ++++ web/README.md | 27 +- web/index.php | 1 + web/nginx.conf.example | 25 + web/play/.gitignore | 2 + web/play/README.md | 56 ++ web/play/app.js | 1 + web/play/build.mjs | 22 + web/play/index.html | 112 +++ web/play/package-lock.json | 1031 ++++++++++++++++++++++++++++ web/play/package.json | 20 + web/play/src/blackjack.ts | 152 ++++ web/play/src/crash.ts | 365 ++++++++++ web/play/src/dom.ts | 104 +++ web/play/src/history.ts | 60 ++ web/play/src/main.ts | 323 +++++++++ web/play/src/net.ts | 120 ++++ web/play/src/roulette.ts | 474 +++++++++++++ web/play/src/slots.ts | 144 ++++ web/play/src/types.ts | 27 + web/play/style.css | 407 +++++++++++ web/play/test/smoke.mjs | 294 ++++++++ web/play/tsconfig.json | 13 + webd/Cargo.toml | 13 + webd/README.md | 118 ++++ webd/src/config.rs | 95 +++ webd/src/main.rs | 61 ++ webd/src/session.rs | 623 +++++++++++++++++ 62 files changed, 6783 insertions(+), 414 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/PROTOCOL-IDEAS.md create mode 100644 docs/WEB-PLAN.md create mode 100644 docs/adr/0003-web-gateway.md create mode 100644 docs/adr/0004-protocol-v5.md create mode 100644 tests/web_types.rs create mode 100644 web/play/.gitignore create mode 100644 web/play/README.md create mode 100644 web/play/app.js create mode 100644 web/play/build.mjs create mode 100644 web/play/index.html create mode 100644 web/play/package-lock.json create mode 100644 web/play/package.json create mode 100644 web/play/src/blackjack.ts create mode 100644 web/play/src/crash.ts create mode 100644 web/play/src/dom.ts create mode 100644 web/play/src/history.ts create mode 100644 web/play/src/main.ts create mode 100644 web/play/src/net.ts create mode 100644 web/play/src/roulette.ts create mode 100644 web/play/src/slots.ts create mode 100644 web/play/src/types.ts create mode 100644 web/play/style.css create mode 100644 web/play/test/smoke.mjs create mode 100644 web/play/tsconfig.json create mode 100644 webd/Cargo.toml create mode 100644 webd/README.md create mode 100644 webd/src/config.rs create mode 100644 webd/src/main.rs create mode 100644 webd/src/session.rs diff --git a/.gitignore b/.gitignore index a140ec2..f1140f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ *.pem *.key /text + +# The browser build pins its dependency tree. +!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 @@ transaction. ## Crash -- The client draws the multiplier from its own clock for display only. A - cashout carries a session id and nothing else; the multiplier and payout come - from the server's clock at receipt. +- The client draws the multiplier from its own clock, corrected by the server + time in `HELLO`/`PONG`, for display only. A cashout carries a session id and + nothing else; the multiplier and payout come from the server's clock at + receipt. - Never send the crash point to the client before the round ends. A client that knows it can cash out at `crash - epsilon` every round for a guaranteed win. - The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of rounds that crash at 1.00x. That is where the house edge is charged on an instant cashout, and it is why no cashout lockout is needed. +- Each round draws a 32-byte seed. `commit = sha256(seed)` goes out with + `CRASH_START`; the seed itself is revealed only when the round ends. The + crash point is `crash_point_from_seed(seed)` (first eight bytes, big-endian, + over 2^64), so a client can recompute it. Never reveal the seed while the + round is live. The guarantee is "fixed before you acted", not "ungrindable"; + see ADR 0004. - The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. Client code must drain the socket on a timer and route pushed lines by prefix instead of assuming the next line answers the last request. - `ServerCore::poll` must be idempotent: it settles the round, removes the session, and pushes once. A second poll must produce nothing. +## Protocol v5 +- Every packet is `[u32 big-endian length][UTF-8 payload]`, at most 64 KiB. + Never read a declared length above the limit into memory; close instead. +- Payloads are `REQ [idem=] `, `RES ` and + `EVT `. The command/body text grammar is unchanged. +- `ClientCore` allocates request ids. Adapters must return a reply with + `handle_reply_id`; a reply with an unknown id is ignored, never applied to + whatever request happens to be pending. +- The server caches the last 256 request ids **per connection** and replays a + matching reply. A reused id with a different payload is an error. Do not make + this durable without a new ADR. +- `HELLO` and `PONG` carry Unix milliseconds. The offset is for display and + interpolation; the server settles crash. Never derive a payout from a + browser or gateway clock. +- While a crash round runs, `poll` pushes at most one `crash.tick` per 100 ms + per session. Settlement (`CRASH_BUSTED`) is never dropped or replaced by a + tick. +- A wire change is a MAJOR bump. Do not add a compatibility mode for v4. + +## Web gateway (`webd/`) +- The browser is a frontend, not a client. It sends `Command` JSON and renders + `View` JSON. It must never parse the game wire, compute a payout, or hold a + credential. +- `webd/` is a separate workspace member. Its dependencies (tungstenite, + serde_json, cookie handling) must not enter the TUI, GUI or server build + graph. +- One session thread owns one `ClientCore` and one upstream TLS connection. + No async runtime, no browser TOFU: `ZAGAN_UPSTREAM_FINGERPRINT` is required + and a mismatch refuses to connect. +- The session cookie is HttpOnly, SameSite=Strict, and Secure on HTTPS. Never + store a password or credential in the browser or gateway. Session-only auth; + no remember-me without a new ADR. +- Close is not forfeit: the gateway holds upstream for `ZAGAN_GRACE_SECONDS` + (default 60). Expiry closes upstream, which is the server's forfeit. A new + socket with the same cookie takes over; tell the old tab it was replaced. +- Bind to loopback and let nginx terminate TLS. Check `Origin` against + `ZAGAN_WEB_ORIGIN`. `/ws` is the only gateway route; `/play/` is static. +- A gateway restart loses live sessions and forfeits their rounds. Do not add + protocol-level resume here without an ADR. + +## Web app (`web/play/`) +- Vanilla TypeScript + esbuild. No framework runtime, no game rules, no wire + protocol. DOM for the shell, canvas for the game surface. +- Colours and spacing come from `web/play/style.css`, which mirrors + `gui/src/theme.rs`. Felt green is only ever the roulette table. +- Build with `cd web/play && npm ci && npm run build`; commit the generated + `app.js` so the site needs no Node at deploy time. +- `/play/` is the app; `/` stays the download page. Web v1 is play-money only: + no deposits, withdrawals or KYC copy. +- The browser cookie covers refresh and the grace window only. A closed tab + that returns later logs in again. + ## GUI - The GUI is a second frontend over `ClientCore`, not a second client. Frontends send `Command` and render `View`; the core owns navigation and diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6fefd53 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,76 @@ +# Context + +zagan-bet is a small play-money casino: one Rust workspace with a shared +library, 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 +current when a term changes meaning. + +## Product terms + +- **Play credit** — the unit a player wins and loses. Registration grants a + starting balance. There are no deposits, withdrawals, cash-outs or KYC; the + web app is a game, not a regulated gambling service. +- **Server** — the authoritative process. It owns the database, the game + rules, the balances, the random draws and the clock used to settle crash. +- **Frontend** — a presentation adapter over `ClientCore`: the terminal client, + the egui desktop app, or the browser app behind the web gateway. Frontends + send `Command` values and render `View` values. They never compute payouts. +- **ClientCore** — the I/O-free state machine shared by every frontend. It owns + navigation, the current game view, the pending request, and the local clock + anchor for crash. It contains no terminal, network or egui types. +- **Gateway (`webd`)** — a server-side frontend adapter for the browser. It owns + one upstream TLS connection and one `ClientCore` per browser session, and + translates browser JSON into `Command`/`View`. The browser never speaks the + game wire protocol. +- **Upstream** — the game server the gateway connects to. Its address and SPKI + fingerprint are operator configuration. The browser never sees a certificate. +- **Session** — one browser login held by the gateway. Identified by an + HttpOnly cookie. A refresh or a new tab reattaches to the same session during + the grace window; a later visit starts a new login. + +## Wire terms + +- **Frame** — `[u32 length][UTF-8 payload]`, length big-endian, at most 64 KiB. + The frame is the unit of transport; the payload is text. +- **Envelope** — the first token of a frame payload: + - `REQ [idem=] ` — client request. + - `RES ` — the reply to that request. + - `EVT ` — a server-initiated event, numbered per connection. +- **Request id** — a client-chosen `u64`, unique on the connection while its + reply is cached. It correlates a reply with the request that caused it. +- **Idempotency key** — an optional opaque token on a request. The connection + caches the last 256 request ids; resending the same id and payload replays the + stored reply instead of performing the action twice. +- **Event sequence** — a server-chosen `u64`, incremented once per `EVT` on a + connection. Clients use it to drop duplicates and to notice gaps. +- **HELLO** — the first request after TLS. Client and server must share + `MAJOR.MINOR`; the reply carries the server version, download URL and the + server's Unix time in milliseconds. +- **Tick** — `EVT crash.tick session= t= m=`, pushed + at most every 100 ms while a crash round runs. The server clock still settles + a cash-out. +- **Commit** — `sha256(seed)` in hex, sent with `CRASH_START` before the player + can act. **Reveal** — the seed itself, sent when the round ends. The crash + point is a pure function of the seed, so a player can check both. + +## Deployment terms + +- **Download site** — `web/`: `index.php` lists builds, `latest.php` feeds the + stable installers, `install.sh`/`install.ps1` fetch and verify the newest app. +- **Play app** — the static browser bundle under `/play/`, built from + `web/play/` with esbuild and served by nginx. +- **Grace window** — the 60 seconds the gateway keeps an upstream connection + after the last browser socket closes. Reattaching within it preserves a live + round; expiry closes upstream and the server forfeits per its existing rule. +- **Takeover** — a new browser connection presenting an existing session cookie + replaces the old socket; the old tab is told it was replaced. + +## Invariants + +- One payout formula, one place. `process_action` stores raw data; + `calculate_result` derives the result. Frontends only render. +- Every balance mutation is transactional, and an audit row exists before the + balance moves. +- A crash point never reaches a client before the round ends. +- The server clock, not a browser or gateway clock, settles cash-out. +- `MAJOR.MINOR` is lockstep between client and server; only the patch component + 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 @@ [workspace] -members = ["gui"] +members = ["gui", "webd"] [package] name = "zagan-bet" -version = "4.4.0" +version = "5.0.0" edition = "2024" license = "GPL-3.0-or-later" 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 ## Non-goals -Audio, 3D, macOS, auto-update, a browser client, and pixel-golden tests in CI. +Audio, 3D, macOS, auto-update, a webview inside the desktop app, and +pixel-golden tests in CI. A separate browser frontend is no longer a non-goal: +it shipped in v5.0.0 through the `webd/` gateway, planned in +`docs/WEB-PLAN.md`. ## Phase 0 — version handshake (v4.0.0) 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 @@ +# Server ↔ client transport ideas + +Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the +differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids, +per-connection idempotency, clock sync + ticks, commit–reveal crash, and +length-prefixed framing. Items 6–7 remain future work. + +These came out of reviewing zagan-bet's transport, but they were prompted by a +*different* app. Nothing here is a backlog item for this repository. The +zagan-bet paths are included because that is where the patterns were worked +out; treat the ideas as portable. + +zagan-bet's current shape, for context: + +- TLS with TOFU over the certificate's SPKI fingerprint, trust decision after + the handshake and before credentials (`src/client/transport.rs`, + `src/tofu.rs`). +- Newline-delimited text lines over the TLS stream (`src/protocol.rs`). +- `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`). +- Request/reply plus exactly one server-initiated line, `CRASH_BUSTED` + (`src/bin/server/handler.rs`, `ServerCore::poll`). +- `ClientCore` is I/O-free; frontends send `Command` and render `View` + (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`. + +Each idea below lists the wire shape, where it would attach, and the cost. + +## 1. Correlation IDs, so a single `Pending` can go away + +One request in flight, and pushes distinguished by string prefix +(`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can +race a pushed bust; the code already has a special case for it +(`src/client/core.rs:211`). An envelope removes the whole class: + +``` +REQ 42 idem=f3a1... INFO +RES 42 OK balance=1200 account=... +EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42} +``` + +`REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers +anything. The core keeps a map of in-flight requests instead of one `Option`, +and "a stale reply must not clobber the result" becomes a generic rule rather +than a crash-specific branch. Also allows pipelining, e.g. clock probes while a +`HISTORY` is in flight, without app-layer head-of-line stalls. Keep line +framing for now; just prepend tokens. + +## 2. Idempotency keys on every balance mutation + +A request can be applied server-side and the reply lost. Today that is safe +only because nothing retries automatically; the moment a client retries or +resumes, it can double-debit. + +``` +REQ 43 idem=9c2e... PLAY_START crash 50 +``` + +Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The +replay check, the debit, and the row insert sit in the same transaction as the +existing reservation (`database::reserve_play`). A duplicate returns the stored +reply and never re-executes. TTL-sweep at startup next to +`recover_orphaned_reservations`. Scope by user, not connection, so a resumed +session cannot replay someone else's key. + +## 3. Clock sync and a real tick stream + +zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`, +`src/bin/server/handler.rs:7`) and the client draws crash locally from an +`Instant`. Payouts are correctly server-clock authoritative, but the client +does not know its offset. Two parts: + +**Clock sync**, Cristian/NTP style over the existing connection: + +``` +REQ 7 idem=... TIME t0=837412 +RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms +``` + +`offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding +window. Render timed games from `server_now`. + +**Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics: + +``` +EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44} +``` + +Coalesce in the writer: replace an unqueued tick, never drop a settlement. +That also enables lag compensation — render at `server_now - presentation_delay` +(e.g. 150 ms) so the displayed number never exceeds what the server accepts at +the moment of a click. A cleaner fairness story than trusting the client clock. + +## 4. Commit–reveal instead of a hidden `Option` + +zagan-bet already withholds the crash point (`CrashState::crash_point`, +`src/crash.rs`). Make it verifiable: + +``` +RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id +...round runs... +EVT seq=980 crash.busted {"seed":"","crash_point":2.37,...} +``` + +Player supplies a client seed; the server echoes it. Crash point derives from +`HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing +`crash_point_from_uniform`. Verification is a pure function and belongs in a +unit test, not a network test. A hash chain lets the server pre-commit every +future round at session start; that is stronger than needed in most cases. + +## 5. Framing: length prefix, then maybe QUIC + +Weaknesses in newline framing: + +- `LineBuffer` grows without bound; a client that never sends `\n` is a memory + DoS. Reject oversized frames *before* allocating. +- `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only + safe while `serde_json` keeps escaping newlines — an implicit dependency. + +``` +[u32 LE len][u8 proto_version][u8 kind][payload] +``` + +This allows a move to `postcard`/CBOR later without touching the read loop. +The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY` +cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds +mid-round, and unreliable datagrams for ticks where latest-wins beats +retransmission. zagan-bet already uses rustls on the client, so this would also +drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a +rustls bump. + +## 6. Resume tokens and a blocking reader thread + +`AUTH` returns an opaque HMAC-signed resume token plus a session id; +`RESUME since=` replays missed `EVT`s from a bounded per-session +buffer. Turns disconnect from "forfeit everything" into a policy choice; a +server-clocked round is trivially resumable. In zagan-bet this contradicts the +AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out +and a grace window. + +Separately, both ends poll with timers: the server's 50 ms read timeout, and +the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in +`TlsStream::read_available_line`. A dedicated blocking reader thread feeding an +`mpsc` removes the per-call timeout mutation, `expects_push`, and +`POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick +deadline, and shutdown. Still no async runtime. + +## 7. Small ones worth batching + +- `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of + strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing. +- Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the + frontend can tell "reconnect" from "show text". +- `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP + connection is otherwise noticed only on the next write. +- Per-connection token-bucket rate limiting. Every inbound line is logged, so a + chatty client is also a log-flood/disk problem. +- A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics + and `balance >= 0` for arbitrary input, plus transcript-replay tests. + +## If only three + +1. Correlation IDs + idempotency keys — small, kills two bug classes. +2. Clock sync + coalesced ticks — turns a special-case push into a generic + event channel. +3. Commit–reveal — cheap, and the strongest fairness story for a betting + server. + +The envelope plus idempotency is the least invasive pair: it touches +`src/protocol.rs`, `ServerCore::handle_message`, and +`ClientCore::handle_reply`, and can sit behind a `caps=` flag so version +lockstep 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. 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and reserves the bet. -2. The multiplier rises smoothly with no network traffic. Cash out with C and - confirm the board shows the cashout multiplier, the payout and the new - balance. +2. The multiplier rises smoothly from the server-clock anchor and is refreshed + by 10 Hz ticks. Cash out with C and confirm the board shows the cashout + multiplier, the payout and the new balance. 3. On another round, press nothing. The board must switch to `CRASHED at Xx` by itself when the crash point is reached, and the bet must be lost. 4. 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. 5. Q during a live round forfeits the bet; Q when idle just leaves. 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, the payout, and `crash_point`/`multiplier`/`result` in `details`. -7. Confirm the crash point is absent from the `CRASH_START` line (it must only - appear once the round is over). +7. Confirm the crash point and seed are absent from the `CRASH_START` message; + only `commit` and `started_at` appear. When the round ends, `seed` appears + and `sha256(seed) == commit`; recompute the crash point from the seed. +8. Confirm `CRASH_BUSTED` is an `EVT`, not a `RES`, and that a second poll + produces no repeated settlement. ## Manual restart check @@ -237,7 +240,7 @@ terminal client; a saved login from one signs in the other. 1. Connect a client and server from the same release: the client connects and the server log shows the `HELLO` exchange. -2. Connect a 4.x client to a 3.x server (or the reverse): the client must print +2. Connect a 5.x client to a 4.x server (or the reverse): the client must print `Version mismatch: client ..., server ...` and never reach the login prompt. 3. With a deliberately skew-built server (PATCH differs) the connection is 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. ## Packaging checklist -1. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), - run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree - because the site links the committed git history as the GPL source. +1. Set the version in `Cargo.toml`, `gui/Cargo.toml` and `webd/Cargo.toml` + (same MAJOR.MINOR), run `cargo test --workspace`, build the browser app + (`cd web/play && npm ci && npm run build`), and commit. `publish.sh` refuses + a dirty tree because the site links the committed git history as the GPL + source. 2. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI with `gui-release` and the terminal clients and servers. The installers are stable site files and are not regenerated. @@ -284,4 +289,33 @@ terminal client; a saved login from one signs in the other. menu entry. It must install the newest version even when an older app sits next to it, and refuse a corrupted download. 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. +6. Start `webd` against the staging server with a pinned fingerprint and open + `/play/` through nginx. Run the manual web checklist below. + +## Manual web checklist + +1. Open `/play/` in a fresh browser. The auth card must appear; the top bar + must not claim a balance until login. +2. Register, then log in with the password. The lobby must show 1 000 Kr and + every game. Refresh the page; the session cookie must reattach and the + balance must survive (within `ZAGAN_GRACE_SECONDS`). +3. Open the same URL in a second tab. The first tab must show "Opened in + another tab" and stop reconnecting; the second must continue. +4. Slots: spin and watch the reels settle on the server result; check the + payout and balance. Roulette: place straight, dozen, column and outside + chips, right-click one back, clear, spin, and confirm the result. +5. Blackjack: deal, hit/stand/double, then deal again; forfeiting must warn + and lose the reserved bet. +6. Crash: start a round, watch the curve and ticks, cash out; on another + round let it bust. Confirm the reveal line shows `sha256(seed) == commit` + and the recomputed crash point matches. +7. Refresh in the middle of a live crash round. The current view must return + with the same `commit` and a rising multiplier; the round must still settle. +8. Close the tab mid-round and wait past the grace window. The next visit must + log in again, and the server must have forfeited the round. +9. Stop the gateway. The browser must show the reconnect overlay, then recover + when `webd` returns. Stop it for longer than the grace window: the round is + forfeited and a login is required. +10. Check the browser console: no credential is stored, the cookie is HttpOnly, + and the only network target is `/ws`. 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 @@ +# Browser frontend plan + +Status: v1 implemented in 5.0.0. This is the working plan and the deployment +contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions. + +## Shape + +``` +browser ──wss──> nginx ──ws──> webd ──TLS──> game server + /play/ /ws 127.0.0.1:8890 127.0.0.1:11525 + static files gateway one thread per authoritative rules, + browser session balances, crash clock +``` + +- `web/play/` is a static TypeScript app. It sends `Command` JSON and renders + `View` JSON. It has no rules and no wire parser. +- `webd/` is a workspace member. It owns one `ClientCore`, one upstream TLS + connection and a 60-second grace window per browser session. +- `src/protocol.rs` is v5: bounded frames, `REQ`/`RES`/`EVT`, request ids, + server time and crash commit–reveal. + +## Phases + +1. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync, + ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change. +2. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned + upstream, `Command`/`View` JSON. Done in the 5.0.0 change. +3. **Web shell** — login/register, lobby, history, notices, route at `/play/`. + Done in the 5.0.0 change. +4. **Games** — slots, roulette (felt + chips), blackjack, crash (curve + + verification). Done in the 5.0.0 change. +5. **Deployment** — nginx routes, static build, systemd unit, verification in + `scripts/deploy.sh`. Done in the 5.0.0 change. + +## Browser contract + +Browser → gateway: + +```json +{"type":"command","command":{"Login":{"user":"alice","credential":""}}} +{"type":"command","command":{"Play":{"game":"crash","bet":10}}} +{"type":"command","command":"Start"} +{"type":"ping"} +``` + +`Command` uses serde's external tagging because several variants carry a +primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`). + +Gateway → browser: + +```json +{"type":"hello","version":"5.0.0","grace_seconds":60,"user":"alice","time":1700000000000} +{"type":"view","view":{"screen":"lobby", "...": "..."}} +{"type":"notice","text":"Logged in as alice"} +{"type":"error","text":"Upstream connection failed: ..."} +{"type":"replaced"} +{"type":"pong"} +``` + +`View` is internally tagged by `screen` (`lobby`, `history`, `slots`, +`roulette`, `blackjack`, `crash`). The browser renders each screen; it never +computes a result. + +The cookie is `zagan_session`, HttpOnly, SameSite=Strict, Secure on HTTPS. It +is set on the WebSocket handshake. It covers refresh and the grace window only; +there is no remember-me. + +## Build and run + +```sh +# static app +cd web/play +npm ci +npm run build # writes app.js; commit it + +# gateway +cargo build -p zagan-bet-webd --release +ZAGAN_UPSTREAM=127.0.0.1:11525 \ +ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \ +ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \ +ZAGAN_WEB_BIND=127.0.0.1:8890 \ +target/release/zagan-bet-webd +``` + +The fingerprint is the same SPKI SHA-256 the desktop client shows: + +```sh +openssl x509 -in cert.pem -pubkey -noout \ + | openssl pkey -pubin -outform DER \ + | sha256sum +``` + +Test against a staging site with +`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899 ZAGAN_WEB_COOKIE_SECURE=0`. + +## Security boundaries + +- The gateway binds loopback. nginx terminates TLS and is the only public + entry. Do not expose `ZAGAN_WEB_BIND` to the internet. +- The upstream key is pinned; there is no TOFU and no unsafe mode. A mismatch + logs both fingerprints and refuses credentials. +- The browser has no certificate UI and no credential storage. XSS cannot read + the HttpOnly cookie; the backend still must escape every notice. +- The browser speaks JSON, not the game wire. A malicious page cannot use the + gateway as a protocol proxy: `Origin` is checked and the session cookie is + SameSite=Strict. +- The web app is play-money only. No deposits, withdrawals, KYC or real-money + claims belong in v1 copy. + +## Failure behaviour + +| Event | Result | +| --- | --- | +| Refresh / new tab, within grace | Same `ClientCore`, current `View` restored | +| New tab, same cookie | Old tab gets `replaced` and closes; new tab continues | +| Socket closed, grace expires | Upstream closes; server forfeits live hand | +| Gateway restart | Sessions lost; players log in again | +| Upstream disconnect | Browser gets an error, core resets, reconnect attempts | +| Browser retry of a request | Gateway does not retry; request ids are per connection | + +## Verification + +- `cargo test --workspace` covers frames, envelope parsing, idempotent replay, + commit verification, ticks, core routing and session JSON. +- `web/play`: `npm run typecheck` and `npm run build`. +- Manual WS smoke test: connect, login, play slots/crash, refresh in a live + crash round, verify the revealed seed. +- `scripts/deploy.sh` checks `/play/`, `app.js`, `style.css` and the existing + 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 @@ # 0001 — GUI is a second frontend over a shared core -Status: accepted, 2026-11-01 +Status: accepted, 2026-11-01; the "no browser" constraint is superseded by +[0003](0003-web-gateway.md), which adds a browser frontend through a gateway. ## Context 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 @@ +# 0003 — Browser frontend through a server-side gateway + +Status: accepted, 2026-11-08 + +## Context + +The game already runs over TLS/TCP with a newline-delimited text protocol. A +browser cannot open a TCP socket, and putting game rules into JavaScript would +create a second implementation of payout, session and forfeit logic — exactly +what the project forbids. + +The site must stay a website in the ordinary sense: open `zagan.bet`, log in, +play. Nothing to install. Balances are play credits; there are no deposits, +withdrawals or KYC in this decision. + +## Decision + +1. **The browser is a third frontend over `ClientCore`.** It sends `Command` + JSON and renders `View` JSON. It contains no rules, no wire-protocol parser + and no balance arithmetic. +2. **A Rust gateway, `webd/`, owns each session.** One blocking thread per + browser session, one `ClientCore`, and one ordinary client TLS connection + upstream. The existing `client::transport` and `ClientCore` are reused. No + async runtime and no new concurrency style. +3. **`webd/` is a separate workspace member.** Like `gui/`, its dependencies + (tungstenite, HTTP cookie handling) must never enter the TUI, GUI or server + build graph. +4. **Everything is same-origin and operator-configured.** nginx terminates + TLS, serves the static app at `/play/`, and proxies `/ws` to the gateway on + loopback. The browser never sees a certificate, there is no CORS, and the + upstream address (`ZAGAN_UPSTREAM`, default `127.0.0.1:11525`) and SPKI + fingerprint (`ZAGAN_UPSTREAM_FINGERPRINT`, required) are gateway config. + A mismatch refuses to connect and logs both fingerprints. There is no TOFU + and no unsafe mode for the upstream leg. +5. **Session-only authentication.** Log in per visit. The gateway issues a + random HttpOnly, SameSite=Strict, Secure session cookie; the cookie covers a + refresh and the grace window only. No credential is stored in the browser or + in the gateway. A new connection with the same cookie takes over the session + and the old socket is told it was replaced. +6. **60-second grace window.** When the last browser socket for a session + closes, the session thread keeps the upstream connection and `ClientCore` + alive for 60 seconds (configurable). A reconnecting socket with the same + cookie reattaches and immediately receives the current `View`. Expiry closes + upstream, and the server forfeits a live hand per the existing disconnect + rule. There is no wire-level resume: the gateway, not the browser, holds the + TCP connection. +7. **Vanilla TypeScript + esbuild.** DOM for login, lobby, history and notices; + one canvas for the game surface. No framework runtime. Theme tokens are + reused from the desktop palette; felt green is reserved for roulette. +8. **Web v1 has full parity after trust.** Register, log in, lobby, all four + games, history, log out, notices, refresh grace. The connect/trust screens + do not exist in the browser because the gateway owns the upstream key. + +## Consequences + +- One implementation of the rules and parsers remains authoritative, and the + TUI/GUI/gateway all exercise the same `ClientCore` tests. +- The gateway becomes an operational component: it needs a service unit, + deployment, logging, a required upstream fingerprint, and a restart story. + A gateway restart loses live sessions and therefore forfeits their rounds; + this is accepted for v1. +- The browser cannot recover from a gateway crash by itself. The player logs in + again. +- A 60-second grace window means a live round can outlive a refresh, but not a + closed laptop for a minute and a half. +- WebSocket frames are same-origin JSON, so the page cannot be used as an open + proxy by another origin. The gateway also checks the `Origin` header against + `ZAGAN_WEB_ORIGIN`. +- The web app is play-money only. Adding payments later would be a separate + product and compliance decision, not a UI change. + +## Alternatives considered + +- **WASM `ClientCore` in the browser, dumb relay**: reuses Rust in the page, + but needs transport, TOFU and credential shims in wasm and a multi-megabyte + download for every player. Rejected. +- **JavaScript rewrite speaking the game protocol**: fastest UI iteration, but + a second implementation of rules and payout invariants. Rejected. +- **Tokio/axum gateway**: idiomatic WebSocket server, but a large async tree + and a second concurrency style for one blocking, low-traffic product. + Rejected. +- **Long-lived remember-me in the gateway**: better return visits, but a new + credential store and revocation flow. Deferred; session-only first. +- **Protocol-level resume tokens**: survives a gateway restart, but is a wire + 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 @@ +# 0004 — Protocol v5: frames, envelopes, time and commit–reveal + +Status: accepted, 2026-11-08 + +## Context + +The current wire is newline-delimited text over TLS. It has served the TUI and +GUI well, but four problems are now worth fixing before a browser gateway is +built on top: + +- `LineBuffer` grows without bound if a peer never sends a newline, and there + is no explicit maximum message size. +- A reply is assumed to answer the request that is currently pending. A late + reply, a retry or a second in-flight request can clobber state. +- There is no idempotency: a gateway retry after a timeout can charge a bet + twice. +- The crash multiplier is anchored to each frontend's local clock, and the + crash point is only "trust the server". + +The web gateway makes the first three worse (it retries and multiplexes) and +the browser makes the fourth visible (a page and the desktop can disagree). + +## Decision + +This is one wire change and one `MAJOR` release (`5.0.0`); there is no +compatibility mode. + +### Framing + +- Every packet is `[u32 length][payload]`, length big-endian. +- `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a + protocol error and the connection closes; buffers never grow past one frame. +- The payload has no trailing newline. + +### Envelope + +- `REQ [idem=] ` — client request. `` is a `u64` + chosen by the client, monotonically increasing on the connection. +- `RES ` — the reply to that request. +- `EVT ` — a server-initiated event. `` is a `u64`, + incremented once per event on the connection. + +The existing text grammar is the `` and ``; `HELLO 5.0.0`, +`PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning. + +`HELLO` is the first framed request. The server ignores every other command +before a successful handshake. The reply is +`HELLO latest= url= time=`. + +### Correlation and idempotency + +- The client keeps a request id until its `RES` arrives. Replies are routed by + id, never by "the next line". +- The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each + connection. A repeated id with the same payload returns the stored reply + without executing the command again. A repeated id with a different payload + returns `ERROR Request id reused with a different command`. +- The cache is per connection and in memory. A reconnect is a new game session + by design; durable idempotency is deferred. +- `idem=` is optional metadata for logs and for a future cross- + connection cache. The request id is the cache key. + +### Clock and ticks + +- `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING` + replies `PONG `. +- Clients keep an offset between server time and their own clock. The offset is + for display and interpolation only; the server clock settles a cash-out. +- A crash start carries `started_at=` and `commit=`. +- While a crash round runs, the server pushes at most one + `EVT crash.tick session= t= m=` per 100 ms. + The client interpolates between ticks and may keep rendering locally. +- `CRASH_BUSTED` remains an event, now with the reveal attached. + +### Commit–reveal crash + +- At round start the server draws 32 random bytes as the round seed and sends + `commit = sha256(seed)` in hex. It also sends the crash point's start time. +- The crash point is a pure function of the seed: + `u = u64_be(seed[0..8]) / 2^64`, then + `crash_point_from_uniform(u)`. The seed never reaches the client before the + round ends. +- When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the + server includes `seed=`. A client verifies `sha256(seed) == commit` and + recomputes the crash point. The UI can show "verified". +- This proves the crash point was fixed before the player acted. It does not + prove the seed was not ground to a favourable value; a hash chain with client + entropy is the documented next step if that matters. + +## Consequences + +- The TUI, GUI, server and gateway all move to v5 together; a v4 peer is + refused at `HELLO`. +- `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based + tests and helpers are rewritten around frames. +- `ClientCore` allocates request ids and routes replies by id. Its `Effect` + carries the id, so an adapter writes the envelope and hands the `RES` back + with the same id. +- Crash rendering can be smoother and consistent across frontends, and the + crash result is independently checkable from the revealed seed. +- The per-connection cache is deliberately small and forgets on reconnect; a + retry after a reconnect is a new action, not a replay. + +## Alternatives considered + +- **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite + of every parser for no scale need. Text inside a bounded frame keeps logs + readable and the change small. +- **Newline-delimited frames with a maximum line length**: still needs a + separate envelope and does not bound the buffer as cleanly; a declared length + is simpler. +- **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the + gateway owns retries on one connection and a reconnect is a fresh session. + Deferred. +- **Server seed plus client seed without a commit round-trip**: the server + sees the client seed before committing and can bias the pair; a half-measure + is worse than an honest per-round commitment. +- **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain + 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 @@ [package] name = "zagan-bet-gui" -version = "4.4.0" +version = "5.0.0" edition = "2024" license = "GPL-3.0-or-later" description = "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; use zagan_bet::client::paths; use zagan_bet::client::view::{Command, LobbyView, View}; use zagan_bet::history::PlayRecord; +use zagan_bet::protocol::Packet; use zagan_bet::version::{VERSION, is_newer}; use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; @@ -280,8 +281,8 @@ impl ZaganApp { } } } - Effect::SendLine(line) => { - self.net.send(NetCommand::Send(line)); + Effect::SendRequest { id, payload } => { + self.net.send(NetCommand::Send { id, payload }); self.awaiting = Some(Instant::now()); } Effect::Disconnect => { @@ -355,17 +356,18 @@ impl ZaganApp { failure, message, } => self.failed(addr, failure, message), - Event::Line(line) => { - if self.core.is_async(&line) { - if let Some(effects) = self.core.handle_async_line(&line) { + Event::Packet(packet) => match packet { + Packet::Event { body, .. } => { + if let Some(effects) = self.core.handle_async_line(&body) { self.apply(ctx, effects); } - } else { + } + Packet::Reply { id, body } => { self.awaiting = None; - let effects = self.core.handle_reply(&line); + let effects = self.core.handle_reply_id(id, &body); self.apply(ctx, effects); } - } + }, Event::Closed => { self.session = None; self.awaiting = None; @@ -384,6 +386,7 @@ impl ZaganApp { self.core .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); self.core.set_logged_in(connected.user.clone()); + self.core.set_server_time(connected.server_time); self.login.error = connected.auth_error.clone(); self.login.user = connected.user.clone().unwrap_or_default(); 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; use rusqlite::Connection; use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; use zagan_bet::client::{database, paths}; +use zagan_bet::protocol::Packet; /// How often the worker checks the socket for pushes when idle. const POLL: Duration = Duration::from_millis(8); @@ -24,7 +25,7 @@ const TRUST_TIMEOUT: Duration = Duration::from_secs(120); #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { Connect { addr: String }, - Send(String), + Send { id: u64, payload: String }, Disconnect, Stop, } @@ -49,6 +50,7 @@ pub struct Connected { pub latest: String, pub url: String, pub elapsed_ms: f32, + pub server_time: u64, } /// What the worker tells the UI. @@ -65,7 +67,7 @@ pub enum Event { failure: Failure, message: String, }, - Line(String), + Packet(Packet), Closed, } @@ -164,6 +166,7 @@ fn worker( latest: connected.latest, url: connected.url, elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, + server_time: connected.server_time, })); stream = Some(connected.stream); } @@ -177,9 +180,9 @@ fn worker( } ctx.request_repaint(); } - Ok(Command::Send(line)) => { + Ok(Command::Send { id, payload }) => { if let Some(open) = &mut stream { - if open.write_line(&line).is_err() { + if open.write_request(id, None, &payload).is_err() { stream = None; let _ = events.send(Event::Closed); } @@ -198,9 +201,9 @@ fn worker( // Drain pushes and replies without blocking the command channel. if let Some(open) = &mut stream { loop { - match open.read_available_line() { - Ok(Some(line)) => { - if events.send(Event::Line(line)).is_err() { + match open.read_available_packet() { + Ok(Some(packet)) => { + if events.send(Event::Packet(packet)).is_err() { return; } } 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 RSYNC_FLAGS=( -a --delay-updates --human-readable --chmod=D755,F644 --exclude=README.md --exclude=.gitignore + --exclude=play/node_modules ) if [[ "$TARGET" == *:* ]]; then @@ -160,6 +161,16 @@ for local in "${artifacts[@]}"; do verified=$((verified + 1)) done +curl -fsSL --max-time 30 "$origin/play/" -o "$tmp/play.html" \ + || die "could not fetch the browser app at /play/" +grep -q 'app.js' "$tmp/play.html" || die "/play/ does not reference app.js" +for asset in app.js style.css; do + curl -fsSL --max-time 30 "$origin/play/$asset" -o "$tmp/$asset" \ + || die "could not fetch /play/$asset" + cmp -s "web/play/$asset" "$tmp/$asset" \ + || die "/play/$asset served from the site differs from the local file" +done + [ "$failed" -eq 0 ] || die "verification failed" -echo "==> $verified artifact(s) verified at $URL" +echo "==> $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; use std::error::Error; use zagan_bet::client::transport::{self, TrustPrompt}; use zagan_bet::client::ui::first_contact_lines; -use zagan_bet::version::{is_newer, VERSION}; +use zagan_bet::version::{VERSION, is_newer}; pub use zagan_bet::client::transport::TlsStream; @@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { pub fn connect( addr: &str, conn: &rusqlite::Connection, -) -> Result<(TlsStream, Option), Box> { +) -> Result<(TlsStream, Option, u64), Box> { let unsafe_mode = *UNSAFE_MODE.lock(); // The terminal has no dialog: print the first-contact notice and trust. @@ -53,5 +53,5 @@ pub fn connect( println!("Authentication failed: {error}"); } - Ok((connected.stream, connected.user)) + Ok((connected.stream, connected.user, connected.server_time)) } 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::{ use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect}; -use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; -use zagan_bet::client::shell::{self, Shell}; -use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; +use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; +use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::ui::{ - display_width, format_pong, help_lines, host_lines, input_cursor_column, - lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, + MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column, + lines_to_move_up, terminal_size_warning, visual_line_count, }; -use zagan_bet::protocol::io_failure_message; +use zagan_bet::client::view::{RoulettePhase, View}; +use zagan_bet::protocol::{Packet, io_failure_message}; use zagan_bet::roulette::{WHEEL, color as roulette_color}; use crate::connection::TlsStream; @@ -78,9 +78,10 @@ impl ClientState { pub fn connect_to(&mut self, addr: &str) { self.addr = Some(addr.to_string()); match crate::connection::connect(addr, &self.conn) { - Ok((stream, authed_user)) => { + Ok((stream, authed_user, server_time)) => { self.stream = Some(stream); self.core.set_connected(true); + self.core.set_server_time(server_time); self.core.set_logged_in(authed_user); let fingerprint = database::get_fingerprint(&self.conn, addr); self.core.set_host_info(Some(addr.to_string()), fingerprint); @@ -126,9 +127,7 @@ impl ClientState { key, ) { GameInput::Quit => vec![Effect::Quit], - GameInput::Command(command) => { - self.core.command(command) - } + GameInput::Command(command) => self.core.command(command), GameInput::None => Vec::new(), } } else { @@ -189,10 +188,10 @@ impl ClientState { let Some(mut stream) = self.stream.take() else { return false; }; - let line = match stream.read_available_line() { - Ok(line) => { + let packet = match stream.read_available_packet() { + Ok(packet) => { self.stream = Some(stream); - line + packet } Err(_) => { self.core.set_connected(false); @@ -201,13 +200,15 @@ impl ClientState { } }; - let Some(line) = line else { + let Some(packet) = packet else { return false; }; - let effects = match self.core.handle_async_line(&line) { - Some(effects) => effects, - None => self.core.handle_reply(&line), + let effects = match packet { + Packet::Event { body, .. } => { + self.core.handle_async_line(&body).unwrap_or_default() + } + Packet::Reply { id, body } => self.core.handle_reply_id(id, &body), }; if self.apply_effects(effects) { return true; @@ -259,13 +260,13 @@ impl ClientState { self.draw_region(&lines); } Effect::ClearRegion => self.clear_region(), - Effect::SendLine(line) => { - let is_ping = line == "PING"; + Effect::SendRequest { id, payload } => { + let is_ping = payload == "PING"; let started = Instant::now(); - let reply = self.send_and_read(&line); + let reply = self.send_and_read(id, &payload); let elapsed = started.elapsed(); - let mut more = self.core.handle_reply(&reply); - if is_ping && reply.trim() == "PONG" { + let mut more = self.core.handle_reply_id(id, &reply); + if is_ping && reply.trim().starts_with("PONG") { for effect in &mut more { if let Effect::Print(lines) = effect { for text in lines { @@ -488,31 +489,30 @@ impl ClientState { self.prompt_text = text.to_string(); } - fn send_and_read(&mut self, line: &str) -> String { + fn send_and_read(&mut self, id: u64, line: &str) -> String { let Some(mut stream) = self.stream.take() else { return "ERROR Not connected".to_string(); }; // A crash event can be pushed while a request is in flight. Route those - // lines as they arrive and keep waiting for the actual reply. + // events as they arrive and keep waiting for the reply with this id. let mut pushed: Vec = Vec::new(); let result = (|| -> std::io::Result { - stream.write_line(line)?; + stream.write_request(id, None, line)?; loop { - let mut response = String::new(); - let read = stream.read_line(&mut response)?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "connection closed", - )); - } - let response = response.trim_end().to_string(); - if self.core.is_async(&response) { - pushed.push(response); - continue; + match stream.read_packet()? { + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "connection closed", + )); + } + Some(Packet::Event { body, .. }) => pushed.push(body), + Some(Packet::Reply { id: reply_id, body }) if reply_id == id => { + return Ok(body); + } + Some(Packet::Reply { .. }) => continue, } - return Ok(response); } })(); @@ -614,7 +614,11 @@ impl ClientState { short_fingerprint(&identity), host ), - Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), + Ok(false) => println!( + "No saved login for server {} ({}).", + short_fingerprint(&identity), + host + ), Err(error) => println!("Failed to forget the saved login: {}", error), } } @@ -625,10 +629,9 @@ impl ClientState { return; }; match database::forget_host(&self.conn, &target) { - Ok(outcome) if outcome.credentials_removed => println!( - "Cleared trust and stored credentials for {}.", - target - ), + Ok(outcome) if outcome.credentials_removed => { + println!("Cleared trust and stored credentials for {}.", target) + } Ok(outcome) => { println!("Cleared trust for {}.", target); if !outcome.siblings.is_empty() { @@ -665,7 +668,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("AUTH {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); if response.starts_with("OK") { @@ -709,7 +713,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("REGISTER {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); 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 @@ use std::time::Duration; use crate::tls::TlsStream; +use zagan_bet::protocol::{event_line, parse_request, reply_line}; use zagan_bet::server::core::{ConnectionState, ServerCore}; /// How long the connection waits for a request before advancing timed games. @@ -30,24 +31,35 @@ pub fn handle_client_tls(mut stream: TlsStream) { } let mut state = ConnectionState::default(); + let mut event_seq = 0u64; println!("[TLS Client connected] {client_addr}"); 'connection: loop { - let mut line = String::new(); - match stream.read_line(&mut line) { + let mut frame = String::new(); + match stream.read_frame(&mut frame) { Ok(0) => break, Ok(_) => { - let line = line.trim(); - if !line.is_empty() { - let user = state.user().unwrap_or("guest").to_string(); - println!("[{user} @ {client_addr}] Received: {line}"); - let response = core.handle_message(&mut state, line); - if let Err(error) = stream.write_line(&response) { - eprintln!("Write error: {error}"); - break; + let user = state.user().unwrap_or("guest").to_string(); + println!("[{user} @ {client_addr}] Received: {frame}"); + match parse_request(&frame) { + Ok(request) => { + let response = core.handle_request(&mut state, &request); + let reply = reply_line(request.id, &response); + if let Err(error) = stream.write_frame(&reply) { + eprintln!("Write error: {error}"); + break; + } + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Sent: {reply}"); + } + Err(message) => { + let reply = reply_line(0, &format!("ERROR {message}")); + if let Err(error) = stream.write_frame(&reply) { + eprintln!("Write error: {error}"); + break; + } + eprintln!("Malformed request from {client_addr}: {reply}"); } - let user = state.user().unwrap_or("guest"); - println!("[{user} @ {client_addr}] Sent: {response}"); } } Err(error) @@ -65,12 +77,17 @@ pub fn handle_client_tls(mut stream: TlsStream) { } for push in core.poll(&state) { - if let Err(error) = stream.write_line(&push) { + event_seq = event_seq.wrapping_add(1); + let event = event_line(event_seq, &push); + if let Err(error) = stream.write_frame(&event) { eprintln!("Write error: {error}"); break 'connection; } - let user = state.user().unwrap_or("guest"); - println!("[{user} @ {client_addr}] Pushed: {push}"); + // Ticks are 10 Hz per live round; logging them floods the disk. + if !push.starts_with("crash.tick ") { + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Pushed: {event}"); + } } } 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 @@ use std::io::{Read, Write}; use std::net::TcpStream; -use zagan_bet::protocol::LineBuffer; +use zagan_bet::protocol::{FrameBuffer, write_frame}; #[derive(Clone)] pub struct TlsServer { @@ -44,34 +44,31 @@ impl TlsServer { pub struct TlsStream { stream: native_tls::TlsStream, - lines: LineBuffer, + frames: FrameBuffer, } impl TlsStream { pub fn new(stream: native_tls::TlsStream) -> Self { Self { stream, - lines: LineBuffer::new(), + frames: FrameBuffer::new(), } } - pub fn read_line(&mut self, s: &mut String) -> std::io::Result { - match self.lines.read_line(&mut self.stream)? { - Some(line) => { - s.push_str(&line); - s.push('\n'); - Ok(line.len() + 1) + /// Read one protocol frame. `Ok(0)` is EOF with no partial frame. + pub fn read_frame(&mut self, out: &mut String) -> std::io::Result { + match self.frames.read_frame(&mut self.stream)? { + Some(frame) => { + let length = frame.len(); + out.push_str(&frame); + Ok(length) } None => Ok(0), } } - pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - use std::io::Write; - self.stream.write_all(msg.as_bytes())?; - self.stream.write_all(b"\n")?; - self.stream.flush()?; - Ok(()) + pub fn write_frame(&mut self, msg: &str) -> std::io::Result<()> { + write_frame(&mut self.stream, msg) } pub fn peer_addr(&self) -> std::io::Result { 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::{ parse_blackjack_response, parse_crash_response, parse_history_response, parse_roulette_response, parse_spin_response, }; +use crate::client::ui::{game_lines, history_lines}; use crate::client::view::{ BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, }; -use crate::client::ui::{game_lines, history_lines}; use crate::crash::{CrashState, multiplier_at}; -use crate::history::{PlayRecord, MAX_HISTORY}; +use crate::history::{MAX_HISTORY, PlayRecord}; use crate::roulette::{BetTarget, Bets}; +use crate::version::{parse_pong, unix_time_ms}; use std::time::{Duration, Instant}; - - /// High-level actions produced by the client core. The terminal adapter is /// responsible for turning these into crossterm calls and network writes. #[derive(Debug, Clone, PartialEq)] @@ -24,8 +23,8 @@ pub enum Effect { Render(View), /// Remove the game board region. ClearRegion, - /// Send one protocol line and feed the reply to `handle_reply`. - SendLine(String), + /// Send one enveloped request and route its reply back by `id`. + SendRequest { id: u64, payload: String }, /// Drop the connection. Disconnect, /// Leave the program. @@ -61,6 +60,10 @@ pub struct ClientCore { connected: bool, logged_in: Option, pending: Option, + /// Id of the request whose reply fills `pending`. + pending_id: Option, + /// Next correlation id allocated on this connection. + next_request_id: u64, balance: Option, /// Account number from the last `INFO`, shown by both frontends. account: Option, @@ -68,6 +71,13 @@ pub struct ClientCore { fingerprint: Option, /// Local anchor for the crash multiplier, set when a round starts. crash_started: Option, + /// Server-time anchor for the crash multiplier, when the server supplied + /// `started_at`. + crash_started_ms: Option, + /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`. + server_offset_ms: i64, + /// True once a frontend has supplied a server timestamp. + clock_synced: bool, /// App screen shown when no game is open. screen: Screen, /// Last plays fetched with `history`. @@ -83,11 +93,16 @@ impl ClientCore { connected: false, logged_in: None, pending: None, + pending_id: None, + next_request_id: 1, balance: None, account: None, host: None, fingerprint: None, crash_started: None, + crash_started_ms: None, + server_offset_ms: 0, + clock_synced: false, screen: Screen::Lobby, play_history: Vec::new(), login_error: None, @@ -99,11 +114,19 @@ impl ClientCore { if !connected { self.logged_in = None; self.pending = None; + self.pending_id = None; self.crash_started = None; + self.crash_started_ms = None; + self.clock_synced = false; self.screen = Screen::Lobby; } } + /// The signed-in username, if any. + pub fn user(&self) -> Option<&str> { + self.logged_in.as_deref() + } + pub fn set_logged_in(&mut self, user: Option) { if user.is_some() { self.login_error = None; @@ -147,7 +170,44 @@ impl ClientCore { self.board_effects() } + /// Record the server's clock and the local time it was received. The + /// offset is for display; cash-out still settles on the server. + pub fn set_server_time(&mut self, server_ms: u64) { + self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64; + self.clock_synced = true; + } + + /// The game server's clock, as this client currently estimates it. + pub fn server_now_ms(&self) -> u64 { + (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64 + } + + /// Allocate a correlation id for a request the adapter sends directly + /// (the terminal's login prompt bypasses `ClientCore`). + pub fn allocate_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id = self.next_request_id.wrapping_add(1).max(1); + id + } + + fn begin_request(&mut self, pending: Pending, payload: String) -> Effect { + let id = self.allocate_request_id(); + self.pending = Some(pending); + self.pending_id = Some(id); + Effect::SendRequest { id, payload } + } + + /// Route a reply by correlation id. A reply we no longer expect (a late + /// response after a timeout) is ignored instead of corrupting the board. + pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec { + if self.pending_id != Some(id) { + return Vec::new(); + } + self.handle_reply(reply) + } + pub fn handle_reply(&mut self, reply: &str) -> Vec { + self.pending_id = None; match self.pending.take() { Some(Pending::Spin) => match parse_spin_response(reply) { Ok(result) => { @@ -265,7 +325,8 @@ impl ClientCore { } }, Some(Pending::Ping) => { - if reply.trim() == "PONG" { + if let Some(server_ms) = parse_pong(reply) { + self.set_server_time(server_ms); print_line("Pong") } else { self.connected = false; @@ -305,10 +366,8 @@ impl ClientCore { if self.game.begin_spin().is_err() { return Vec::new(); } - self.pending = Some(Pending::Spin); - let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::Spin, request)); effects } @@ -322,24 +381,24 @@ impl ClientCore { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; - self.pending = Some(Pending::Login { user: user.clone() }); - vec![Effect::SendLine(format!("AUTH {user} {credential}"))] + vec![self.begin_request( + Pending::Login { user: user.clone() }, + format!("AUTH {user} {credential}"), + )] } Command::Register { user, credential } => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } self.login_error = None; - self.pending = Some(Pending::Register); - vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] + vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))] } Command::Games => vec![Effect::Print(game_lines(&self.games()))], Command::Ping => { if !self.connected { return print_line("Not connected. Use 'connect :' first."); } - self.pending = Some(Pending::Ping); - vec![Effect::SendLine("PING".to_string())] + vec![self.begin_request(Pending::Ping, "PING".to_string())] } Command::Echo(message) => { if !self.connected { @@ -348,8 +407,7 @@ impl ClientCore { if message.trim().is_empty() { return print_line("Usage: send "); } - self.pending = Some(Pending::Echo); - vec![Effect::SendLine(message)] + vec![self.begin_request(Pending::Echo, message)] } Command::Info => self.info_effects(), Command::Play { game, bet } => self.play_game(&game, bet), @@ -458,6 +516,7 @@ impl ClientCore { fn exit_game(&mut self) -> Vec { self.crash_started = None; + self.crash_started_ms = None; self.screen = Screen::Lobby; self.game.end_game(); vec![ @@ -475,19 +534,17 @@ impl ClientCore { return self.exit_game(); }; self.game.set_blackjack_status("forfeiting"); - self.pending = Some(Pending::BlackjackForfeit); - vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} forfeit", - hand.session_id - ))] + vec![self.begin_request( + Pending::BlackjackForfeit, + format!("BLACKJACK_ACTION {} forfeit", hand.session_id), + )] } fn forfeit_crash(&mut self) -> Vec { let Some(session) = self.game.crash_session_id() else { return self.exit_game(); }; - self.pending = Some(Pending::CrashForfeit); - vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] + vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))] } fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec { @@ -533,10 +590,8 @@ impl ClientCore { if self.game.begin_spin().is_err() { return Vec::new(); } - self.pending = Some(Pending::RouletteSpin); - let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::RouletteSpin, request)); effects } @@ -566,9 +621,8 @@ impl ClientCore { } self.game.set_blackjack_status("dealing"); - self.pending = Some(Pending::BlackjackStart); let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::BlackjackStart, request)); effects } @@ -582,15 +636,21 @@ impl ClientCore { if action == "double" && !hand.actions.iter().any(|available| available == "double") { return Vec::new(); } - self.pending = Some(Pending::BlackjackAction); - vec![Effect::SendLine(format!( - "BLACKJACK_ACTION {} {}", - hand.session_id, action - ))] + vec![self.begin_request( + Pending::BlackjackAction, + format!("BLACKJACK_ACTION {} {}", hand.session_id, action), + )] } /// Redraw the rising multiplier. Called by the terminal loop on a timer. pub fn tick(&mut self) -> Vec { + if self.clock_synced { + if let Some(started_ms) = self.crash_started_ms { + let now = unix_time_ms() as i64 + self.server_offset_ms; + let elapsed_ms = (now - started_ms as i64).max(0) as u64; + return self.tick_at(Duration::from_millis(elapsed_ms)); + } + } let Some(started) = self.crash_started else { return Vec::new(); }; @@ -619,12 +679,16 @@ impl ClientCore { } pub fn is_async(&self, line: &str) -> bool { - line.trim_start().starts_with("CRASH_BUSTED ") + let line = line.trim_start(); + line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ") } /// Handle a server-initiated line. Returns `None` when the line is a normal /// request/response reply and belongs to `handle_reply`. pub fn handle_async_line(&mut self, line: &str) -> Option> { + if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") { + return Some(self.apply_crash_tick(tick)); + } if !self.is_async(line) { return None; } @@ -643,13 +707,43 @@ impl ClientCore { if self.crash_started.is_none() { self.crash_started = Some(Instant::now()); } + if state.started_at > 0 { + self.crash_started_ms = Some(state.started_at); + } } else { self.crash_started = None; + self.crash_started_ms = None; } self.game.apply_crash_state(state); self.board_effects() } + /// Apply `crash.tick session= t= m=`. + fn apply_crash_tick(&mut self, tick: &str) -> Vec { + let mut session = None; + let mut multiplier = None; + for token in tick.split_whitespace() { + if let Some(value) = token.strip_prefix("session=") { + session = Some(value); + } else if let Some(value) = token.strip_prefix("m=") { + multiplier = value.parse::().ok(); + } + } + let Some(multiplier) = multiplier else { + return Vec::new(); + }; + if let Some(session) = session { + if self.game.crash_session_id().as_deref() != Some(session) { + return Vec::new(); + } + } + if self.game.crash_status() != "running" { + return Vec::new(); + } + self.game.set_crash_multiplier(multiplier); + self.board_effects() + } + fn start_crash(&mut self) -> Vec { let status = self.game.crash_status(); if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { @@ -671,9 +765,8 @@ impl ClientCore { } self.game.set_crash_status("starting"); - self.pending = Some(Pending::CrashStart); let mut effects = self.board_effects(); - effects.push(Effect::SendLine(request)); + effects.push(self.begin_request(Pending::CrashStart, request)); effects } @@ -681,8 +774,7 @@ impl ClientCore { let Some(session) = self.game.crash_session_id() else { return Vec::new(); }; - self.pending = Some(Pending::CrashCashout); - vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] + vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))] } fn play_game(&mut self, game_type: &str, bet: Option) -> Vec { @@ -706,8 +798,7 @@ impl ClientCore { } let mut effects = self.board_effects(); - self.pending = Some(Pending::Info); - effects.push(Effect::SendLine("INFO".to_string())); + effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } Err(_) => print_line("Failed to enter game"), @@ -726,13 +817,12 @@ impl ClientCore { } self.screen = Screen::History; - self.pending = Some(Pending::History); let request = if limit == crate::history::DEFAULT_HISTORY { "HISTORY".to_string() } else { format!("HISTORY {limit}") }; - vec![Effect::SendLine(request)] + vec![self.begin_request(Pending::History, request)] } fn info_effects(&mut self) -> Vec { @@ -754,8 +844,7 @@ impl ClientCore { ])), } - self.pending = Some(Pending::Info); - effects.push(Effect::SendLine("INFO".to_string())); + effects.push(self.begin_request(Pending::Info, "INFO".to_string())); effects } @@ -809,7 +898,6 @@ impl ClientCore { } self.view().map(Effect::Render).into_iter().collect() } - } fn 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; use super::slots::SlotsGame; use super::traits::{Game, GameError, GamePhase, GameState}; use crate::blackjack::BlackjackState; -use crate::crash::{payout, CrashState}; +use crate::crash::{CrashState, payout}; use crate::roulette::Bets; use std::collections::HashMap; @@ -244,9 +244,7 @@ impl GameManager { pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { if let Some(session) = self.current_session.as_mut() { - session - .data - .insert("cursor".to_string(), cursor.encoded()); + session.data.insert("cursor".to_string(), cursor.encoded()); } } @@ -339,7 +337,7 @@ impl GameManager { }; session.bet_amount = state.bet; session.data.remove("error"); - for key in ["crash_point", "payout"] { + for key in ["crash_point", "payout", "commit", "seed"] { session.data.remove(key); } session @@ -360,10 +358,21 @@ impl GameManager { session .data .insert("payout".to_string(), state.payout.to_string()); + session + .data + .insert("started_at".to_string(), state.started_at.to_string()); + if let Some(commit) = &state.commit { + session.data.insert("commit".to_string(), commit.clone()); + } + if let Some(seed) = &state.seed { + session.data.insert("seed".to_string(), seed.clone()); + } if let Some(crash_point) = state.crash_point { + // Keep full precision: the browser recomputes this from the + // revealed seed, and the GUI charts it. session .data - .insert("crash_point".to_string(), format!("{crash_point:.2}")); + .insert("crash_point".to_string(), crash_point.to_string()); } } 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 @@ use super::traits::{Game, GameState}; use crate::client::view::{RoulettePhase, RouletteView}; use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; +use serde::{Deserialize, Serialize}; pub const ROULETTE_BOARD_LINES: usize = 12; @@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [ pub struct RouletteGame; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Direction { Up, Down, @@ -41,7 +42,7 @@ pub enum Direction { /// odd,even line between rows split of the two numbers /// even,even corner of four cells corner /// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Cursor { Zero, Lattice { x: usize, y: usize }, @@ -67,7 +68,9 @@ impl Cursor { match parts.as_slice() { ["zero"] => Cursor::Zero, ["lattice", x, y] => match (x.parse::(), y.parse::()) { - (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { + (Ok(x), Ok(y)) + if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => + { Cursor::Lattice { x, y } } _ => 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 @@ use std::fmt; use std::io::{Read, Write}; use std::net::TcpStream; -use std::sync::mpsc; use std::sync::Arc; +use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; use parking_lot::Mutex; -use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -use rustls::DigitallySignedStruct; use rusqlite::Connection; +use rustls::DigitallySignedStruct; +use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use crate::client::database; -use crate::protocol::LineBuffer; -use crate::tofu::{decide, spki_fingerprint, TrustDecision}; -use crate::version::{parse_hello, HelloReply, VERSION}; +use crate::protocol::{ + FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame, +}; +use crate::tofu::{TrustDecision, decide, spki_fingerprint}; +use crate::version::{HelloReply, VERSION, parse_hello}; + +/// Reserved request ids for the transport handshake. Application requests +/// start at 1, so the two ranges cannot collide on one connection. +const HELLO_REQUEST_ID: u64 = u64::MAX; +const AUTH_REQUEST_ID: u64 = u64::MAX - 1; /// How long to wait for TCP + TLS before giving up. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -43,6 +50,8 @@ pub struct Connected { pub server_version: String, pub latest: String, pub url: String, + /// Server Unix time reported by `HELLO`, milliseconds. + pub server_time: u64, /// Round trip of the HELLO exchange. pub elapsed: Duration, } @@ -51,10 +60,7 @@ pub struct Connected { pub enum TransportError { Timeout, TrustDeclined, - KeyChanged { - expected: String, - observed: String, - }, + KeyChanged { expected: String, observed: String }, VersionMismatch(String), Closed, Unexpected(String), @@ -121,7 +127,7 @@ pub fn connect( return Err(TransportError::KeyChanged { expected, observed: actual, - }) + }); } } } @@ -131,12 +137,7 @@ pub fn connect( .map_err(io_failure)?; let start = Instant::now(); - tls.write_line(&format!("HELLO {VERSION}")) - .map_err(io_failure)?; - let mut response = String::new(); - if tls.read_line(&mut response).map_err(io_failure)? == 0 { - return Err(TransportError::Closed); - } + let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?; let elapsed = start.elapsed(); let response = response.trim(); @@ -151,10 +152,11 @@ pub fn connect( let mut user = None; let mut auth_error = None; if let Some((username, password)) = database::get_credentials(conn, &observed) { - tls.write_line(&format!("AUTH {username} {password}")) - .map_err(io_failure)?; - let mut auth = String::new(); - tls.read_line(&mut auth).map_err(io_failure)?; + let auth = exchange( + &mut tls, + AUTH_REQUEST_ID, + &format!("AUTH {username} {password}"), + )?; if auth.trim().starts_with("OK") { user = Some(username); } else { @@ -169,19 +171,31 @@ pub fn connect( server_version: hello.version, latest: hello.latest, url: hello.url, + server_time: hello.time, elapsed, }) } +/// Send one request and wait for the reply with the same id, ignoring events +/// that arrive in between. Used only for the handshake. +fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result { + stream + .write_request(id, None, payload) + .map_err(io_failure)?; + loop { + match stream.read_packet().map_err(io_failure)? { + None => return Err(TransportError::Closed), + Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body), + Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue, + } + } +} + /// TCP + TLS on a thread so a blocked handshake can time out. fn tls_connect(addr: &str) -> Result<(TlsStream, Option), TransportError> { let (sender, receiver) = mpsc::channel(); let addr = addr.to_string(); - let server_name = addr - .split(':') - .next() - .unwrap_or("127.0.0.1") - .to_string(); + let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string(); thread::spawn(move || { let outcome = (|| -> Result<(TlsStream, Option), TransportError> { @@ -241,7 +255,7 @@ impl TlsConnector { session: Arc::new(Mutex::new(Session { sock, tls: connection, - lines: LineBuffer::new(), + frames: FrameBuffer::new(), read_timeout: Some(READ_TIMEOUT), })), }) @@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier { _ocsp_response: &[u8], _now: std::time::SystemTime, ) -> Result { - let fingerprint = spki_fingerprint(&end_entity.0) - .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; + let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| { + rustls::Error::General(format!("certificate parse failed: {error}")) + })?; *self.observed.lock() = Some(fingerprint); Ok(ServerCertVerified::assertion()) } @@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier { struct Session { sock: TcpStream, tls: rustls::ClientConnection, - lines: LineBuffer, + frames: FrameBuffer, read_timeout: Option, } @@ -329,37 +344,58 @@ pub struct TlsStream { } impl TlsStream { - pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { + /// Write one enveloped request. Application code allocates `id`. + pub fn write_request( + &mut self, + id: u64, + idem: Option<&str>, + payload: &str, + ) -> std::io::Result<()> { + self.write_frame(&request_line(id, idem, payload)) + } + + /// Write one raw frame payload. + pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> { + let mut s = self.session.lock(); + write_protocol_frame(&mut s.tls.writer(), payload)?; + s.drive() + } + + /// Block until one whole frame arrives. + pub fn read_frame(&mut self) -> std::io::Result> { let mut s = self.session.lock(); loop { - if let Some(line) = s.lines.next_line() { - buf.push_str(&line); - buf.push('\n'); - return Ok(line.len() + 1); + match s.frames.next_frame() { + Some(Ok(frame)) => return Ok(Some(frame)), + Some(Err(error)) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + None => {} } let mut chunk = [0u8; 4096]; match s.tls.reader().read(&mut chunk) { - Ok(0) => { - if let Some(rest) = s.lines.take_rest() { - let read = rest.len(); - buf.push_str(&rest); - return Ok(read); - } - return Ok(0); - } - Ok(n) => s.lines.push(&chunk[..n]), + Ok(0) => return Ok(None), + Ok(n) => s.frames.push(&chunk[..n]), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, Err(error) => return Err(error), } } } - pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { - let mut s = self.session.lock(); - s.tls.writer().write_all(message.as_bytes())?; - s.tls.writer().write_all(b"\n")?; - s.drive() + /// Block until one `RES` or `EVT` arrives. + pub fn read_packet(&mut self) -> std::io::Result> { + loop { + let Some(frame) = self.read_frame()? else { + return Ok(None); + }; + match parse_packet(&frame) { + Ok(packet) => return Ok(Some(packet)), + Err(error) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + } + } } pub fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { @@ -368,24 +404,36 @@ impl TlsStream { s.sock.set_read_timeout(duration) } - /// Return one complete line if the socket already has one, without - /// blocking. Used to pick up server pushes between requests. - pub fn read_available_line(&mut self) -> std::io::Result> { + /// Return one packet if the socket already has one, without blocking. + /// Used to pick up server pushes between requests. + pub fn read_available_packet(&mut self) -> std::io::Result> { let mut s = self.session.lock(); - if let Some(line) = s.lines.next_line() { - return Ok(Some(line)); - } // `set_read_timeout(Some(0))` is rejected by std, so poll with the // shortest timeout the socket accepts instead of a nonblocking mode. s.sock .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; - let drained = (|| -> std::io::Result<()> { + let drained = (|| -> std::io::Result> { loop { + match s.frames.next_frame() { + Some(Ok(frame)) => { + return match parse_packet(&frame) { + Ok(packet) => Ok(Some(packet)), + Err(error) => { + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + } + }; + } + Some(Err(error)) => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + None => {} + } + let mut buf = [0u8; 4096]; match s.tls.reader().read(&mut buf) { - Ok(0) => return Ok(()), - Ok(n) => s.lines.push(&buf[..n]), + Ok(0) => return Ok(None), + Ok(n) => s.frames.push(&buf[..n]), // The reader only drains plaintext rustls has already // decrypted; `drive` moves bytes off the socket. Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { @@ -397,7 +445,7 @@ impl TlsStream { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { - return Ok(()); + return Ok(None); } Err(error) => return Err(error), } @@ -408,15 +456,14 @@ impl TlsStream { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { - return Ok(()); + return Ok(None); } Err(error) => return Err(error), } } })(); s.sock.set_read_timeout(s.read_timeout)?; - drained?; - Ok(s.lines.next_line()) + drained } } 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; use crate::client::games::roulette::{Cursor, Direction}; use crate::client::games::traits::{GamePhase, GameState}; use crate::roulette::{BetTarget, Bets}; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum RoulettePhase { Placing, Spinning, Result, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RouletteView { pub bet_amount: u64, pub balance: Option, @@ -51,7 +53,10 @@ impl RouletteView { Some("result") => RoulettePhase::Result, _ => RoulettePhase::Placing, }; - let number = state.data.get("number").and_then(|value| value.parse().ok()); + let number = state + .data + .get("number") + .and_then(|value| value.parse().ok()); let color = state.data.get("color").cloned(); let winnings = state .data @@ -82,14 +87,15 @@ impl RouletteView { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum SlotsPhase { Ready, Spinning, Result, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SlotsView { pub bet: u64, pub balance: Option, @@ -149,7 +155,7 @@ impl SlotsView { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BlackjackView { pub status: String, pub balance: Option, @@ -186,7 +192,7 @@ impl BlackjackView { } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CrashView { pub status: String, pub balance: Option, @@ -195,6 +201,11 @@ pub struct CrashView { pub potential: u64, pub crash_point: Option, pub payout: u64, + pub started_at: u64, + /// `sha256(seed)` for this round, before it ends. + pub commit: Option, + /// The revealed seed, after the round ends. + pub seed: Option, pub error: Option, } @@ -232,6 +243,13 @@ impl CrashView { .get("payout") .and_then(|value| value.parse::().ok()) .unwrap_or(0), + started_at: state + .data + .get("started_at") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + commit: state.data.get("commit").cloned(), + seed: state.data.get("seed").cloned(), error: state .data .get("error") @@ -242,7 +260,7 @@ impl CrashView { } /// The lobby: who is logged in, the balance, and what can be played. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LobbyView { pub connected: bool, pub user: Option, @@ -254,13 +272,14 @@ pub struct LobbyView { } /// The play log, already parsed. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct HistoryView { pub records: Vec, } /// Everything a frontend can draw. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "screen", rename_all = "snake_case")] pub enum View { Lobby(LobbyView), History(HistoryView), @@ -271,17 +290,28 @@ pub enum View { } /// Semantic input. Frontends turn keys and clicks into these. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Command { /// The credential is already hashed by the frontend. - Login { user: String, credential: String }, + Login { + user: String, + credential: String, + }, Games, Ping, Echo(String), Info, - Register { user: String, credential: String }, - Play { game: String, bet: Option }, - History { limit: u32 }, + Register { + user: String, + credential: String, + }, + Play { + game: String, + bet: Option, + }, + History { + limit: u32, + }, ShowLobby, Logout, 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 @@ //! clock, never from anything the client sends. use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::time::Duration; /// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. @@ -47,6 +48,53 @@ pub fn crash_point_from_uniform(u: f64) -> f64 { ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) } +/// How many bytes a round seed contains. The first eight bytes choose the +/// crash point; the rest are revealed and hashed with it. +pub const SEED_LEN: usize = 32; + +/// Hex-encoded SHA-256 of a round seed, sent before the round starts. +pub fn commit_seed(seed: &[u8]) -> String { + let digest = Sha256::digest(seed); + format!("{digest:x}") +} + +/// Uniform draw encoded by a seed: the first eight bytes, big-endian. +pub fn seed_to_uniform(seed: &[u8]) -> f64 { + let mut bytes = [0u8; 8]; + let take = seed.len().min(8); + bytes[..take].copy_from_slice(&seed[..take]); + u64::from_be_bytes(bytes) as f64 / 18_446_744_073_709_551_616.0 +} + +/// The crash point a seed commits to. A client recomputes this after the +/// reveal and checks it against the published commit. +pub fn crash_point_from_seed(seed: &[u8]) -> f64 { + crash_point_from_uniform(seed_to_uniform(seed)) +} + +/// Decode a lowercase or uppercase hex seed. Returns `None` for odd lengths, +/// bad digits or a seed that is not exactly [`SEED_LEN`] bytes. +pub fn seed_from_hex(raw: &str) -> Option> { + if raw.len() != SEED_LEN * 2 { + return None; + } + let mut seed = Vec::with_capacity(SEED_LEN); + for pair in raw.as_bytes().chunks_exact(2) { + let high = (pair[0] as char).to_digit(16)?; + let low = (pair[1] as char).to_digit(16)?; + seed.push((high * 16 + low) as u8); + } + Some(seed) +} + +/// Check a revealed seed against the commit published before the round. +pub fn verify_seed(seed_hex: &str, commit: &str) -> bool { + let Some(seed) = seed_from_hex(seed_hex) else { + return false; + }; + commit_seed(&seed).eq_ignore_ascii_case(commit) +} + /// Whole-Kr cashout value. Balances are integers, so the payout is floored. pub fn payout(bet: u64, multiplier: f64) -> u64 { if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { @@ -72,10 +120,16 @@ pub struct CrashState { pub multiplier: f64, /// `floor(bet * multiplier)` for the value above. pub potential: u64, + /// Server Unix time when the round started, milliseconds. + pub started_at: u64, /// Hidden until the round ends. pub crash_point: Option, pub payout: u64, pub result: String, + /// `sha256(seed)`, present for the whole round. + pub commit: Option, + /// The seed itself, present only after the round ends. + pub seed: Option, } impl 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 @@ -//! Newline-delimited protocol helpers shared by the client and server. +//! Protocol v5: bounded frames and the request/reply/event envelope. //! -//! TLS reads can return more than one line at a time. Keeping the leftover -//! bytes is essential: dropping them silently loses the next message. +//! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and +//! capped, so a peer cannot make the reader allocate without bound. Inside a +//! frame the payload is text: +//! +//! ```text +//! REQ [idem=] +//! RES +//! EVT +//! ``` +//! +//! The command and body grammar is the same text protocol the games already +//! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds +//! correlation, event numbering and a hard size limit. + +use std::fmt; +use std::io::{self, Read, Write}; + +/// Largest payload a peer may send in one frame. A game message is a few +/// hundred bytes; 64 KiB is generous and still bounds the reader. +pub const MAX_FRAME_LEN: usize = 64 * 1024; + +/// How many request ids a connection remembers for idempotent retries. +pub const REPLAY_CACHE_LEN: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameError { + /// The declared payload length exceeds [`MAX_FRAME_LEN`]. + TooLarge(usize), + /// The payload was not valid UTF-8. + InvalidUtf8, +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FrameError::TooLarge(length) => { + write!( + f, + "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit" + ) + } + FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"), + } + } +} -use std::io::{self, Read}; +impl std::error::Error for FrameError {} +/// A bounded frame decoder. Feed it bytes from a socket, take whole frames. #[derive(Debug, Default)] -pub struct LineBuffer { +pub struct FrameBuffer { buffer: Vec, } -impl LineBuffer { +impl FrameBuffer { pub fn new() -> Self { Self::default() } @@ -19,46 +63,172 @@ impl LineBuffer { self.buffer.extend_from_slice(bytes); } - /// Return the next complete line, including a line that ended at EOF. - pub fn next_line(&mut self) -> Option { - let end = self.buffer.iter().position(|byte| *byte == b'\n')?; - let mut line: Vec = self.buffer.drain(..=end).collect(); - line.pop(); // '\n' - if line.last() == Some(&b'\r') { - line.pop(); + /// Return the next complete frame, if the buffer holds one. + /// + /// A declared length above the limit is reported immediately instead of + /// waiting for bytes that will never be accepted; the caller should close + /// the connection. + pub fn next_frame(&mut self) -> Option> { + if self.buffer.len() < 4 { + return None; } - Some(String::from_utf8_lossy(&line).into_owned()) - } - /// Return the unterminated remainder at EOF, if any. - pub fn take_rest(&mut self) -> Option { - if self.buffer.is_empty() { + let length = u32::from_be_bytes([ + self.buffer[0], + self.buffer[1], + self.buffer[2], + self.buffer[3], + ]) as usize; + + if length > MAX_FRAME_LEN { + self.buffer.clear(); + return Some(Err(FrameError::TooLarge(length))); + } + if self.buffer.len() < 4 + length { return None; } - let mut rest = std::mem::take(&mut self.buffer); - if rest.last() == Some(&b'\r') { - rest.pop(); + + let payload: Vec = self.buffer[4..4 + length].to_vec(); + self.buffer.drain(..4 + length); + match String::from_utf8(payload) { + Ok(text) => Some(Ok(text)), + Err(_) => Some(Err(FrameError::InvalidUtf8)), } - Some(String::from_utf8_lossy(&rest).into_owned()) } - /// Read one line from a blocking reader, preserving any extra lines for - /// the next call. Returns `None` at EOF with nothing buffered. - pub fn read_line(&mut self, reader: &mut R) -> io::Result> { + /// Read one frame from a blocking reader, preserving leftover bytes. + /// `None` means EOF with no complete frame buffered. + pub fn read_frame(&mut self, reader: &mut R) -> io::Result> { loop { - if let Some(line) = self.next_line() { - return Ok(Some(line)); + match self.next_frame() { + Some(Ok(frame)) => return Ok(Some(frame)), + Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), + None => {} } let mut chunk = [0u8; 4096]; match reader.read(&mut chunk) { - Ok(0) => return Ok(self.take_rest()), + Ok(0) => return Ok(None), Ok(n) => self.push(&chunk[..n]), Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => return Err(error), } } } + + /// True when a partial frame is buffered (used by tests and diagnostics). + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } +} + +/// Encode one payload as a length-prefixed frame. +pub fn encode_frame(payload: &str) -> Vec { + let length = payload.len() as u32; + let mut bytes = Vec::with_capacity(4 + payload.len()); + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(payload.as_bytes()); + bytes +} + +/// Write one payload as a length-prefixed frame. +pub fn write_frame(writer: &mut W, payload: &str) -> io::Result<()> { + writer.write_all(&encode_frame(payload))?; + writer.flush() +} + +/// One client request. `id` is the correlation key; `idem` is optional +/// metadata for logs and a future cross-connection cache. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub id: u64, + pub idem: Option, + pub payload: String, +} + +/// A frame received by a client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Packet { + Reply { id: u64, body: String }, + Event { seq: u64, body: String }, +} + +pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String { + match idem { + Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"), + _ => format!("REQ {id} {payload}"), + } +} + +pub fn reply_line(id: u64, body: &str) -> String { + format!("RES {id} {body}") +} + +pub fn event_line(seq: u64, body: &str) -> String { + format!("EVT {seq} {body}") +} + +/// Parse a `REQ` payload. Errors are safe to return to the peer. +pub fn parse_request(frame: &str) -> Result { + let frame = frame.trim_end(); + let rest = frame + .strip_prefix("REQ ") + .ok_or_else(|| "Malformed request: expected REQ".to_string())?; + + let (id_token, mut rest) = split_token(rest); + let id: u64 = id_token + .parse() + .map_err(|_| "Malformed request id".to_string())?; + + let mut idem = None; + let (next, after) = split_token(rest); + if let Some(value) = next.strip_prefix("idem=") { + if value.is_empty() { + return Err("Malformed idempotency key".to_string()); + } + idem = Some(value.to_string()); + rest = after; + } + + Ok(Request { + id, + idem, + payload: rest.to_string(), + }) +} + +/// Parse a `RES` or `EVT` payload on the client side. +pub fn parse_packet(frame: &str) -> Result { + let frame = frame.trim_end(); + if let Some(rest) = frame.strip_prefix("RES ") { + let (id_token, body) = split_token(rest); + let id: u64 = id_token + .parse() + .map_err(|_| "Malformed reply id".to_string())?; + return Ok(Packet::Reply { + id, + body: body.to_string(), + }); + } + if let Some(rest) = frame.strip_prefix("EVT ") { + let (seq_token, body) = split_token(rest); + let seq: u64 = seq_token + .parse() + .map_err(|_| "Malformed event sequence".to_string())?; + return Ok(Packet::Event { + seq, + body: body.to_string(), + }); + } + Err(format!("Unexpected packet: {frame}")) +} + +fn split_token(rest: &str) -> (&str, &str) { + let rest = rest.trim_start(); + match rest.find(' ') { + Some(index) => (&rest[..index], rest[index + 1..].trim_start()), + None => (rest, ""), + } } /// 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 @@ -use bcrypt::{hash, verify, DEFAULT_COST}; +use bcrypt::{DEFAULT_COST, hash, verify}; use rusqlite::Connection; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use crate::blackjack::BlackjackState; use crate::crash::CrashState; +use crate::protocol::{REPLAY_CACHE_LEN, Request}; use crate::server::database; -use crate::version::{download_url, hello_line, Version, VERSION}; use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; use crate::server::games::registry::GameRegistry; use crate::server::games::session::SessionManager; use crate::server::games::traits::{GameError, GamePhase, GameState}; +use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms}; #[derive(Debug, Default)] pub struct ConnectionState { user: Option, greeted: bool, + /// Last replies on this connection, oldest first. Request ids are unique + /// while cached, so a retried request replays its first result. + replays: VecDeque<(u64, String, String)>, } /// Settle reservations left behind by a previous process. @@ -39,6 +43,27 @@ impl ConnectionState { pub fn greeted(&self) -> bool { self.greeted } + + fn replay(&self, id: u64, payload: &str) -> Option> { + self.replays + .iter() + .find(|(cached_id, _, _)| *cached_id == id) + .map(|(_, cached_payload, reply)| { + if cached_payload == payload { + Ok(reply.clone()) + } else { + Err("ERROR Request id reused with a different command".to_string()) + } + }) + } + + fn remember(&mut self, id: u64, payload: &str, reply: &str) { + if self.replays.len() >= REPLAY_CACHE_LEN { + self.replays.pop_front(); + } + self.replays + .push_back((id, payload.to_string(), reply.to_string())); + } } pub struct ServerCore { @@ -49,6 +74,8 @@ pub struct ServerCore { blackjack: BlackjackGame, crash: CrashGame, clock: Arc, + /// Last time a crash tick was pushed, in seconds on the game clock. + last_tick: HashMap, } impl ServerCore { @@ -65,6 +92,7 @@ impl ServerCore { blackjack: BlackjackGame::new(), crash: CrashGame::new(), clock: Arc::new(RealClock::new()), + last_tick: HashMap::new(), } } @@ -96,6 +124,21 @@ impl ServerCore { Ok(Self::new(conn)) } + /// Handle one enveloped request, replaying a cached reply for a repeated + /// request id. The payload grammar is unchanged; the envelope only adds + /// correlation and idempotency. + pub fn handle_request(&mut self, state: &mut ConnectionState, request: &Request) -> String { + if let Some(reply) = state.replay(request.id, &request.payload) { + return match reply { + Ok(reply) => reply, + Err(message) => message, + }; + } + let reply = self.handle_message(state, &request.payload); + state.remember(request.id, &request.payload, &reply); + reply + } + pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { let parts: Vec<&str> = msg.splitn(5, ' ').collect(); @@ -201,7 +244,7 @@ impl ServerCore { } } } - "PING" => "PONG".to_string(), + "PING" => pong_line(unix_time_ms()), "INFO" => self.handle_info(state), _ => msg.to_string(), } @@ -212,7 +255,8 @@ impl ServerCore { return "ERROR Invalid HELLO format. Usage: HELLO ".to_string(); } - let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { + let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) + else { return "ERROR Invalid version".to_string(); }; @@ -225,7 +269,7 @@ impl ServerCore { } state.greeted = true; - hello_line(VERSION, VERSION, &download_url()) + hello_line(VERSION, VERSION, &download_url(), unix_time_ms()) } fn handle_register(&mut self, username: &str, credential: &str) -> String { @@ -529,8 +573,8 @@ impl ServerCore { // The session is the audit guard for the deduction, so it exists before // the balance is touched. - let crash_point = self.crash.next_crash_point(); - let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); + let seed = self.crash.next_seed(); + let round = CrashRound::new(bet as u64, seed, self.clock.now(), unix_time_ms()); let mut data = HashMap::new(); data.insert("round".to_string(), round.to_json()); data.insert("balance".to_string(), balance.to_string()); @@ -643,11 +687,27 @@ impl ServerCore { if !round.is_running() { continue; } - let elapsed = (self.clock.now() - round.started_at).max(0.0); - if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { + let now = self.clock.now(); + let elapsed = (now - round.started_at).max(0.0); + let multiplier = crate::crash::multiplier_at_secs(elapsed); + if multiplier < round.crash_point { + let last = self + .last_tick + .get(&session.session_id) + .copied() + .unwrap_or(f64::NEG_INFINITY); + if now - last >= 0.1 { + self.last_tick.insert(session.session_id.clone(), now); + out.push(format!( + "crash.tick session={} t={} m={multiplier:.4}", + session.session_id, + unix_time_ms() + )); + } continue; } round.crash(); + self.last_tick.remove(&session.session_id); match self.settle_crash(user, &session.session_id, &round, 0) { Ok(balance) => { let state = round.state(&session.session_id, balance); @@ -678,6 +738,7 @@ impl ServerCore { payout as i64, &details, ); + self.last_tick.remove(session_id); let _ = self.sessions.remove_session(session_id); result.map_err(|error| error.to_string()) } 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 @@ use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; -use crate::crash::{crash_point_from_uniform, payout, CrashState}; +use crate::crash::{ + CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform, +}; +use rand::RngCore; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; @@ -64,7 +67,14 @@ pub struct CrashRound { pub bet: u64, /// Drawn at start, revealed only when the round ends. pub crash_point: f64, + /// The 32 random bytes the crash point commits to, hex encoded. Kept on + /// the server until the round ends. + pub seed: String, + /// `sha256(seed)` in hex, sent with the start reply. + pub commit: String, pub started_at: f64, + /// Server Unix time at start, milliseconds, for clock sync. + pub started_at_ms: u64, /// `running`, `cashed`, `crashed` or `forfeit`. pub phase: String, pub result: String, @@ -74,11 +84,15 @@ pub struct CrashRound { } impl CrashRound { - pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { + pub fn new(bet: u64, seed: [u8; SEED_LEN], started_at: f64, started_at_ms: u64) -> Self { + let crash_point = crash_point_from_seed(&seed); Self { bet, crash_point, + seed: seed_hex(&seed), + commit: commit_seed(&seed), started_at, + started_at_ms, phase: "running".to_string(), result: "playing".to_string(), multiplier: 1.0, @@ -120,6 +134,7 @@ impl CrashRound { balance, multiplier: self.multiplier, potential: payout(self.bet, self.multiplier), + started_at: self.started_at_ms, crash_point: if self.is_running() { None } else { @@ -127,6 +142,12 @@ impl CrashRound { }, payout: self.payout, result: self.result.clone(), + commit: Some(self.commit.clone()), + seed: if self.is_running() { + None + } else { + Some(self.seed.clone()) + }, } } @@ -146,6 +167,34 @@ pub struct CrashGame { uniforms: VecDeque, } +/// Hex encode a round seed. Lowercase, no separators. +fn seed_hex(seed: &[u8]) -> String { + let mut out = String::with_capacity(seed.len() * 2); + for byte in seed { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// Draw a seed whose committed point matches a queued test uniform. +/// +/// The first eight bytes are the big-endian integer closest to +/// `uniform * 2^64`; the remaining bytes are zero. This keeps the existing +/// deterministic crash-point tests meaningful while every round still has a +/// real seed. +fn seed_from_uniform(uniform: f64) -> [u8; SEED_LEN] { + let uniform = if uniform.is_finite() { + uniform.clamp(0.0, 0.999_999_999_999_999_9) + } else { + 0.0 + }; + let value = (uniform * 18_446_744_073_709_551_616.0) as u64; + let mut seed = [0u8; SEED_LEN]; + seed[..8].copy_from_slice(&value.to_be_bytes()); + seed +} + impl CrashGame { pub fn new() -> Self { Self { @@ -163,12 +212,25 @@ impl CrashGame { } } + /// Draw the next round seed. Tests supply uniforms; production uses the + /// operating system's random source. + pub fn next_seed(&mut self) -> [u8; SEED_LEN] { + if let Some(uniform) = self.uniforms.pop_front() { + return seed_from_uniform(uniform); + } + let mut seed = [0u8; SEED_LEN]; + rand::thread_rng().fill_bytes(&mut seed); + seed + } + + /// Kept for callers that only need the point a queued uniform encodes. pub fn next_crash_point(&mut self) -> f64 { - let uniform = self - .uniforms - .pop_front() - .unwrap_or_else(rand::random::); - crash_point_from_uniform(uniform) + crash_point_from_seed(&self.next_seed()) + } + + /// The uniform encoded by a seed, exposed for verification tests. + pub fn uniform_of(seed: &[u8; SEED_LEN]) -> f64 { + seed_to_uniform(seed) } } 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 @@ //! accepted until the server has answered it. use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; /// Version this build reports. The client and the server share it. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -73,9 +74,9 @@ pub fn is_newer(latest: &str, current: &str) -> bool { } } -/// The server line that accepts a handshake. -pub fn hello_line(version: &str, latest: &str, url: &str) -> String { - format!("HELLO {version} latest={latest} url={url}") +/// The server line that accepts a handshake. `time` is Unix milliseconds. +pub fn hello_line(version: &str, latest: &str, url: &str, time: u64) -> String { + format!("HELLO {version} latest={latest} url={url} time={time}") } #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,9 +84,11 @@ pub struct HelloReply { pub version: String, pub latest: String, pub url: String, + /// Server clock at the moment the reply was built, Unix milliseconds. + pub time: u64, } -/// Parse `HELLO latest= url=`. +/// Parse `HELLO latest= url= time=`. pub fn parse_hello(line: &str) -> Option { let rest = line.trim().strip_prefix("HELLO ")?; let mut tokens = rest.split_whitespace(); @@ -93,11 +96,14 @@ pub fn parse_hello(line: &str) -> Option { let mut latest = None; let mut url = None; + let mut time = None; for token in tokens { if let Some(value) = token.strip_prefix("latest=") { latest = Some(value.to_string()); } else if let Some(value) = token.strip_prefix("url=") { url = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("time=") { + time = value.parse().ok(); } } @@ -105,9 +111,29 @@ pub fn parse_hello(line: &str) -> Option { version, latest: latest?, url: url?, + time: time?, }) } +/// The reply to `PING`: `PONG `. +pub fn pong_line(time: u64) -> String { + format!("PONG {time}") +} + +/// Parse a `PONG` reply's server time. +pub fn parse_pong(line: &str) -> Option { + line.trim().strip_prefix("PONG ")?.trim().parse().ok() +} + +/// Current Unix time in milliseconds. Used for handshake timestamps and crash +/// anchors; a system clock before the epoch reads as zero. +pub fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + /// Download page advertised by the server. `DOWNLOAD_URL` wins when set. pub fn download_url() -> String { 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 @@ use std::time::Duration; use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect}; -use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; -use zagan_bet::client::shell::{self, Shell}; -use zagan_bet::client::ui::help_lines; use zagan_bet::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_spin_response, }; use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; +use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::ui::command_names; +use zagan_bet::client::ui::help_lines; use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; -use zagan_bet::roulette::BetTarget; use zagan_bet::crash::{CrashState, multiplier_at}; +use zagan_bet::roulette::BetTarget; const WIN3: &str = "CCCCLLCLODGSDGS"; const LOSS: &str = "CLOOGDSLCDCOGLS"; @@ -54,7 +54,7 @@ impl Screen { self.region_start = None; self.region_len = 0; } - Effect::SendLine(line) => sent.push(line.clone()), + Effect::SendRequest { payload, .. } => sent.push(payload.clone()), Effect::Quit | Effect::Disconnect => {} } } @@ -601,7 +601,7 @@ fn ping_prints_pong() { h.line("ping"); assert_eq!(h.sent.last().map(String::as_str), Some("PING")); - h.reply("PONG"); + h.reply("PONG 1700000000000"); assert!(h.screen.lines.iter().any(|line| line == "Pong")); } @@ -968,6 +968,13 @@ fn crash_state( crash_point, payout, result: phase.to_string(), + started_at: 1_700_000_000_000, + commit: Some("00".repeat(32)), + seed: if phase == "running" { + None + } else { + Some("00".repeat(32)) + }, } } @@ -1221,11 +1228,12 @@ fn history_prints_recent_plays() { let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; h.reply(&format!("HISTORY {json}")); - assert!(h - .screen - .lines - .iter() - .any(|line| line.starts_with("Recent plays (1)"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)")) + ); let row = h .screen .lines @@ -1242,11 +1250,12 @@ fn history_without_plays_says_so() { let mut h = Harness::new(); h.line("history"); h.reply("HISTORY []"); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "No plays recorded yet.")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "No plays recorded yet.") + ); } #[test] @@ -1254,11 +1263,12 @@ fn history_rejects_a_bad_count_without_sending() { let mut h = Harness::new(); h.line("history 0"); assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); - assert!(h - .screen - .lines - .iter() - .any(|line| line.contains("Usage: history [1-50]"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.contains("Usage: history [1-50]")) + ); } #[test] @@ -1291,10 +1301,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() { Some(BetTarget::Straight { number: 2 }) ); assert_eq!(view.bets.total(), 10); - assert_eq!( - view.bets.amount_on(&BetTarget::Straight { number: 2 }), - 10 - ); + assert_eq!(view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10); } #[test] @@ -1302,7 +1309,9 @@ fn clicking_a_bet_target_does_not_move_the_cursor() { let mut h = Harness::new(); h.enter_roulette(); - let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); + let effects = h + .core + .command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); h.apply(effects); let Some(View::Roulette(view)) = h.core.view() else { @@ -1387,9 +1396,7 @@ fn the_slots_view_carries_the_result() { let mut h = Harness::new(); h.enter_slots("10"); h.key(Key::Char(' ')); - h.reply(&format!( - "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6" - )); + h.reply(&format!("GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6")); let Some(View::Slots(view)) = h.core.view() else { panic!("expected a slots view"); @@ -1518,11 +1525,12 @@ fn history_is_a_screen_and_keeps_its_records() { assert_eq!(view.records[0].summary(), "cashed 1.13x"); // The terminal adapter still prints the table. - assert!(h - .screen - .lines - .iter() - .any(|line| line.starts_with("Recent plays (1)"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)")) + ); } #[test] @@ -1563,11 +1571,12 @@ fn a_login_command_sends_auth_and_sets_the_user() { }; assert_eq!(lobby.user.as_deref(), Some("alice")); assert!(lobby.login_error.is_none()); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "Logged in as alice")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "Logged in as alice") + ); } #[test] @@ -1600,11 +1609,12 @@ fn a_register_command_sends_register_and_reports_the_result() { assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); h.reply("REGISTERED bob"); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "Account created. Log in to play.")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "Account created. Log in to play.") + ); } #[test] @@ -1646,3 +1656,56 @@ fn the_crash_multiplier_is_not_rounded_for_the_chart() { "a two-decimal store would flatten the curve into steps" ); } + +#[test] +fn a_reply_is_routed_by_request_id_and_late_replies_are_ignored() { + let mut h = Harness::new(); + let first = h.core.command(Command::Ping); + let first_id = first + .iter() + .find_map(|effect| match effect { + Effect::SendRequest { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + + let second = h.core.command(Command::Info); + let second_id = second + .iter() + .find_map(|effect| match effect { + Effect::SendRequest { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_ne!(first_id, second_id, "ids must be unique on the connection"); + + // The ping reply arrives after INFO was sent. It must not complete INFO. + assert!( + h.core + .handle_reply_id(first_id, "PONG 1700000000000") + .is_empty() + ); + let effects = h.core.handle_reply_id(second_id, "INFO alice 1 1000"); + assert!( + effects.iter().any(|effect| matches!(effect, Effect::Print(lines) if lines.iter().any(|line| line.contains("Balance: 1000")))), + "the matching reply is applied" + ); +} + +#[test] +fn a_crash_tick_updates_the_board_for_its_own_session() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + assert!(h.board()[2].contains("Multiplier: 1.00x")); + + h.push("crash.tick session=cr1 t=1700000000100 m=1.5"); + assert!(h.board()[2].contains("Multiplier: 1.50x")); + assert!(h.board()[3].contains("Potential: 15 Kr")); + + // A tick for another round cannot move this one. + h.push("crash.tick session=other t=1700000000200 m=9.0"); + assert!(h.board()[2].contains("Multiplier: 1.50x")); +} 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() { assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); } + +#[test] +fn a_seed_commits_to_its_crash_point_and_can_be_verified() { + use zagan_bet::crash::{ + SEED_LEN, commit_seed, crash_point_from_seed, seed_from_hex, seed_to_uniform, verify_seed, + }; + + let mut seed = [0u8; SEED_LEN]; + let value = (0.67_f64 * 18_446_744_073_709_551_616.0) as u64; + seed[..8].copy_from_slice(&value.to_be_bytes()); + let commit = commit_seed(&seed); + let hex = format!( + "{}", + seed.iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + + assert_eq!(seed_from_hex(&hex).as_deref(), Some(seed.as_slice())); + assert!(verify_seed(&hex, &commit)); + assert!(!verify_seed(&hex, &"00".repeat(32)), "a wrong commit fails"); + assert!(!verify_seed("nope", &commit), "bad hex fails"); + + // The point the client recomputes is the point the server committed to. + close(crash_point_from_seed(&seed), 3.0, 1e-9); + close(crash_point_from_seed(&seed), 3.0, 1e-9); + + // The first eight bytes are the uniform; the rest are still hashed. + close(seed_to_uniform(&seed), 0.67, 1e-9); + let mut altered = seed; + altered[SEED_LEN - 1] = 1; + assert!(!verify_seed( + &altered + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + &commit + )); +} 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 @@ -use std::io::{Cursor, Error, ErrorKind}; +use std::io::Cursor; +use zagan_bet::protocol::{ + FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message, + parse_packet, parse_request, reply_line, request_line, +}; -use zagan_bet::protocol::{io_failure_message, LineBuffer}; +fn framed(payload: &str) -> Vec { + encode_frame(payload) +} #[test] -fn keeps_data_after_the_first_newline() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); +fn decodes_every_complete_frame_and_keeps_the_remainder() { + let mut buffer = FrameBuffer::new(); + let mut bytes = framed("RES 1 PONG 1700000000000"); + bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5")); + buffer.push(&bytes); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); assert_eq!( - buffer.next_line().as_deref(), - Some("GAME_RESULT session 60 10 990 OSS two") + buffer.next_frame().unwrap().unwrap(), + "RES 1 PONG 1700000000000" + ); + assert_eq!( + buffer.next_frame().unwrap().unwrap(), + "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5" ); - assert_eq!(buffer.next_line(), None); + assert!(buffer.next_frame().is_none()); + assert!(buffer.is_empty()); } #[test] -fn handles_crlf_and_empty_lines() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\r\n\r\n"); +fn waits_for_a_partial_frame() { + let mut buffer = FrameBuffer::new(); + let bytes = framed("RES 7 OK"); + buffer.push(&bytes[..3]); + assert!(buffer.next_frame().is_none()); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); - assert_eq!(buffer.next_line().as_deref(), Some("")); - assert_eq!(buffer.next_line(), None); + buffer.push(&bytes[3..7]); + assert!(buffer.next_frame().is_none()); + + buffer.push(&bytes[7..]); + assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK"); } #[test] -fn waits_for_a_split_line() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PO"); - assert_eq!(buffer.next_line(), None); +fn an_oversized_length_is_rejected_without_waiting_for_bytes() { + let mut buffer = FrameBuffer::new(); + buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes())); + match buffer.next_frame() { + Some(Err(FrameError::TooLarge(length))) => { + assert_eq!(length, MAX_FRAME_LEN + 1); + } + other => panic!("expected an oversize error, got {other:?}"), + } + assert!(buffer.is_empty(), "the buffer must not keep the bad frame"); +} - buffer.push(b"NG\n"); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); +#[test] +fn invalid_utf8_is_a_protocol_error() { + let mut buffer = FrameBuffer::new(); + buffer.push(&1u32.to_be_bytes()); + buffer.push(&[0xff]); + assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8))); } #[test] -fn reads_multiple_lines_from_a_reader() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); +fn reads_frames_from_a_blocking_reader() { + let mut bytes = framed("RES 1 OK"); + bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0")); + let mut reader = Cursor::new(bytes); + let mut buffer = FrameBuffer::new(); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("one") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("RES 1 OK") ); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("two") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("EVT 1 crash.tick session=cr1 t=1 m=1.0") ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!(buffer.read_frame(&mut reader).unwrap(), None); } #[test] -fn returns_a_partial_line_at_eof() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"partial".to_vec()); - +fn builds_and_parses_the_envelope() { assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("partial") + request_line(42, None, "PLAY_START crash 50"), + "REQ 42 PLAY_START crash 50" ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!( + request_line(42, Some("abc123"), "PLAY_START crash 50"), + "REQ 42 idem=abc123 PLAY_START crash 50" + ); + + let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap(); + assert_eq!(request.id, 42); + assert_eq!(request.idem.as_deref(), Some("abc123")); + assert_eq!(request.payload, "PLAY_START crash 50"); + + let request = parse_request("REQ 7 PING").unwrap(); + assert_eq!(request.id, 7); + assert_eq!(request.idem, None); + assert_eq!(request.payload, "PING"); + + assert!(parse_request("PLAY_START crash 50").is_err()); + assert!(parse_request("REQ nope PING").is_err()); + assert!(parse_request("REQ 1 idem= PING").is_err()); } #[test] -fn timeout_errors_get_a_timeout_message() { - assert_eq!( - io_failure_message(&Error::from(ErrorKind::TimedOut)), - "ERROR Request timed out" - ); - assert_eq!( - io_failure_message(&Error::from(ErrorKind::WouldBlock)), - "ERROR Request timed out" - ); +fn parses_replies_and_events() { assert_eq!( - io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), - "ERROR Connection lost" + parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(), + Packet::Reply { + id: 9, + body: "ERROR Insufficient balance".to_string() + } ); assert_eq!( - io_failure_message(&Error::from(ErrorKind::ConnectionReset)), - "ERROR Connection lost" + parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(), + Packet::Event { + seq: 3, + body: "CRASH_BUSTED {}".to_string() + } ); + assert!(parse_packet("REQ 1 PING").is_err()); + assert!(parse_packet("RES nope OK").is_err()); +} + +#[test] +fn io_failures_are_reported_as_short_messages() { + let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow"); + assert_eq!(io_failure_message(&timeout), "ERROR Request timed out"); + + let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone"); + assert_eq!(io_failure_message(&closed), "ERROR Connection lost"); } 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 { } fn hello(core: &mut ServerCore, state: &mut ConnectionState) { - let response = core.handle_message( - state, - &format!("HELLO {}", zagan_bet::version::VERSION), + let response = core.handle_message(state, &format!("HELLO {}", zagan_bet::version::VERSION)); + assert!( + response.starts_with("HELLO "), + "handshake failed: {response}" ); - assert!(response.starts_with("HELLO "), "handshake failed: {response}"); } fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { @@ -258,8 +258,18 @@ fn roulette_settles_split_bets_and_records_details() { let mut bets = Bets::default(); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); - bets.add(BetTarget::Outside { kind: Outside::Black }, 10); - bets.add(BetTarget::Outside { kind: Outside::Even }, 10); + bets.add( + BetTarget::Outside { + kind: Outside::Black, + }, + 10, + ); + bets.add( + BetTarget::Outside { + kind: Outside::Even, + }, + 10, + ); bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); bets.add(BetTarget::Straight { number: 17 }, 5); let bets = bets.to_json(); @@ -671,10 +681,14 @@ fn crash_poll_pushes_the_bust_exactly_once() { let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); clock.advance(1.0); + let ticks = core.poll(&state); + assert_eq!(ticks.len(), 1, "a running round pushes coalesced ticks"); assert!( - core.poll(&state).is_empty(), - "a running round must not push anything" + ticks[0].starts_with("crash.tick session="), + "tick: {}", + ticks[0] ); + assert!(ticks[0].contains(" m="), "tick carries the multiplier"); clock.advance(15.0); let pushed = core.poll(&state); @@ -920,9 +934,10 @@ fn history_lists_recent_plays_newest_first() { login(&mut core, &mut state, "alice"); for _ in 0..3 { - assert!(core - .handle_message(&mut state, "PLAY_START slots 10") - .starts_with("GAME_RESULT")); + assert!( + core.handle_message(&mut state, "PLAY_START slots 10") + .starts_with("GAME_RESULT") + ); } let response = core.handle_message(&mut state, "HISTORY 2"); @@ -979,7 +994,10 @@ fn hello_reports_the_version_and_download_url() { assert!(state.greeted()); // The gate is open now. - assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); + assert!( + core.handle_message(&mut state, "PING").starts_with("PONG "), + "PING must carry the server time" + ); } #[test] @@ -999,7 +1017,10 @@ fn hello_refuses_a_different_major_or_minor() { response.starts_with("ERROR Version mismatch"), "response: {response}" ); - assert!(!state.greeted(), "a refused handshake must not open the gate"); + assert!( + !state.greeted(), + "a refused handshake must not open the gate" + ); assert_eq!( core.handle_message(&mut state, "PING"), "ERROR HELLO required" @@ -1014,9 +1035,10 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); let mut state = ConnectionState::default(); - assert!(core - .handle_message(&mut state, &format!("HELLO {patch}")) - .starts_with("HELLO ")); + assert!( + core.handle_message(&mut state, &format!("HELLO {patch}")) + .starts_with("HELLO ") + ); let mut other = ConnectionState::default(); assert_eq!( @@ -1028,3 +1050,70 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { "ERROR Invalid version" ); } + +#[test] +fn crash_start_commits_to_a_seed_and_the_end_reveals_it() { + use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed}; + + let (mut core, _path, clock) = setup_crash("crash-commit", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + let commit = started.commit.clone().expect("a start reply commits"); + assert_eq!(commit.len(), 64, "sha256 hex"); + assert_eq!(started.seed, None, "the seed stays hidden while running"); + assert_eq!(started.crash_point, None); + assert!(started.started_at > 1_600_000_000_000, "server time anchor"); + + clock.advance(5.0); + let cashed = crash_state( + &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), + ); + let seed = cashed.seed.clone().expect("the end reveals the seed"); + assert!(verify_seed(&seed, &commit), "the reveal matches the commit"); + let bytes = seed_from_hex(&seed).expect("32-byte hex seed"); + let point = crash_point_from_seed(&bytes); + assert!( + (point - cashed.crash_point.unwrap()).abs() < 1e-9, + "the client recomputes the same point" + ); + assert_eq!(cashed.commit.as_deref(), Some(commit.as_str())); +} + +#[test] +fn a_repeated_request_id_replays_its_reply_without_charging_twice() { + use zagan_bet::protocol::Request; + + let (mut core, _path) = setup("idempotent-spin", WIN3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let request = Request { + id: 41, + idem: Some("spin-1".to_string()), + payload: "PLAY_START slots 10".to_string(), + }; + let first = core.handle_request(&mut state, &request); + assert!(first.starts_with("GAME_RESULT"), "first: {first}"); + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 1); + + let replay = core.handle_request(&mut state, &request); + assert_eq!(replay, first, "the cached reply is returned verbatim"); + assert_eq!(core.balance("alice"), Some(1000), "no second debit"); + assert_eq!(core.play_count("alice"), 1, "no second audit row"); + + let different = Request { + id: 41, + idem: Some("spin-2".to_string()), + payload: "PLAY_START slots 20".to_string(), + }; + assert_eq!( + core.handle_request(&mut state, &different), + "ERROR Request id reused with a different command" + ); + assert_eq!(core.balance("alice"), Some(1000)); +} 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 @@ use zagan_bet::version::{ - compatible, hello_line, is_newer, parse_hello, Version, VERSION, + VERSION, Version, compatible, hello_line, is_newer, parse_hello, parse_pong, pong_line, + unix_time_ms, }; #[test] @@ -24,16 +25,16 @@ fn versions_parse_and_reject_nonsense() { Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), Some((4, 0, 0)) ); - assert_eq!( - Version::parse("4.0.0+build7").map(|v| v.patch), - Some(0) - ); + assert_eq!(Version::parse("4.0.0+build7").map(|v| v.patch), Some(0)); for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); } - assert!(Version::parse(VERSION).is_some(), "the build version must parse"); + assert!( + Version::parse(VERSION).is_some(), + "the build version must parse" + ); } #[test] @@ -57,19 +58,45 @@ fn newer_versions_are_ordered_by_major_minor_patch() { #[test] fn the_hello_reply_round_trips() { - let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); + let line = hello_line( + "4.0.0", + "4.1.2", + "https://zagan.example/", + 1_700_000_000_123, + ); let reply = parse_hello(&line).expect("parse a generated reply"); assert_eq!(reply.version, "4.0.0"); assert_eq!(reply.latest, "4.1.2"); assert_eq!(reply.url, "https://zagan.example/"); + assert_eq!(reply.time, 1_700_000_000_123); - assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); - assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); + assert!( + parse_hello("HELLO 4.0.0 url=https://x/ time=1").is_none(), + "latest is required" + ); + assert!( + parse_hello("HELLO 4.0.0 latest=4.0.0 time=1").is_none(), + "url is required" + ); + assert!( + parse_hello("HELLO 4.0.0 latest=4.0.0 url=https://x/").is_none(), + "time is required" + ); assert!(parse_hello("PONG").is_none()); assert!(parse_hello("HELLO").is_none()); } +#[test] +fn pong_carries_the_server_time() { + let line = pong_line(1_700_000_000_456); + assert_eq!(line, "PONG 1700000000456"); + assert_eq!(parse_pong(&line), Some(1_700_000_000_456)); + assert!(parse_pong("PONG").is_none()); + assert!(parse_pong("RES 1 PONG 1700000000456").is_none()); + assert!(unix_time_ms() > 1_600_000_000_000); +} + /// The GUI is a client, so it must ship in lockstep with the TUI and server. /// Keep `gui/Cargo.toml` at the same version as the root package. #[test] @@ -83,6 +110,18 @@ fn the_gui_member_uses_the_same_version() { assert_eq!(gui_version, VERSION); } +/// The gateway is a frontend too; keep it in lockstep with the core. +#[test] +fn the_webd_member_uses_the_same_version() { + let manifest = include_str!("../webd/Cargo.toml"); + let version = manifest + .lines() + .find_map(|line| line.trim().strip_prefix("version = ")) + .expect("webd/Cargo.toml declares a version") + .trim_matches('"'); + assert_eq!(version, VERSION); +} + /// egui's dependency tree must never enter the TUI or server build graph. /// The GUI is a workspace member that depends on this package, never the /// 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 @@ +//! The browser and gateway exchange serde JSON for `Command`/`View`. These +//! tests pin the wire shape the TypeScript app renders. + +use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::client::view::{ + BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView, + SlotsPhase, SlotsView, View, +}; +use zagan_bet::roulette::{BetTarget, Bets, Outside}; + +#[test] +fn browser_commands_deserialize() { + let login: Command = + serde_json::from_str(r#"{"Login":{"user":"alice","credential":"abc"}}"#).unwrap(); + assert_eq!( + login, + Command::Login { + user: "alice".to_string(), + credential: "abc".to_string() + } + ); + + let play: Command = serde_json::from_str(r#"{"Play":{"game":"crash","bet":25}}"#).unwrap(); + assert_eq!( + play, + Command::Play { + game: "crash".to_string(), + bet: Some(25) + } + ); + + let bet: Command = + serde_json::from_str(r#"{"PlaceBetAt":{"bet":"outside","kind":"red"}}"#).unwrap(); + assert_eq!( + bet, + Command::PlaceBetAt(BetTarget::Outside { kind: Outside::Red }) + ); + + let split: Command = + serde_json::from_str(r#"{"PlaceBetAt":{"bet":"split","a":1,"b":2}}"#).unwrap(); + assert_eq!(split, Command::PlaceBetAt(BetTarget::Split { a: 1, b: 2 })); + + assert_eq!( + serde_json::from_str::(r#""Spin""#).unwrap(), + Command::Spin + ); + assert_eq!( + serde_json::from_str::(r#""ShowLobby""#).unwrap(), + Command::ShowLobby + ); + assert_eq!( + serde_json::from_str::(r#"{"SetBet":50}"#).unwrap(), + Command::SetBet(50) + ); + assert_eq!( + serde_json::from_str::(r#"{"MoveCursor":"Left"}"#).unwrap(), + Command::MoveCursor(Direction::Left) + ); +} + +#[test] +fn views_serialize_with_a_screen_tag() { + let lobby = View::Lobby(LobbyView { + connected: true, + user: Some("alice".to_string()), + account: Some("1".to_string()), + balance: Some(1000), + games: vec![("slots".to_string(), "Slots".to_string())], + login_error: None, + }); + let json = serde_json::to_value(&lobby).unwrap(); + assert_eq!(json["screen"], "lobby"); + assert_eq!(json["user"], "alice"); + + let slots = View::Slots(SlotsView { + bet: 10, + balance: Some(990), + reels: "CLOGD".chars().collect(), + win_cells: vec![0], + win_lines: vec![1], + win_type: "win".to_string(), + winnings: Some(20), + phase: SlotsPhase::Result, + error: None, + }); + let json = serde_json::to_value(&slots).unwrap(); + assert_eq!(json["screen"], "slots"); + assert_eq!(json["phase"], "result"); + assert_eq!(json["reels"][0], "C"); + + let roulette = View::Roulette(RouletteView { + bet_amount: 10, + balance: Some(1000), + chip: 25, + bets: Bets::default(), + cursor: Cursor::default(), + phase: RoulettePhase::Placing, + number: None, + color: None, + winnings: None, + error: None, + }); + let json = serde_json::to_value(&roulette).unwrap(); + assert_eq!(json["screen"], "roulette"); + assert_eq!(json["phase"], "placing"); + + let blackjack = View::Blackjack(BlackjackView { + status: "ready".to_string(), + balance: Some(1000), + bet: 10, + hand: None, + error: None, + }); + assert_eq!( + serde_json::to_value(&blackjack).unwrap()["screen"], + "blackjack" + ); + + let crash = View::Crash(CrashView { + status: "running".to_string(), + balance: Some(990), + bet: 10, + multiplier: 1.5, + potential: 15, + crash_point: None, + payout: 0, + started_at: 1_700_000_000_000, + commit: Some("ab".repeat(32)), + seed: None, + error: None, + }); + let json = serde_json::to_value(&crash).unwrap(); + assert_eq!(json["screen"], "crash"); + assert_eq!(json["commit"], "ab".repeat(32)); + + let history = View::History(HistoryView { records: vec![] }); + assert_eq!(serde_json::to_value(&history).unwrap()["screen"], "history"); +} 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/ releases.php shared scanner and hash cache install.sh stable Linux installer install.ps1 stable Windows installer + play/ browser app, served at /play/ bin/gui/ apps, served at /bin/gui/... bin/client/ terminal clients bin/server/ servers @@ -20,6 +21,26 @@ web/ Serve `web/` as the document root. Binaries in `bin/` are gitignored. +## Browser app + +`play/` is a static vanilla TypeScript app. It talks to the WebSocket gateway +in `webd/`, not to the game server: the browser sends `Command` JSON and renders +`View` JSON, and the gateway owns the upstream TLS connection and the pinned +server key. nginx serves `/play/` and proxies `/ws` to `127.0.0.1:8890`. + +Build it before publishing: + +```sh +cd web/play +npm ci +npm run build # writes app.js +cd ../.. +``` + +Commit the generated `app.js`; the site then needs no Node at deploy time. +`npm run typecheck` checks the sources. The gateway has its own deployment +notes in `webd/README.md`; it is not part of the player download. + ## Naming The page only lists files that match the release pattern; anything else in @@ -132,9 +153,9 @@ Then open . `deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a half-written binary, then fetches the live page and every artifact it lists — GUI apps, installers, terminal clients and servers — and compares the -downloaded bytes with the local SHA-256. `--verify-only` skips the -upload and only checks the live site. Old versions are left on the server; -prune them by hand. +downloaded bytes with the local SHA-256. It also fetches `/play/`, `app.js` and +`style.css`. `--verify-only` skips the upload and only checks the live site. +Old versions are left on the server; prune them by hand. A local directory works as a target, which is how the verification itself is tested: 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

żagań.bet

+

Play in your browser — no install, just an account. Play-money only.

Official desktop app, terminal client and server builds.

0): ?>

Builds published UTC.

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 { add_header Cache-Control "public, max-age=31536000, immutable"; } + # The browser app: static files, no framework runtime. + location = /play { + return 301 /play/; + } + + location /play/ { + try_files $uri $uri/ /play/index.html; + add_header Cache-Control "no-cache"; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + } + + # WebSocket gateway. Keep the upgrade headers and a long read timeout: + # a crash round can idle for a while between pushes. + location = /ws { + proxy_pass http://127.0.0.1:8890; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + # The page rescans bin/ on every request, so don't cache it. location = /index.php { 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 @@ +node_modules/ +app.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 @@ +# `web/play` — browser frontend + +Vanilla TypeScript + esbuild. The browser sends `Command` JSON and renders +`View` JSON; it contains no game rules, no payout math and no wire protocol. +The gateway in `webd/` owns the upstream TLS connection. + +## Build + +```sh +npm ci +npm run build # writes app.js +npm run typecheck +npm test # headless DOM smoke test (jsdom) +``` + +Commit the generated `app.js` so the site deploys without Node. + +## Local development + +Serve the repository's `web/` directory (for `/play/`) and put the gateway +behind the same origin at `/ws`. The simplest local setup is nginx with the +`web/nginx.conf.example` block pointed at a staging root and +`ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899`. For the app alone: + +```sh +python3 -m http.server 8900 -d web/play +``` + +It will load, but the WebSocket will only connect through nginx or another +proxy that routes `/ws` to `webd`. + +## Screens + +- Auth: login/register. The password is hashed with Web Crypto (`SHA-256`) + before it leaves the page; the gateway and browser never store it. +- Lobby: four games, per-game bet, history. +- Slots: five reels, win cells and paylines. +- Roulette: canvas felt with straight, dozen, column and outside chips; + left click adds, right click removes. +- Blackjack: dealer/player cards, hit/stand/double/forfeit. +- Crash: curve, live multiplier, cash-out, commit/seed verification. +- History: settled plays with net result. + +## Theme + +`style.css` mirrors the palette in `gui/src/theme.rs`: near-black ground, +cyan focus, green for wins, gold for chips, felt green only for roulette. +Do not introduce a second palette or hard-code colours in the render modules. + +## Security + +- The session cookie is HttpOnly and SameSite=Strict; JavaScript never reads + it and never stores a credential. +- Notices are inserted with `textContent`, not HTML. +- The app is play-money only. Do not add deposits, withdrawals, KYC or + 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;a0&&(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;uu.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=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()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=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 @@ +import { build, context } from "esbuild"; + +const watch = process.argv.includes("--watch"); + +const options = { + entryPoints: ["src/main.ts"], + bundle: true, + outfile: "app.js", + format: "iife", + target: ["es2020"], + minify: !watch, + sourcemap: watch ? "inline" : false, + charset: "utf8", + logLevel: "info", +}; + +if (watch) { + const ctx = await context(options); + await ctx.watch(); +} else { + await build(options); +} 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 @@ + + + + + +Play — żagań.bet + + + + + + +
+
+ żagań.bet + Play credits · no real money + + +
+ +
+ + + + + + + +
+
+ +
+ + + + + + + + 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 @@ +{ + "name": "zagan-bet-play", + "version": "5.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zagan-bet-play", + "version": "5.0.0", + "devDependencies": { + "esbuild": "^0.28.0", + "jsdom": "^30.1.3", + "typescript": "^5.9.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} 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 @@ +{ + "name": "zagan-bet-play", + "private": true, + "version": "5.0.0", + "type": "module", + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "node test/smoke.mjs" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "jsdom": "^30.1.3", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=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 @@ +import type { View } from "./types"; +import { cardFace, clear, money, node } from "./dom"; + +function cardElement(card: string): HTMLElement { + if (card === "?") { + const back = node("div", "card back"); + back.append(node("span", "rank", "?"), node("span", "suit", "")); + return back; + } + const face = cardFace(card); + const element = node("div", "card"); + if (face.red) element.classList.add("red"); + element.append(node("span", "rank", face.rank), node("span", "suit", face.suit)); + return element; +} + +function handBlock(label: string, cards: string[], total: number): HTMLElement { + const block = node("div", "hand"); + block.append(node("div", "label", label)); + const row = node("div", "cards"); + for (const card of cards) row.append(cardElement(card)); + const sum = node("div", "total"); + sum.append(document.createTextNode("Total "), node("strong", undefined, String(total))); + block.append(row, sum); + return block; +} + +function outcome(view: View, hand: any): string { + const result = String(hand?.result ?? ""); + const winnings = Number(hand?.winnings ?? 0); + switch (result) { + case "blackjack": + return `Blackjack — won ${money(winnings)}`; + case "win": + return `You win — ${money(winnings)}`; + case "push": + return `Push — ${money(winnings)} 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 view.status === "player" ? "Your move." : "Dealing…"; + default: + return "Ready to deal."; + } +} + +export function renderBlackjack( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const hand = view.hand; + const playerPhase = hand?.phase === "player" || view.status === "player"; + const done = hand?.phase === "done" || view.status === "done"; + const canDeal = !hand || done; + + board.append(node("h2", undefined, "Blackjack")); + board.append( + node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."), + ); + + if (hand) { + board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); + board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); + } else { + const empty = node("div", "outcome"); + empty.textContent = "Press DEAL to start a hand."; + board.append(empty); + } + + if (done || (hand && !playerPhase)) { + const result = node("div", "outcome"); + result.textContent = outcome(view, hand); + if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { + result.classList.add("win-text"); + } + board.append(result); + } + + if (view.error) board.append(node("p", "error-text", String(view.error))); + + // Sidebar + side.append(node("div", "label", "Blackjack")); + const bet = Number(hand?.bet ?? view.bet ?? 0); + const balance = view.balance ?? view.hand?.balance; + side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); + + if (canDeal) { + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const amount = node("strong", "mono", money(bet)); + const plus = node("button", "btn small", "+10"); + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, amount, plus); + side.append(node("div", "label", "Bet"), betRow); + } else { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet))); + side.append(stat); + } + + if (balance !== undefined) { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance)))); + side.append(stat); + } + + const actions = node("div", "actions"); + if (playerPhase) { + const actionsRow = node("div", "row wrap"); + for (const action of hand?.actions ?? []) { + const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE"; + const button = + action === "stand" + ? node("button", "btn primary", label) + : node("button", "btn", label); + button.addEventListener("click", () => { + if (action === "hit") send("Hit"); + else if (action === "stand") send("Stand"); + else send("Double"); + }); + actionsRow.append(button); + } + actions.append(actionsRow); + const forfeit = node("button", "btn danger wide", "Forfeit hand"); + forfeit.addEventListener("click", () => { + if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); + }); + actions.append(forfeit); + } else if (canDeal) { + const deal = node("button", "btn primary wide big", "DEAL"); + deal.addEventListener("click", () => send("Deal")); + actions.append(deal); + } + + const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table"); + leave.addEventListener("click", () => { + if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return; + send("Leave"); + }); + actions.append(leave); + side.append(actions); +} 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 @@ +import type { View } from "./types"; +import { clear, money, node, shortHash } from "./dom"; + +const GROWTH = Math.LN2 / 5; + +type Clock = { now: () => number }; + +type Parts = { + board: HTMLElement; + side: HTMLElement; + number: HTMLElement; + meta: HTMLElement; + verify: HTMLElement; + canvas: HTMLCanvasElement; + bet: HTMLElement; + potential: HTMLElement; + balance: HTMLElement; + status: HTMLElement; + lastCrash: HTMLElement; + error: HTMLElement; + primary: HTMLButtonElement; + leave: HTMLButtonElement; + minus: HTMLButtonElement; + plus: HTMLButtonElement; +}; + +let parts: Parts | null = null; +let current: View | null = null; +let clock: Clock | null = null; +let sendCommand: ((command: unknown) => void) | null = null; +let frameHandle: number | undefined; +let verifyToken = 0; +let lastStatus = ""; + +function build( + board: HTMLElement, + side: HTMLElement, + send: (command: unknown) => void, + now: Clock, +): Parts { + clear(board); + clear(side); + sendCommand = send; + clock = now; + + board.append(node("h2", undefined, "Crash")); + board.append( + node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), + ); + + const wrap = node("div", "crash-wrap"); + const number = node("div", "crash-number", "1.00×"); + const meta = node("div", "crash-meta"); + const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; + const verify = node("div", "verify", "Waiting for a round commitment."); + wrap.append(number, meta, canvas, verify); + board.append(wrap); + + side.append(node("div", "label", "Crash")); + side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10") as HTMLButtonElement; + const bet = node("strong", "mono", "—"); + const plus = node("button", "btn small", "+10") as HTMLButtonElement; + minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); + plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + const potential = node("strong", "mono", "—"); + const balance = node("strong", "mono", "—"); + for (const [label, value] of [ + ["Potential", potential], + ["Balance", balance], + ] as const) { + const row = node("div", "stat"); + row.append(node("span", "muted", label), value); + side.append(row); + } + + const status = node("p", "note", "Ready."); + const lastCrash = node("p", "note", ""); + const error = node("p", "error-text"); + side.append(status, lastCrash, error); + + const actions = node("div", "actions"); + const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; + const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; + primary.addEventListener("click", () => { + if (current?.status === "running") sendCommand?.("CashOut"); + else sendCommand?.("Start"); + }); + leave.addEventListener("click", () => { + if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return; + sendCommand?.("Leave"); + }); + actions.append(primary, leave); + side.append(actions); + + return { + board, + side, + number, + meta, + verify, + canvas, + bet, + potential, + balance, + status, + lastCrash, + error, + primary, + leave, + minus, + plus, + }; +} + +export function renderCrash( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, + now: Clock, +): void { + if (!parts || parts.board !== board) { + parts = build(board, side, send, now); + lastStatus = ""; + } + const p = parts; + sendCommand = send; + clock = now; + current = view; + + if (view.status !== lastStatus) { + if (view.status === "starting" || view.status === "running") { + if (lastStatus !== "running") { + p.verify.className = "verify"; + p.verify.textContent = "Round committed; seed hidden until the round ends."; + } + } + lastStatus = String(view.status); + } + + const running = view.status === "running"; + const starting = view.status === "starting"; + const live = running || starting; + + p.bet.textContent = money(Number(view.bet ?? 0)); + p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0)); + p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance)); + p.error.textContent = view.error ? String(view.error) : ""; + + p.minus.disabled = live; + p.plus.disabled = live; + p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START"; + p.primary.disabled = starting; + p.primary.className = running ? "btn primary wide big" : "btn primary wide big"; + p.leave.textContent = running ? "Forfeit round" : "Leave table"; + + if (running) { + p.status.textContent = "Rising — cash out before the crash."; + p.status.className = "note win-text"; + } else if (starting) { + p.status.textContent = "Starting the round…"; + p.status.className = "note"; + } else if (view.status === "cashed") { + p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`; + p.status.className = "note win-text"; + } else if (view.status === "crashed") { + p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`; + p.status.className = "note error-text"; + } else if (view.status === "forfeit") { + p.status.textContent = "Round forfeited."; + p.status.className = "note error-text"; + } else { + p.status.textContent = "Ready. Start a round when you want."; + p.status.className = "note"; + } + + if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") { + p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`; + } + + const commit = view.commit ? String(view.commit) : ""; + p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; + + if (view.seed && commit) { + const token = ++verifyToken; + const seed = String(view.seed); + const crashPoint = Number(view.crash_point ?? 0); + void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => { + if (token !== verifyToken || !parts) return; + const ok = commitOk && pointOk; + parts.verify.className = `verify ${ok ? "ok" : "bad"}`; + parts.verify.textContent = ok + ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed` + : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`; + }); + } else if (!live && !commit) { + p.verify.className = "verify"; + p.verify.textContent = "Waiting for a round commitment."; + } + + if (frameHandle !== undefined) { + cancelAnimationFrame(frameHandle); + frameHandle = undefined; + } + if (running) { + const loop = () => { + if (!parts || current?.status !== "running") { + frameHandle = undefined; + return; + } + update(parts, current, now); + frameHandle = requestAnimationFrame(loop); + }; + frameHandle = requestAnimationFrame(loop); + } else { + update(p, view, now); + } +} + +function currentMultiplier(view: View, now: Clock): number { + if (view.status === "running") { + const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000); + const local = Math.min(1000, Math.exp(GROWTH * elapsed)); + return Math.max(local, Number(view.multiplier ?? 1)); + } + if (view.status === "cashed") return Number(view.multiplier ?? 1); + if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1); + return 1; +} + +function update(p: Parts, view: View, now: Clock): void { + const multiplier = currentMultiplier(view, now); + p.number.textContent = `${multiplier.toFixed(2)}×`; + p.number.className = `crash-number ${ + view.status === "running" + ? "running" + : view.status === "cashed" + ? "cashed" + : view.status === "crashed" + ? "crashed" + : "" + }`; + p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier)); + drawChart(p.canvas, view, multiplier, now); +} + +function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void { + const width = canvas.clientWidth || 600; + const height = canvas.clientHeight || 360; + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const padL = 54; + const padR = 16; + const padT = 18; + const padB = 34; + const plotW = Math.max(1, width - padL - padR); + const plotH = Math.max(1, height - padT - padB); + + const elapsed = + view.status === "running" + ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000) + : multiplier > 1 + ? Math.log(multiplier) / GROWTH + : 1; + const xMax = Math.max(5, elapsed * 1.08); + const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2); + const x = (seconds: number) => padL + (seconds / xMax) * plotW; + const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH; + + ctx.strokeStyle = "#242e38"; + ctx.fillStyle = "#8a98a6"; + ctx.font = "11px ui-monospace, monospace"; + ctx.lineWidth = 1; + for (let step = 0; step <= 4; step += 1) { + const value = 1 + ((yMax - 1) * step) / 4; + const py = y(value); + ctx.beginPath(); + ctx.moveTo(padL, py); + ctx.lineTo(width - padR, py); + ctx.stroke(); + ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); + } + for (let step = 0; step <= 4; step += 1) { + const seconds = (xMax * step) / 4; + const px = x(seconds); + ctx.beginPath(); + ctx.moveTo(px, padT); + ctx.lineTo(px, padT + plotH); + ctx.strokeStyle = "#1b232c"; + ctx.stroke(); + ctx.fillStyle = "#5b6773"; + ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); + } + + const line = + view.status === "crashed" + ? "#e05a5a" + : view.status === "cashed" + ? "#4ec27b" + : "#e0b64a"; + + ctx.beginPath(); + const samples = 180; + for (let index = 0; index <= samples; index += 1) { + const seconds = (elapsed * index) / samples; + const value = Math.min(yMax, Math.exp(GROWTH * seconds)); + const px = x(seconds); + const py = y(value); + if (index === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.strokeStyle = line; + ctx.lineWidth = 2.4; + ctx.stroke(); + + const endX = x(elapsed); + const endY = y(Math.min(yMax, multiplier)); + ctx.beginPath(); + ctx.arc(endX, endY, 4.5, 0, Math.PI * 2); + ctx.fillStyle = line; + ctx.fill(); + + ctx.fillStyle = line; + ctx.font = "600 12px ui-monospace, monospace"; + ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10)); +} + +async function verifyReveal( + seed: string, + commit: string, + crashPoint: number, +): Promise<[boolean, boolean]> { + const bytes = new Uint8Array(seed.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16); + } + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const commitOk = actual === commit.toLowerCase(); + + let uniform = 0; + for (let index = 0; index < 8; index += 1) { + uniform = uniform * 256 + bytes[index]; + } + uniform /= 2 ** 64; + const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform))); + const pointOk = Math.abs(point - crashPoint) < 1e-6; + return [commitOk, pointOk]; +} 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 @@ +export function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; +} + +export function node( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +export function clear(element: HTMLElement): void { + element.replaceChildren(); +} + +export function groupDigits(amount: number): string { + const negative = amount < 0; + const digits = String(Math.abs(Math.trunc(amount))); + let grouped = ""; + for (let index = 0; index < digits.length; index += 1) { + if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009"; + grouped += digits[index]; + } + return negative ? `-${grouped}` : grouped; +} + +export function money(amount: number | null | undefined): string { + if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr"; + return `${groupDigits(amount)} Kr`; +} + +export function signedMoney(amount: number): string { + return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; +} + +export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void { + const root = document.getElementById("toasts"); + if (!root || !text) return; + const item = node("div", `toast ${tone}`, text); + root.append(item); + window.setTimeout(() => { + item.style.opacity = "0"; + item.style.transition = "opacity 220ms ease"; + window.setTimeout(() => item.remove(), 240); + }, 4200); + while (root.children.length > 4) root.firstElementChild?.remove(); +} + +export function toneFor(text: string): "info" | "good" | "bad" { + const lowered = text.toLowerCase(); + if ( + text.startsWith("ERROR") || + lowered.includes("failed") || + lowered.includes("lost") || + lowered.includes("invalid") || + lowered.includes("forfeit") + ) { + return "bad"; + } + if ( + text.startsWith("Logged in") || + text.startsWith("Account created") || + lowered.includes("won") || + lowered.includes("cashed") + ) { + return "good"; + } + return "info"; +} + +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function shortHash(hash: string, keep = 10): string { + if (hash.length <= keep * 2) return hash; + return `${hash.slice(0, keep)}…${hash.slice(-keep)}`; +} + +export function formatTime(raw: string): string { + // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC. + const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z"); + if (Number.isNaN(parsed)) return raw; + return new Date(parsed).toLocaleString(); +} + +export function cardFace(card: string): { rank: string; suit: string; red: boolean } { + if (card === "?" || !card) return { rank: "?", suit: "", red: false }; + const suit = card.slice(-1); + const rank = card.slice(0, -1); + const glyph = + suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit; + return { rank, suit: glyph, red: suit === "D" || suit === "H" }; +} 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 @@ +import type { View } from "./types"; +import { clear, formatTime, money, node, signedMoney } from "./dom"; + +function summary(record: any): string { + const details = record.details ?? {}; + switch (record.game) { + case "slots": + return String(details.win_type ?? "spin"); + case "roulette": + return `${details.number ?? "?"} ${details.color ?? ""}`.trim(); + case "blackjack": + return String(details.result ?? ""); + case "crash": { + const result = String(details.result ?? ""); + const point = details.crash_point; + return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`; + } + default: + return ""; + } +} + +export function renderHistory(root: HTMLElement, view: View): void { + clear(root); + const records: any[] = view.records ?? []; + if (records.length === 0) { + const empty = node("div", "board"); + empty.append( + node("h2", undefined, "No plays yet"), + node("p", "muted", "Your settled plays will appear here."), + ); + root.append(empty); + return; + } + + const wrap = node("div", "table-wrap"); + const table = node("table"); + const head = node("thead"); + const headRow = node("tr"); + for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { + headRow.append(node("th", undefined, label)); + } + head.append(headRow); + const body = node("tbody"); + for (const record of records) { + const row = node("tr"); + row.append(node("td", "mono", formatTime(String(record.at ?? "")))); + row.append(node("td", undefined, String(record.game ?? ""))); + row.append(node("td", "num", money(Number(record.bet ?? 0)))); + row.append(node("td", "muted", summary(record))); + const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); + const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net)); + row.append(netCell); + row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); + body.append(row); + } + table.append(head, body); + wrap.append(table); + root.append(wrap); +} 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 @@ +import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; +import { Gateway } from "./net"; +import { renderBlackjack } from "./blackjack"; +import { renderCrash } from "./crash"; +import { renderHistory } from "./history"; +import { renderRoulette } from "./roulette"; +import { renderSlots } from "./slots"; +import type { View } from "./types"; + +const authScreen = byId("auth-screen"); +const lobbyScreen = byId("lobby-screen"); +const gameScreen = byId("game-screen"); +const historyScreen = byId("history-screen"); +const sessionBar = byId("session-bar"); +const accountName = byId("account-name"); +const accountBalance = byId("account-balance"); +const gameGrid = byId("game-grid"); +const gameBoard = byId("game-board"); +const gameSide = byId("game-side"); +const gameTitle = byId("game-title"); +const gameBadge = byId("game-badge"); +const historyBody = byId("history-body"); +const authForm = byId("auth-form"); +const authUser = byId("auth-user"); +const authPass = byId("auth-pass"); +const authError = byId("auth-error"); +const authSubmit = byId("auth-submit"); +const tabLogin = byId("tab-login"); +const tabRegister = byId("tab-register"); +const reconnectOverlay = byId("reconnect-overlay"); +const reconnectMessage = byId("reconnect-message"); +const replacedOverlay = byId("replaced-overlay"); + +let view: View | null = null; +let currentUser: string | null = null; +let lastBalance: number | null = null; +let authMode: "login" | "register" = "login"; +let infoFor: string | null = null; +let serverOffset = 0; +let reconnectAttempts = 0; +const bets = new Map([ + ["slots", 10], + ["roulette", 10], + ["blackjack", 10], + ["crash", 10], +]); + +const titles: Record = { + slots: "Slots", + roulette: "Roulette", + blackjack: "Blackjack", + crash: "Crash", +}; + +const descriptions: Record = { + 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.", +}; + +const basePath = location.pathname.includes("/play/") + ? location.pathname.slice(0, location.pathname.indexOf("/play/")) + : ""; +const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; + +const gateway = new Gateway(wsUrl, { + onHello(hello) { + serverOffset = hello.time - Date.now(); + currentUser = hello.user ?? null; + gameBadge.textContent = `v${hello.version}`; + }, + onView(next) { + view = next; + // Game and history views carry no account field; the lobby is the source + // of truth for who is signed in. A null lobby user is a logout or an + // expired session. + if (typeof next.balance === "number") lastBalance = next.balance; + if (next.screen === "lobby" && "user" in next) { + currentUser = (next.user as string | null) ?? null; + if (currentUser) authPass.value = ""; + else lastBalance = null; + } + reconnectAttempts = 0; + reconnectOverlay.hidden = true; + render(); + if (currentUser && view.balance === null && infoFor !== currentUser) { + infoFor = currentUser; + gateway.send("Info"); + } + if (!currentUser) infoFor = null; + }, + onNotice(text) { + if ( + text.startsWith("Connected to:") || + text.startsWith("Fingerprint:") || + text.startsWith("User:") || + text.startsWith("Account:") || + text.startsWith("Balance:") + ) { + return; + } + toast(text, toneFor(text)); + if (text.startsWith("Account created")) { + setAuthMode("login"); + authPass.value = ""; + authUser.focus(); + } + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onError(text) { + toast(text, "bad"); + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onReplaced() { + replacedOverlay.hidden = false; + }, + onPong() { + /* The gateway answered; the socket is alive. */ + }, + onOpen() { + reconnectOverlay.hidden = true; + }, + onClosed(willRetry) { + if (replacedOverlay.hidden === false) return; + if (!willRetry) { + reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; + reconnectOverlay.hidden = false; + return; + } + reconnectAttempts += 1; + reconnectMessage.textContent = + reconnectAttempts > 1 + ? "Still trying to reach the game server…" + : "Trying to reach the game server…"; + reconnectOverlay.hidden = false; + }, +}); + +function send(command: unknown): void { + gateway.send(command); +} + +function serverNow(): number { + return Date.now() + serverOffset; +} + +function setAuthMode(mode: "login" | "register"): void { + authMode = mode; + tabLogin.classList.toggle("active", mode === "login"); + tabRegister.classList.toggle("active", mode === "register"); + authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; + authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; + authError.textContent = ""; +} + +function show(screen: "auth" | "lobby" | "game" | "history"): void { + authScreen.hidden = screen !== "auth"; + lobbyScreen.hidden = screen !== "lobby"; + gameScreen.hidden = screen !== "game"; + historyScreen.hidden = screen !== "history"; +} + +function setBalance(balance: number | null | undefined): void { + accountBalance.textContent = money(balance ?? null); +} + +function render(): void { + if (!view) return; + const user = currentUser; + if (user) { + sessionBar.hidden = false; + accountName.textContent = String(user); + } else { + sessionBar.hidden = true; + } + + setBalance(lastBalance); + + if (!user) { + show("auth"); + authError.textContent = view.login_error ? String(view.login_error) : ""; + if (view.screen === "lobby" && view.connected === false) { + authError.textContent = "The game server is not reachable yet. Retrying…"; + } + return; + } + + switch (view.screen) { + case "lobby": + show("lobby"); + renderLobby(view); + break; + case "history": + show("history"); + renderHistory(historyBody, view); + break; + case "slots": + case "roulette": + case "blackjack": + case "crash": + show("game"); + gameTitle.textContent = titles[view.screen] ?? view.screen; + gameBadge.textContent = badge(view); + if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); + else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); + else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); + else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); + break; + default: + show("auth"); + } +} + +function badge(view: View): string { + if (view.screen === "crash") return String(view.status ?? ""); + if (view.screen === "blackjack") return String(view.status ?? ""); + if (view.screen === "slots") return String(view.phase ?? ""); + if (view.screen === "roulette") return String(view.phase ?? ""); + return ""; +} + +function renderLobby(lobby: View): void { + gameGrid.replaceChildren(); + const list: [string, string][] = lobby.games ?? []; + for (const [id, name] of list) { + const card = node("article", `game-card ${id}`); + card.append(node("h3", undefined, name || titles[id] || id)); + card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); + + const row = node("div", "row wrap"); + const label = node("label", "field"); + label.style.flex = "1"; + label.append(node("span", "label", "Bet")); + const input = node("input", "input bet-input") as HTMLInputElement; + input.type = "number"; + input.min = "1"; + input.step = "10"; + input.value = String(bets.get(id) ?? 10); + input.addEventListener("change", () => { + const value = Math.max(1, Math.floor(Number(input.value) || 1)); + input.value = String(value); + bets.set(id, value); + }); + label.append(input); + + const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); + play.addEventListener("click", () => { + const bet = Math.max(1, Math.floor(Number(input.value) || 1)); + bets.set(id, bet); + send({ Play: { game: id, bet } }); + }); + row.append(label, play); + card.append(row); + gameGrid.append(card); + } + + if (list.length === 0) { + gameGrid.append(node("p", "muted", "The server did not advertise any games.")); + } +} + +// Auth tabs and form ------------------------------------------------------- +tabLogin.addEventListener("click", () => setAuthMode("login")); +tabRegister.addEventListener("click", () => setAuthMode("register")); + +authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const user = authUser.value.trim(); + const password = authPass.value; + if (!user || !password) { + authError.textContent = "Enter a username and a password."; + return; + } + authSubmit.disabled = true; + authSubmit.textContent = "Working…"; + authError.textContent = ""; + try { + const credential = await sha256Hex(password); + if (authMode === "login") { + send({ Login: { user, credential } }); + } else { + send({ Register: { user, credential } }); + } + } catch { + authError.textContent = "This browser cannot hash the password (needs a secure context)."; + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + } +}); + +// Chrome and navigation ---------------------------------------------------- +byId("logout").addEventListener("click", () => send("Logout")); +byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); +byId("refresh-lobby").addEventListener("click", () => send("Info")); +byId("history-back").addEventListener("click", () => send("ShowLobby")); +byId("leave-game").addEventListener("click", () => { + if (!view) return; + const live = + (view.screen === "blackjack" && view.status === "player") || + (view.screen === "crash" && view.status === "running"); + if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; + send("Leave"); +}); +byId("reload-replaced").addEventListener("click", () => location.reload()); +byId("reload-reconnect").addEventListener("click", () => location.reload()); + +window.addEventListener("resize", () => { + window.clearTimeout((window as any).__resizeTimer); + (window as any).__resizeTimer = window.setTimeout(() => { + if (view) render(); + }, 140); +}); + +// A periodic ping keeps the gateway session visibly alive and refreshes the +// server clock estimate without changing game state. +window.setInterval(() => { + if (currentUser) gateway.ping(); +}, 25_000); + +gateway.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 @@ +import type { Handlers } from "./types"; + +const MAX_ATTEMPTS = 6; + +/** + * Browser side of the gateway socket. + * + * The cookie is HttpOnly and handled by the browser; this class never reads or + * stores a credential. A transient close reconnects inside the gateway's grace + * window; a deliberate takeover stops for good. + */ +export class Gateway { + private socket: WebSocket | null = null; + private stopped = false; + private replaced = false; + private attempts = 0; + private timer: number | undefined; + + constructor( + private readonly url: string, + private readonly handlers: Handlers, + ) {} + + connect(): void { + if (this.stopped) return; + this.clearTimer(); + + let socket: WebSocket; + try { + socket = new WebSocket(this.url); + } catch { + const retrying = this.scheduleReconnect(); + if (!retrying) this.handlers.onClosed(false); + return; + } + this.socket = socket; + + socket.addEventListener("open", () => { + this.attempts = 0; + this.handlers.onOpen(); + }); + + socket.addEventListener("message", (event) => { + let message: any; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + switch (message.type) { + case "hello": + this.handlers.onHello(message); + break; + case "view": + this.handlers.onView(message.view); + break; + case "notice": + this.handlers.onNotice(String(message.text ?? "")); + break; + case "error": + this.handlers.onError(String(message.text ?? "")); + break; + case "replaced": + this.replaced = true; + this.handlers.onReplaced(); + break; + case "pong": + this.handlers.onPong(); + break; + } + }); + + socket.addEventListener("close", () => { + const wasThisSocket = this.socket === socket; + if (wasThisSocket) this.socket = null; + if (this.replaced || this.stopped) { + this.handlers.onClosed(false); + return; + } + const retrying = this.scheduleReconnect(); + this.handlers.onClosed(retrying); + }); + + socket.addEventListener("error", () => { + // The close event follows; reconnect is scheduled there. + }); + } + + send(command: unknown): void { + if (this.socket?.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ type: "command", command })); + } + + ping(): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: "ping" })); + } + } + + close(): void { + this.stopped = true; + this.clearTimer(); + this.socket?.close(); + } + + private scheduleReconnect(): boolean { + if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false; + this.attempts += 1; + const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000); + this.timer = window.setTimeout(() => this.connect(), delay); + return true; + } + + private clearTimer(): void { + if (this.timer !== undefined) { + window.clearTimeout(this.timer); + this.timer = undefined; + } + } +} 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 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +type Zone = { + x: number; + y: number; + w: number; + h: number; + target: any; + label: string; + kind: "number" | "zero" | "dozen" | "column" | "outside"; + number?: number; +}; + +type Hotspot = { + x: number; + y: number; + radius: number; + target: any; + rank: number; +}; + +type Layout = { + width: number; + height: number; + cellW: number; + cellH: number; + zeroX: number; + zeroW: number; + gridX: number; + gridY: number; + gridW: number; + columnX: number; + dozensY: number; + outsideY: number; + outsideH: number; + zones: Zone[]; + hotspots: Hotspot[]; +}; + +const RED = "#b0313a"; +const BLACK = "#151b21"; +const ZERO = "#106b45"; +const FELT = "#0d5c39"; +const FELT_DARK = "#09472c"; +const FELT_LINE = "#1f7d52"; +const TEXT = "#ecf7f0"; +const GOLD = "#e0b64a"; + +function colorOf(number: number): string { + if (number === 0) return "green"; + const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; + return reds.includes(number) ? "red" : "black"; +} + +function layout(width: number, height: number): Layout { + const pad = 14; + const zeroW = Math.max(38, width * 0.055); + const columnW = Math.max(34, width * 0.05); + const outsideH = Math.max(30, height * 0.07); + const gap = 6; + const gridW = width - pad * 2 - zeroW - columnW; + const cellW = gridW / 12; + const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; + const gridX = pad + zeroW; + const gridY = pad; + const dozensY = gridY + cellH * 3 + gap; + const outsideY = dozensY + outsideH + gap; + const zones: Zone[] = []; + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 3; row += 1) { + const number = column * 3 + (3 - row); + zones.push({ + x: gridX + column * cellW, + y: gridY + row * cellH, + w: cellW, + h: cellH, + target: { bet: "straight", number }, + label: String(number), + kind: "number", + number, + }); + } + } + + zones.push({ + x: pad, + y: gridY, + w: zeroW, + h: cellH * 3, + target: { bet: "straight", number: 0 }, + label: "0", + kind: "zero", + number: 0, + }); + + for (let top = 0; top < 3; top += 1) { + const index = 2 - top; + zones.push({ + x: gridX + gridW, + y: gridY + top * cellH, + w: columnW, + h: cellH, + target: { bet: "column", index }, + label: "2:1", + kind: "column", + }); + } + + for (let index = 0; index < 3; index += 1) { + zones.push({ + x: gridX + (gridW / 3) * index, + y: dozensY, + w: gridW / 3, + h: outsideH, + target: { bet: "dozen", index }, + label: ["1st 12", "2nd 12", "3rd 12"][index], + kind: "dozen", + }); + } + + const outside = [ + { 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" }, + ]; + outside.forEach((item, index) => { + zones.push({ + x: gridX + (gridW / 6) * index, + y: outsideY, + w: gridW / 6, + h: outsideH, + target: { bet: "outside", kind: item.kind }, + label: item.label, + kind: "outside", + }); + }); + + // Split and corner hotspots sit on the lines between cells, like the real + // felt (and the desktop fine lattice). Corners rank ahead of splits. + const hotspots: Hotspot[] = []; + const cellCenter = (number: number) => { + const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); + return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; + }; + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + (row + 1) * cellH, + radius: 12, + rank: 0, + target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, + }); + } + } + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + const center = cellCenter(top); + if (!center) continue; + hotspots.push({ + x: center.x, + y: gridY + (row + 1) * cellH, + radius: 10, + rank: 1, + target: { bet: "split", a: top, b: top - 1 }, + }); + } + } + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 3; row += 1) { + const left = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: left, b: left + 3 }, + }); + } + } + + for (let row = 0; row < 3; row += 1) { + const number = 3 - row; + hotspots.push({ + x: gridX, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: 0, b: number }, + }); + } + + return { + width, + height, + cellW, + cellH, + zeroX: pad, + zeroW, + gridX, + gridY, + gridW, + columnX: gridX + gridW, + dozensY, + outsideY, + outsideH, + zones, + hotspots, + }; +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radius: number, +): void { + const r = Math.min(radius, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function draw(canvas: HTMLCanvasElement, view: View): Layout { + const width = canvas.clientWidth || 800; + const height = canvas.clientHeight || 540; + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + const ctx = canvas.getContext("2d"); + const map = layout(width, height); + if (!ctx) return map; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = FELT_DARK; + roundRect(ctx, 0, 0, width, height, 10); + ctx.fill(); + + for (const zone of map.zones) { + let fill = FELT; + if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; + else if (zone.kind === "zero") fill = ZERO; + ctx.fillStyle = fill; + ctx.strokeStyle = FELT_LINE; + ctx.lineWidth = 1; + roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = TEXT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (zone.kind === "number" || zone.kind === "zero") { + ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; + } else { + ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; + } + ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); + } + + drawChips(ctx, map, view); + return map; +} + +function targetKey(target: any): string { + return JSON.stringify(target); +} + +function centerFor(layout: Layout, target: any): { x: number; y: number } | null { + const key = targetKey(target); + const exact = layout.zones.find((zone) => targetKey(zone.target) === key); + if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; + + // Split and corner bets are reachable from the desktop's fine lattice. A + // browser session can still show them: average the covered number cells. + if (target?.bet === "split" || target?.bet === "corner") { + const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; + const cells = layout.zones.filter( + (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), + ); + if (cells.length > 0) { + return { + x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, + y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, + }; + } + } + return null; +} + +function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { + const chips: any[] = view.bets?.chips ?? []; + for (const chip of chips) { + const center = centerFor(map, chip.target); + if (!center) continue; + const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); + ctx.beginPath(); + ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); + ctx.fillStyle = GOLD; + ctx.fill(); + ctx.strokeStyle = "#7a5a12"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = "#221a05"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; + ctx.fillText(String(chip.amount), center.x, center.y + 0.5); + } +} + +export function renderRoulette( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const spinning = view.phase === "spinning"; + board.append(node("h2", undefined, "Roulette")); + board.append( + node( + "p", + "note", + "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", + ), + ); + + const wrap = node("div", "roulette-wrap"); + const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; + wrap.append(canvas); + + if (view.phase === "result" && view.number !== null && view.number !== undefined) { + const result = node("div", `wheel-result ${view.color ?? ""}`); + result.append(node("div", "label", "Result")); + result.append(node("div", "num", String(view.number))); + const winnings = Number(view.winnings ?? 0); + result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); + wrap.append(result); + } + board.append(wrap); + + // The canvas must be in the document before its client size is read. + const map = draw(canvas, view); + let current = map; + + const hit = (clientX: number, clientY: number): Zone | null => { + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + let best: Hotspot | null = null; + for (const hotspot of current.hotspots) { + const distance = Math.hypot(x - hotspot.x, y - hotspot.y); + if (distance > hotspot.radius) continue; + if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { + best = hotspot; + } + } + if (best) { + return { + x: best.x - best.radius, + y: best.y - best.radius, + w: best.radius * 2, + h: best.radius * 2, + target: best.target, + label: "", + kind: "number", + }; + } + + for (let index = current.zones.length - 1; index >= 0; index -= 1) { + const zone = current.zones[index]; + if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { + return zone; + } + } + return null; + }; + + canvas.addEventListener("click", (event) => { + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ PlaceBetAt: zone.target }); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ RemoveBetAt: zone.target }); + }); + canvas.addEventListener("mousemove", (event) => { + const zone = hit(event.clientX, event.clientY); + canvas.style.cursor = zone && !spinning ? "pointer" : "default"; + }); + new ResizeObserver(() => { + current = draw(canvas, view); + }).observe(canvas); + + // Sidebar + side.append(node("div", "label", "Roulette")); + side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); + + side.append(node("div", "label", "Chip")); + const chipRow = node("div", "chip-row"); + for (const value of [10, 25, 50, 100, 500]) { + const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); + button.disabled = spinning; + button.addEventListener("click", () => send({ SetChip: value })); + chipRow.append(button); + } + side.append(chipRow); + + const total = (view.bets?.chips ?? []).reduce( + (sum: number, chip: any) => sum + Number(chip.amount ?? 0), + 0, + ); + const totalRow = node("div", "stat"); + totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); + side.append(totalRow); + + if (view.balance !== null && view.balance !== undefined) { + const balanceRow = node("div", "stat"); + balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); + side.append(balanceRow); + } + + if (view.error) side.append(node("p", "error-text", String(view.error))); + + const status = node("p", "note"); + if (spinning) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; + status.className = winnings > 0 ? "note win-text" : "note"; + } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); + spin.disabled = spinning || total === 0; + spin.addEventListener("click", () => send("Spin")); + const clearBets = node("button", "btn wide", "Clear bets"); + clearBets.disabled = spinning || total === 0; + clearBets.addEventListener("click", () => send("ClearBets")); + const leave = node("button", "btn ghost wide", "Leave table"); + leave.disabled = spinning; + leave.addEventListener("click", () => { + if (spinning) return; + send("Leave"); + }); + actions.append(spin, clearBets, leave); + side.append(actions); +} 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 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +const REELS = 5; +const ROWS = 3; +const SYMBOLS = "CLOGDS"; +const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; + +let spinUntil = 0; +let animation: number | undefined; +let latest: View | null = null; +let latestBoard: HTMLElement | null = null; + +export function requestSlotsSpin(): void { + spinUntil = Date.now() + 900; +} + +function spinning(view: View): boolean { + return view.phase === "spinning" || Date.now() < spinUntil; +} + +export function renderSlots( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + latest = view; + latestBoard = board; + if (animation !== undefined) { + window.clearInterval(animation); + animation = undefined; + } + + const active = spinning(view); + clear(board); + clear(side); + + const title = node("h2", undefined, "Slots"); + const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); + board.append(title, note); + + const reels = node("div", "reels"); + const tiles: HTMLElement[] = []; + const flat = String(view.reels ?? ""); + for (let column = 0; column < REELS; column += 1) { + const reel = node("div", "reel"); + for (let row = 0; row < ROWS; row += 1) { + const index = column * ROWS + row; + const symbol = active + ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length] + : flat.length === REELS * ROWS + ? flat[index] + : flat.length === REELS + ? flat[column] + : SYMBOLS[(index * 5 + column) % SYMBOLS.length]; + const tile = node("div", "tile", symbol); + tile.classList.add(`sym-${symbol}`); + if (active) tile.classList.add("spinning"); + if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win"); + tiles.push(tile); + reel.append(tile); + } + reels.append(reel); + } + board.append(reels); + + const lines = node("div", "win-lines"); + if (!active && view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + if (winnings > 0) { + const names = (view.win_lines ?? []) + .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) + .join(" · "); + lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + lines.classList.add("win-text"); + } else { + lines.textContent = "No win — spin again."; + } + } else if (active) { + lines.textContent = "Spinning…"; + } else { + lines.textContent = "Pick a bet and spin."; + } + board.append(lines); + + if (active) { + let tick = 0; + animation = window.setInterval(() => { + tick += 1; + for (let index = 0; index < tiles.length; index += 1) { + const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length]; + tiles[index].textContent = symbol; + tiles[index].className = `tile spinning sym-${symbol}`; + } + if (Date.now() >= spinUntil && latest && latestBoard) { + window.clearInterval(animation); + animation = undefined; + renderSlots(latestBoard, side, latest, send); + } + }, 70); + } + + // Sidebar + side.append(node("div", "label", "Slots")); + side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const bet = node("strong", "mono", money(Number(view.bet ?? 0))); + const plus = node("button", "btn small", "+10"); + minus.disabled = active; + plus.disabled = active; + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + if (view.error) { + side.append(node("p", "error-text", String(view.error))); + } + + const status = node("p", "note"); + if (active) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; + if (winnings > 0) status.classList.add("win-text"); + } else status.textContent = "Ready when you are."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); + spin.disabled = active; + spin.addEventListener("click", () => { + requestSlotsSpin(); + send("Spin"); + }); + const leave = node("button", "btn ghost wide", "Leave machine"); + leave.disabled = active; + leave.addEventListener("click", () => send("Leave")); + actions.append(spin, leave); + side.append(actions); +} 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 @@ +export type View = { + screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash"; + [key: string]: any; +}; + +export type Hello = { + type: "hello"; + version: string; + grace_seconds: number; + /** Signed-in user restored after a refresh, if any. */ + user?: string | null; + /** Estimated game-server Unix time in milliseconds. */ + time: number; +}; + +export type Send = (command: unknown) => void; + +export type Handlers = { + onHello: (hello: Hello) => void; + onView: (view: View) => void; + onNotice: (text: string) => void; + onError: (text: string) => void; + onReplaced: () => void; + onPong: () => void; + onOpen: () => void; + onClosed: (willRetry: boolean) => void; +}; 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 @@ +:root { + --bg: #0a0e12; + --surface: #11171d; + --raised: #171f27; + --hover: #1d2731; + --line: #242e38; + --text: #d9e2ea; + --dim: #8a98a6; + --faint: #5b6773; + --accent: #4fb8c9; + --win: #4ec27b; + --loss: #e05a5a; + --gold: #e0b64a; + --felt: #0d5c39; + --felt-dark: #09472c; + --felt-line: #1f7d52; + --felt-text: #ecf7f0; + --red: #b0313a; + --black: #151b21; + --zero: #106b45; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --sans: "Open Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "Hack", ui-monospace, "SFMono-Regular", "Cascadia Code", monospace; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + background: + radial-gradient(1100px 600px at 80% -10%, rgba(79, 184, 201, 0.06), transparent 60%), + var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +[hidden] { display: none !important; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button, input { font: inherit; color: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: 0.45; } + +.shell { min-height: 100%; display: flex; flex-direction: column; } + +.topbar { + display: flex; + align-items: center; + gap: 16px; + min-height: 64px; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: rgba(10, 14, 18, 0.86); + backdrop-filter: blur(10px); + position: sticky; + top: 0; + z-index: 20; +} +.brand { + color: var(--text); + font-family: var(--mono); + font-size: 19px; + letter-spacing: 0.04em; + white-space: nowrap; +} +.brand span { color: var(--win); } +.brand:hover { text-decoration: none; } +.tagline { + color: var(--faint); + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; +} +.spacer { flex: 1; } +.session { display: flex; align-items: center; gap: 12px; } +.session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; } +.session .who strong { color: var(--text); font-weight: 600; } +.balance { + color: var(--win); + font-family: var(--mono); + font-size: 16px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + white-space: nowrap; +} + +main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; } + +h1, h2, h3 { margin: 0; font-weight: 600; } +h1 { font-size: 26px; letter-spacing: -0.01em; } +h2 { font-size: 18px; } +.muted { color: var(--dim); } +.faint { color: var(--faint); } +.mono { font-family: var(--mono); } +.label { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--dim); +} +.error-text { color: var(--loss); } +.win-text { color: var(--win); } + +.btn { + border: 1px solid var(--line); + background: var(--raised); + color: var(--text); + padding: 9px 14px; + border-radius: var(--radius-md); + line-height: 1.2; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; +} +.btn:hover:not(:disabled) { background: var(--hover); border-color: #33404d; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn.primary { + background: var(--win); + border-color: var(--win); + color: #06120b; + font-weight: 700; +} +.btn.primary:hover:not(:disabled) { filter: brightness(1.08); } +.btn.accent { + background: rgba(79, 184, 201, 0.14); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} +.btn.danger { + background: rgba(224, 90, 90, 0.12); + border-color: rgba(224, 90, 90, 0.55); + color: var(--loss); +} +.btn.ghost { background: transparent; } +.btn.small { padding: 5px 9px; font-size: 12px; } +.btn.wide { width: 100%; } +.btn.big { padding: 13px 18px; font-size: 15px; } + +.input { + width: 100%; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 10px 12px; + outline: none; +} +.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 184, 201, 0.18); } +.field { display: grid; gap: 6px; } +.row { display: flex; align-items: center; gap: 10px; } +.row.wrap { flex-wrap: wrap; } +.hr { height: 1px; background: var(--line); border: 0; margin: 18px 0; } + +/* Auth */ +.auth-wrap { display: grid; place-items: center; min-height: 52vh; } +.auth-card { + width: min(430px, 100%); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 26px; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); +} +.auth-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg); padding: 4px; border-radius: var(--radius-md); } +.auth-tab { flex: 1; border: 0; background: transparent; color: var(--dim); padding: 8px; border-radius: var(--radius-sm); } +.auth-tab.active { background: var(--raised); color: var(--text); } +.auth-card form { display: grid; gap: 14px; } +.auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); } + +/* Lobby */ +.lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; } +.lobby-head .spacer { flex: 1; } +.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } +.game-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 178px; + position: relative; + overflow: hidden; +} +.game-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--accent); + opacity: 0.7; +} +.game-card.crash::before { background: var(--gold); } +.game-card.roulette::before { background: var(--felt-line); } +.game-card.blackjack::before { background: var(--loss); } +.game-card h3 { font-size: 17px; } +.game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; } +.game-card .row { justify-content: space-between; } +.bet-input { width: 92px; } + +/* Game shell */ +.game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; } +.game-head .spacer { flex: 1; } +.game-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 16px; + align-items: start; +} +.board { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + min-height: 560px; + overflow: hidden; +} +.sidebar { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 84px; +} +.sidebar .actions { margin-top: auto; display: grid; gap: 8px; } +.sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; } +.sidebar .stat strong { font-family: var(--mono); font-weight: 600; } +.note { color: var(--dim); font-size: 12px; } +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } +.chip-btn { + font-family: var(--mono); + font-size: 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--raised); + padding: 5px 10px; +} +.chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); } + +/* Slots */ +.reels { + display: grid; + grid-template-columns: repeat(5, minmax(60px, 1fr)); + gap: 10px; + max-width: 760px; + margin: 24px auto 0; +} +.reel { display: grid; gap: 8px; } +.tile { + aspect-ratio: 1 / 0.92; + display: grid; + place-items: center; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--mono); + font-size: clamp(26px, 5vw, 54px); + font-weight: 700; + color: var(--dim); + transition: color 90ms ease, border-color 90ms ease, background 90ms ease; +} +.tile.sym-C { color: #c8d1da; } +.tile.sym-L { color: #7cc4d4; } +.tile.sym-O { color: #d8c27a; } +.tile.sym-G { color: #83d09a; } +.tile.sym-D { color: #d48d8d; } +.tile.sym-S { color: #e0b64a; } +.tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); } +.tile.spinning { color: var(--faint); } +.win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; } + +/* Cards */ +.hand { margin: 16px 0; } +.hand .label { margin-bottom: 8px; } +.cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; } +.card { + width: 76px; + height: 108px; + border-radius: 9px; + background: #f2f5f7; + color: #121820; + border: 1px solid #cfd8df; + padding: 8px; + display: flex; + flex-direction: column; + justify-content: space-between; + font-family: var(--mono); + font-weight: 700; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); +} +.card.red { color: #a51f2d; } +.card.back { background: repeating-linear-gradient(135deg, #1d2731, #1d2731 6px, #26333f 6px, #26333f 12px); border-color: #33404d; } +.card .rank { font-size: 22px; line-height: 1; } +.card .suit { font-size: 22px; align-self: flex-end; } +.card.back .rank, .card.back .suit { color: var(--faint); } +.total { color: var(--dim); font-family: var(--mono); font-size: 12px; } +.total strong { color: var(--text); font-size: 15px; } +.outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); } + +/* Crash */ +.crash-wrap { position: relative; } +.crash-number { + font-family: var(--mono); + font-size: clamp(44px, 9vw, 86px); + line-height: 1; + letter-spacing: -0.04em; +} +.crash-number.running { color: var(--gold); } +.crash-number.crashed { color: var(--loss); } +.crash-number.cashed { color: var(--win); } +.crash-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; color: var(--faint); font-size: 11px; font-family: var(--mono); } +.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; } +.verify.ok { border-color: rgba(78, 194, 123, 0.4); color: var(--win); } +.verify.bad { border-color: rgba(224, 90, 90, 0.5); color: var(--loss); } + +/* Roulette */ +.roulette-wrap { position: relative; } +canvas { display: block; width: 100%; } +.roulette-canvas { height: 540px; } +.crash-canvas { height: 360px; } +.wheel-result { + position: absolute; + top: 16px; + right: 16px; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: rgba(10, 14, 18, 0.86); + font-family: var(--mono); +} +.wheel-result .num { font-size: 26px; font-weight: 700; } +.wheel-result.red .num { color: #f06a74; } +.wheel-result.black .num { color: #dfe8ef; } +.wheel-result.green .num { color: #66d49a; } + +/* History */ +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); } +table { width: 100%; border-collapse: collapse; background: var(--surface); } +th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 13px; white-space: nowrap; } +th { color: var(--dim); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; } +tr:last-child td { border-bottom: 0; } +td.num { font-family: var(--mono); } +td.pos { color: var(--win); } +td.neg { color: var(--loss); } + +/* Toasts and overlays */ +#toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); } +.toast { + background: var(--surface); + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: var(--radius-md); + padding: 10px 12px; + font-size: 13px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + animation: toast-in 160ms ease-out; +} +.toast.good { border-left-color: var(--win); } +.toast.bad { border-left-color: var(--loss); } +@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } } +.overlay { + position: fixed; + inset: 0; + background: rgba(5, 8, 11, 0.78); + display: grid; + place-items: center; + z-index: 60; + padding: 24px; +} +.modal { + width: min(440px, 100%); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 24px; + text-align: center; +} +.modal p { color: var(--dim); } + +@media (max-width: 900px) { + .game-body { grid-template-columns: 1fr; } + .sidebar { position: static; } + .tagline { display: none; } +} +@media (max-width: 620px) { + main { padding: 18px 14px 54px; } + .topbar { padding: 0 14px; gap: 10px; } + .session .who { display: none; } + .balance { font-size: 14px; } + .reels { gap: 6px; } + .tile { font-size: clamp(18px, 7vw, 34px); } + .card { width: 58px; height: 84px; } + .crash-canvas { height: 260px; } + .roulette-canvas { height: 400px; } +} 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 @@ +import fs from "node:fs"; +import { webcrypto } from "node:crypto"; +import { JSDOM, VirtualConsole } from "jsdom"; + +const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8") + .replace('', ""); +const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8"); + +const failures = []; +const virtualConsole = new VirtualConsole(); +virtualConsole.on("jsdomError", (error) => failures.push(String(error))); + +const dom = new JSDOM(html, { + url: "http://127.0.0.1:8899/play/", + runScripts: "outside-only", + pretendToBeVisual: true, + virtualConsole, +}); +const { window } = dom; + +Object.defineProperty(window, "crypto", { value: webcrypto }); +window.devicePixelRatio = 1; +window.confirm = () => true; + +class MockWebSocket { + static OPEN = 1; + static instances = []; + readyState = MockWebSocket.OPEN; + sent = []; + listeners = new Map(); + + constructor(url) { + this.url = url; + MockWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open", {})); + } + + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + send(raw) { + this.sent.push(JSON.parse(raw)); + } + + close() { + this.emit("close", {}); + } + + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + message(message) { + this.emit("message", { data: JSON.stringify(message) }); + } +} +window.WebSocket = MockWebSocket; +window.ResizeObserver = class { + observe() {} + disconnect() {} +}; +window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0); +window.cancelAnimationFrame = (handle) => window.clearTimeout(handle); + +const context = new Proxy( + {}, + { + get(target, key) { + if (key === "measureText") return () => ({ width: 10 }); + if (key in target) return target[key]; + return () => undefined; + }, + set(target, key, value) { + target[key] = value; + return true; + }, + }, +); +window.HTMLCanvasElement.prototype.getContext = function getContext() { + return context; +}; + +window.eval(bundle); + +const document = window.document; +const byId = (id) => document.getElementById(id); +const assert = (condition, message) => { + if (!condition) failures.push(message); +}; + +const socket = MockWebSocket.instances[0]; +assert(socket, "the app opened a WebSocket"); +assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`); + +const hello = { + type: "hello", + version: "5.0.0", + grace_seconds: 60, + time: Date.now(), +}; +const lobby = { + type: "view", + view: { + screen: "lobby", + connected: true, + user: null, + account: null, + balance: null, + games: [ + ["blackjack", "Blackjack"], + ["crash", "Crash"], + ["roulette", "Roulette"], + ["slots", "Slots"], + ], + login_error: null, + }, +}; +socket.message(hello); +socket.message(lobby); + +assert(byId("auth-screen").hidden === false, "auth is shown before login"); +assert(byId("session-bar").hidden === true, "no session bar before login"); + +byId("auth-user").value = "alice"; +byId("auth-pass").value = "secret"; +byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + +await new Promise((resolve) => setTimeout(resolve, 10)); +const login = socket.sent.find((message) => message.command?.Login); +assert(login, "the login form sends a Login command"); +assert(login.command.Login.user === "alice", "the username is sent"); +assert( + /^[0-9a-f]{64}$/.test(login.command.Login.credential), + "the password is sent as a SHA-256 hex digest", +); + +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: null, + games: lobby.view.games, + login_error: null, + }, +}); +assert( + socket.sent.some((message) => message.command === "Info"), + "the app asks for the balance after login", +); +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: 1000, + games: lobby.view.games, + login_error: null, + }, +}); +assert(byId("auth-screen").hidden === true, "auth is hidden after login"); +assert(byId("lobby-screen").hidden === false, "the lobby is shown after login"); +assert(byId("account-name").textContent === "alice", "the account name is shown"); +assert(byId("account-balance").textContent.includes("1"), "the balance is shown"); +assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render"); +socket.message({ + type: "view", + view: { + screen: "slots", + bet: 10, + balance: 1000, + reels: "CLOGDSCLOGDSLLO".split(""), + win_cells: [0, 1, 2], + win_lines: [1], + win_type: "win", + winnings: 20, + phase: "result", + error: null, + }, +}); +assert(byId("game-screen").hidden === false, "the game screen is shown"); +assert(document.querySelectorAll(".reel").length === 5, "five reels render"); +assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render"); +assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action"); + +socket.message({ + type: "view", + view: { + screen: "roulette", + bet_amount: 10, + balance: 1000, + chip: 25, + bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] }, + cursor: { Lattice: { x: 1, y: 5 } }, + phase: "result", + number: 5, + color: "red", + winnings: 900, + error: null, + }, +}); +assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); +assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders"); +assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders"); + +socket.message({ + type: "view", + view: { + screen: "blackjack", + status: "player", + balance: 990, + bet: 10, + hand: { + session_id: "bj1", + phase: "player", + player: ["10S", "6H"], + dealer: ["9D", "?"], + player_total: 16, + dealer_total: 9, + bet: 10, + base_bet: 10, + balance: 990, + actions: ["hit", "stand", "double"], + result: "playing", + winnings: 0, + }, + error: null, + }, +}); +assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render"); +assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render"); +assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase"); +assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders"); + +socket.message({ + type: "view", + view: { + screen: "crash", + status: "running", + balance: 990, + bet: 10, + multiplier: 1.2, + potential: 12, + crash_point: null, + payout: 0, + started_at: Date.now(), + commit: "ab".repeat(32), + seed: null, + error: null, + }, +}); +assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); +assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); +assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); +assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment"); + +socket.message({ + type: "view", + view: { + screen: "history", + records: [ + { + game: "slots", + bet: 10, + winnings: 20, + balance_after: 1010, + at: "2026-11-08 03:00:00", + details: { win_type: "win" }, + }, + ], + }, +}); +assert(byId("history-screen").hidden === false, "the history screen renders"); +assert(document.querySelector("table tbody tr"), "history rows render"); + +socket.message({ type: "replaced" }); +assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab"); + +await new Promise((resolve) => setTimeout(resolve, 30)); +dom.window.close(); + +if (failures.length > 0) { + console.error("web smoke test failed:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.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 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} 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 @@ +[package] +name = "zagan-bet-webd" +version = "5.0.0" +edition = "2024" +license = "GPL-3.0-or-later" + +[dependencies] +zagan-bet = { path = "..", default-features = false } +tungstenite = "0.21" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rand = "0.8" +rusqlite = { 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 @@ +# `webd` — browser gateway + +The browser cannot open the game's TCP/TLS stream, and the rules must not be +reimplemented in JavaScript. `webd` is a server-side frontend: one blocking +thread per browser session, one `ClientCore`, and one ordinary client TLS +connection to the game server. nginx terminates TLS and proxies `/ws` here. + +It is a workspace member like `gui/`. Its dependencies never enter the TUI, +GUI or server build graph. + +## Build + +```sh +cargo build -p zagan-bet-webd --release +``` + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `ZAGAN_WEB_BIND` | `127.0.0.1:8890` | Loopback address nginx proxies to | +| `ZAGAN_WEB_ORIGIN` | `https://zagan.2137697.xyz` | Exact `Origin` allowed | +| `ZAGAN_UPSTREAM` | `127.0.0.1:11525` | Game server `host:port` | +| `ZAGAN_UPSTREAM_FINGERPRINT` | **required** | SPKI SHA-256, 64 lowercase hex | +| `ZAGAN_GRACE_SECONDS` | `60` | Session survives this long without a socket | +| `ZAGAN_WEB_COOKIE_SECURE` | on for `https://` origin | `Secure` on the cookie | + +The fingerprint is the same one the desktop client shows: + +```sh +openssl x509 -in cert.pem -pubkey -noout \ + | openssl pkey -pubin -outform DER \ + | sha256sum +``` + +There is no trust-on-first-use and no unsafe mode. A mismatch refuses to +connect and logs both fingerprints. Run the gateway on the same host as the +game server (or pin the remote key and accept the network hop). + +## systemd + +Install `zagan-bet-webd` under `/usr/local/bin/`, then: + +```ini +# /etc/systemd/system/zagan-bet-webd.service +[Unit] +Description=zagan-bet browser gateway +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=zagan +Group=zagan +EnvironmentFile=/etc/zagan-bet-webd.env +ExecStart=/usr/local/bin/zagan-bet-webd +Restart=on-failure +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +```sh +sudo install -m 0755 target/release/zagan-bet-webd /usr/local/bin/ +sudo install -d -m 0755 /etc +sudo tee /etc/zagan-bet-webd.env >/dev/null <<'ENV' +ZAGAN_UPSTREAM=127.0.0.1:11525 +ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> +ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz +ZAGAN_WEB_BIND=127.0.0.1:8890 +ENV +sudo systemctl daemon-reload +sudo systemctl enable --now zagan-bet-webd +``` + +nginx needs: + +```nginx +location = /ws { + proxy_pass http://127.0.0.1:8890; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} +``` + +## Behaviour + +- The WebSocket handshake sets an HttpOnly, SameSite=Strict session cookie. + There is no credential storage in the browser or gateway. +- A browser close starts the grace window. Upstream stays connected, crash + ticks are still applied to the core, and a reconnect with the same cookie + gets the current `View`. Expiry closes upstream; the server forfeits a live + hand, exactly like a desktop disconnect. +- A second socket with the same cookie takes over: the old tab receives + `{"type":"replaced"}` and is closed. +- A gateway restart loses live sessions, so their rounds are forfeited. There + is no protocol-level resume. +- The browser sends `{"type":"command","command":...}` and receives + `view`/`notice`/`error`/`replaced`/`hello`/`pong` JSON. It never sees the + game wire or a certificate. + +## Security notes + +- Bind loopback. Do not expose the gateway directly. +- Keep `ZAGAN_WEB_ORIGIN` exact. The cookie is SameSite=Strict, and the + `Origin` check is a second lock on cross-site WebSocket attempts. +- The gateway is play-money only in v1. No payments, KYC or real-money copy. +- `ZAGAN_UPSTREAM_FINGERPRINT` is the browser's credential path: a wrong or + 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 @@ +//! Gateway configuration from the environment. +//! +//! The upstream key is mandatory. There is no trust-on-first-use and no unsafe +//! mode: the browser's credentials ride on this connection. + +use std::env; +use std::time::Duration; + +pub const DEFAULT_BIND: &str = "127.0.0.1:8890"; +pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525"; +pub const DEFAULT_GRACE_SECONDS: u64 = 60; +pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz"; + +#[derive(Debug, Clone)] +pub struct Config { + /// Loopback address nginx proxies `/ws` to. + pub bind: String, + /// Exact browser origin allowed on the WebSocket handshake. + pub origin: String, + /// Game server address, `host:port`. + pub upstream: String, + /// Lowercase hex SHA-256 of the upstream certificate's SPKI. + pub fingerprint: String, + /// How long a session survives without a browser socket. + pub grace: Duration, + /// `Secure` on the session cookie. Off only for local http testing. + pub cookie_secure: bool, +} + +impl Config { + pub fn from_env() -> Result { + let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND); + let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN); + if !origin.starts_with("http://") && !origin.starts_with("https://") { + return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string()); + } + let origin = origin.trim_end_matches('/').to_string(); + + let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM); + let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?; + let fingerprint = fingerprint.trim().to_ascii_lowercase(); + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err( + "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI" + .to_string(), + ); + } + + let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") { + Ok(raw) => raw + .trim() + .parse::() + .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?, + Err(_) => DEFAULT_GRACE_SECONDS, + }; + + let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") { + Ok(raw) => matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ), + Err(_) => origin.starts_with("https://"), + }; + + Ok(Self { + bind, + origin, + upstream, + fingerprint, + grace: Duration::from_secs(grace_seconds), + cookie_secure, + }) + } +} + +fn value(name: &str, default: &str) -> String { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +fn required(name: &str) -> Result { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "{name} is required: the gateway pins the upstream key. \ + Use the SPKI fingerprint the desktop client shows." + ) + }) +} 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 @@ +//! Browser gateway for zagan-bet. +//! +//! nginx terminates TLS, serves the static app and proxies `/ws` here. This +//! process owns one upstream TLS connection and one `ClientCore` per browser +//! session, so the browser never speaks the game wire protocol and never sees +//! a certificate. + +mod config; +mod session; + +use std::net::TcpListener; +use std::sync::Arc; +use std::thread; + +use config::Config; + +fn main() { + let config = match Config::from_env() { + Ok(config) => config, + Err(error) => { + eprintln!("webd: {error}"); + std::process::exit(2); + } + }; + + let listener = match TcpListener::bind(&config.bind) { + Ok(listener) => listener, + Err(error) => { + eprintln!("webd: could not bind {}: {error}", config.bind); + std::process::exit(1); + } + }; + + println!( + "webd listening on ws://{} for origin {}; upstream {} pinned to {}; grace {}s", + config.bind, + config.origin, + config.upstream, + config.fingerprint, + config.grace.as_secs() + ); + + let registry = session::new_registry(); + for stream in listener.incoming() { + let stream = match stream { + Ok(stream) => stream, + Err(error) => { + eprintln!("webd: accept failed: {error}"); + continue; + } + }; + let _ = stream.set_nodelay(true); + let config = config.clone(); + let registry = Arc::clone(®istry); + thread::spawn(move || { + if let Err(error) = session::accept(stream, config, registry) { + eprintln!("webd: {error}"); + } + }); + } +} 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 @@ +//! One gateway session: a browser socket, one upstream TLS connection and one +//! `ClientCore`. +//! +//! The browser sends `Command` JSON and renders `View` JSON. All game state, +//! protocol framing and balance arithmetic stay in the Rust core. + +use std::collections::HashMap; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use tungstenite::error::Error as WsError; +use tungstenite::http::HeaderValue; +use tungstenite::http::header::ORIGIN; +use tungstenite::protocol::{WebSocket, WebSocketConfig}; +use tungstenite::{Message, accept_hdr_with_config}; + +use zagan_bet::client::core::{ClientCore, Effect}; +use zagan_bet::client::database; +use zagan_bet::client::transport::{self, TlsStream}; +use zagan_bet::client::view::{Command, View}; +use zagan_bet::protocol::Packet; +use zagan_bet::version::VERSION; + +use crate::config::Config; + +const COOKIE_NAME: &str = "zagan_session"; +/// On HTTPS, use the `__Host-` prefix so a sibling origin cannot set a +/// domain-scoped cookie and fixate the session. +const HOST_COOKIE_NAME: &str = "__Host-zagan_session"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const POLL: Duration = Duration::from_millis(10); + +pub enum SocketEvent { + Attach(WebSocket), +} + +/// Session id → generation and the thread that owns it. +/// +/// The generation lets an ending thread remove its own entry without deleting +/// a replacement session that reused the same cookie. +pub type Registry = Arc)>>>; + +static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); + +pub fn new_registry() -> Registry { + Arc::new(Mutex::new(HashMap::new())) +} + +/// Complete the WebSocket handshake, then attach this socket to an existing +/// session or start one. +pub fn accept(stream: TcpStream, config: Config, registry: Registry) -> Result<(), String> { + let mut session_id = None; + let origin = config.origin.clone(); + let secure = config.cookie_secure; + + let ws_config = WebSocketConfig { + // A command or view is a few KiB at most; keep a compromised page from + // making the gateway buffer tens of megabytes. + max_message_size: Some(64 * 1024), + max_frame_size: Some(64 * 1024), + ..WebSocketConfig::default() + }; + let mut ws = accept_hdr_with_config( + stream, + |request: &tungstenite::handshake::server::Request, + mut response: tungstenite::handshake::server::Response| { + if let Some(header) = request.headers().get(ORIGIN) { + if header.to_str().ok() != Some(origin.as_str()) { + return Err(tungstenite::http::Response::builder() + .status(403) + .body(Some("origin not allowed".to_string())) + .expect("static response")); + } + } + + let found = request + .headers() + .get("cookie") + .and_then(|value| value.to_str().ok()) + .and_then(cookie_id); + let (id, fresh) = match found { + Some(id) => (id, false), + None => (random_id(), true), + }; + if fresh { + let mut cookie = format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict"); + if secure { + cookie.push_str("; Secure"); + } + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().append("set-cookie", value); + } + } + session_id = Some(id); + Ok(response) + }, + Some(ws_config), + ) + .map_err(|error| format!("WebSocket handshake failed: {error}"))?; + + let id = session_id.ok_or_else(|| "handshake produced no session id".to_string())?; + ws.get_ref() + .set_nonblocking(true) + .map_err(|error| format!("could not make the browser socket nonblocking: {error}"))?; + + let (sender, receiver) = mpsc::channel(); + let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); + { + let mut sessions = registry.lock().map_err(|_| "session registry poisoned")?; + if let Some((_, existing)) = sessions.get(&id).cloned() { + match existing.send(SocketEvent::Attach(ws)) { + Ok(()) => return Ok(()), + Err(mpsc::SendError(SocketEvent::Attach(returned))) => { + sessions.remove(&id); + ws = returned; + } + } + } + sessions.insert(id.clone(), (generation, sender.clone())); + } + + thread::spawn(move || { + let session = match Session::new(id.clone(), config) { + Ok(session) => Some(session), + Err(error) => { + eprintln!("[webd {id}] could not start: {error}"); + None + } + }; + if let Some(mut session) = session { + session.attach(ws); + session.poll_loop(&receiver); + } + if let Ok(mut sessions) = registry.lock() { + let own = sessions + .get(&id) + .map(|(generation_now, _)| *generation_now == generation) + .unwrap_or(false); + if own { + sessions.remove(&id); + } + } + eprintln!("[webd {id}] session ended"); + }); + + Ok(()) +} + +fn cookie_id(raw: &str) -> Option { + for pair in raw.split(';') { + let (name, value) = pair.split_once('=')?; + if matches!(name.trim(), COOKIE_NAME | HOST_COOKIE_NAME) { + let value = value.trim(); + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Some(value.to_ascii_lowercase()); + } + return None; + } + } + None +} + +fn random_id() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientMessage { + Command { command: Command }, + Ping, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerMessage<'a> { + Hello { + version: &'a str, + grace_seconds: u64, + /// Signed-in user, so a refresh during a game can skip the login card. + user: Option<&'a str>, + /// Estimated game-server Unix time, milliseconds. + time: u64, + }, + View { + view: &'a View, + }, + Notice { + text: &'a str, + }, + Error { + text: &'a str, + }, + Replaced, + Pong, +} + +struct Session { + id: String, + config: Config, + core: ClientCore, + /// In-memory trust store seeded with the pinned fingerprint. No player + /// credentials are ever stored here. + trust: Connection, + upstream: Option, + ws: Option>, + pending_id: Option, + pending_since: Option, + grace_until: Option, + drop_upstream: bool, +} + +impl Session { + fn new(id: String, config: Config) -> Result { + let trust = Connection::open_in_memory() + .map_err(|error| format!("could not open the trust store: {error}"))?; + database::init_db(&trust); + database::save_fingerprint(&trust, &config.upstream, &config.fingerprint) + .map_err(|error| format!("could not pin the upstream key: {error}"))?; + + Ok(Self { + id, + config, + core: ClientCore::new(), + trust, + upstream: None, + ws: None, + pending_id: None, + pending_since: None, + grace_until: None, + drop_upstream: false, + }) + } + + fn attach(&mut self, ws: WebSocket) { + if let Some(mut old) = self.ws.take() { + send_json(&mut old, &ServerMessage::Replaced); + close_socket(&mut old); + } + eprintln!("[webd {}] browser attached", self.id); + self.ws = Some(ws); + self.grace_until = None; + let mut ws = self.ws.take().expect("just attached"); + if let Err(text) = self.ensure_upstream() { + send_json(&mut ws, &ServerMessage::Error { text: &text }); + } + send_json( + &mut ws, + &ServerMessage::Hello { + version: VERSION, + grace_seconds: self.config.grace.as_secs(), + user: self.core.user(), + time: self.core.server_now_ms(), + }, + ); + self.send_view(&mut ws); + self.ws = Some(ws); + } + + fn poll_loop(&mut self, receiver: &mpsc::Receiver) { + loop { + match receiver.recv_timeout(POLL) { + Ok(SocketEvent::Attach(ws)) => self.attach(ws), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + + self.pump_browser(); + self.pump_upstream(); + self.check_request_timeout(); + + if self.ws.is_some() { + self.grace_until = None; + } else if let Some(deadline) = self.grace_until { + if Instant::now() >= deadline { + break; + } + } else { + self.grace_until = Some(Instant::now() + self.config.grace); + } + } + + // Dropping the TLS stream closes upstream; the server forfeits any + // live hand, exactly like a desktop client disconnecting. + self.upstream = None; + eprintln!("[webd {}] grace expired", self.id); + } + + fn pump_browser(&mut self) { + let Some(mut ws) = self.ws.take() else { + return; + }; + let mut keep = true; + + loop { + match ws.read() { + Ok(Message::Text(text)) => { + if !self.handle_browser_text(&text, &mut ws) { + keep = false; + break; + } + } + Ok(Message::Close(_)) => { + keep = false; + break; + } + Ok(Message::Ping(payload)) => { + let _ = send_message(&mut ws, Message::Pong(payload)); + } + Ok(Message::Binary(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => {} + Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => { + keep = false; + break; + } + Err(_) => { + keep = false; + break; + } + } + } + + if keep { + self.ws = Some(ws); + } else { + self.grace_until = Some(Instant::now() + self.config.grace); + self.pending_id = None; + self.pending_since = None; + } + } + + fn handle_browser_text(&mut self, text: &str, ws: &mut WebSocket) -> bool { + let message = match serde_json::from_str::(text) { + Ok(message) => message, + Err(error) => { + send_json( + ws, + &ServerMessage::Error { + text: &format!("Malformed message: {error}"), + }, + ); + return true; + } + }; + + match message { + ClientMessage::Ping => send_json(ws, &ServerMessage::Pong), + ClientMessage::Command { command } => { + if let Err(text) = self.ensure_upstream() { + send_json(ws, &ServerMessage::Error { text: &text }); + self.send_view(ws); + return true; + } + let effects = self.core.command(command); + self.apply_effects(effects, Some(ws)); + } + } + true + } + + fn ensure_upstream(&mut self) -> Result<(), String> { + if self.upstream.is_some() { + return Ok(()); + } + + let mut trust = |_prompt: &transport::TrustPrompt| false; + match transport::connect(&self.config.upstream, &self.trust, false, &mut trust) { + Ok(connected) => { + self.core.set_connected(true); + self.core.set_server_time(connected.server_time); + self.core.set_host_info( + Some(self.config.upstream.clone()), + Some(self.config.fingerprint.clone()), + ); + self.upstream = Some(connected.stream); + eprintln!( + "[webd {}] upstream connected: {} ({}), ping {:.1}ms", + self.id, + self.config.upstream, + connected.server_version, + connected.elapsed.as_secs_f64() * 1000.0 + ); + Ok(()) + } + Err(error) => { + self.core.reset_after_disconnect(); + Err(format!("Upstream connection failed: {error}")) + } + } + } + + fn pump_upstream(&mut self) { + let Some(mut upstream) = self.upstream.take() else { + return; + }; + let mut ws = self.ws.take(); + let mut disconnected = None; + + loop { + match upstream.read_available_packet() { + Ok(Some(Packet::Event { body, .. })) => { + if let Some(effects) = self.core.handle_async_line(&body) { + self.apply_effects(effects, ws.as_mut()); + } + } + Ok(Some(Packet::Reply { id, body })) => { + if self.pending_id == Some(id) { + self.pending_id = None; + self.pending_since = None; + } + let effects = self.core.handle_reply_id(id, &body); + self.apply_effects(effects, ws.as_mut()); + } + Ok(None) => break, + Err(error) => { + disconnected = Some(error.to_string()); + break; + } + } + } + + if self.drop_upstream { + self.drop_upstream = false; + drop(upstream); + } else if disconnected.is_none() { + self.upstream = Some(upstream); + } + + if let Some(message) = disconnected { + eprintln!("[webd {}] upstream lost: {message}", self.id); + self.core.reset_after_disconnect(); + self.pending_id = None; + self.pending_since = None; + if let Some(ws) = ws.as_mut() { + send_json( + ws, + &ServerMessage::Error { + text: "Connection to the game server was lost", + }, + ); + self.send_view(ws); + } + } + + self.ws = ws; + } + + fn check_request_timeout(&mut self) { + let Some(sent) = self.pending_since else { + return; + }; + if sent.elapsed() < REQUEST_TIMEOUT { + return; + } + + let id = self.pending_id.take(); + self.pending_since = None; + self.upstream = None; + let mut ws = self.ws.take(); + if let Some(id) = id { + let effects = self.core.handle_reply_id(id, "ERROR Request timed out"); + self.apply_effects(effects, ws.as_mut()); + if let Some(ws) = ws.as_mut() { + send_json( + ws, + &ServerMessage::Notice { + text: "Request timed out. Disconnected.", + }, + ); + } + } + self.core.reset_after_disconnect(); + if let Some(ws) = ws.as_mut() { + self.send_view(ws); + } + self.ws = ws; + } + + fn apply_effects(&mut self, effects: Vec, mut ws: Option<&mut WebSocket>) { + for effect in effects { + match effect { + Effect::Print(lines) => { + // A multi-line print is a terminal table; the structured + // `View` carries that data to the browser. + if lines.len() == 1 { + if let (Some(socket), Some(line)) = (ws.as_deref_mut(), lines.first()) { + send_json(socket, &ServerMessage::Notice { text: line }); + } + } + } + Effect::SendRequest { id, payload } => { + self.pending_id = Some(id); + self.pending_since = Some(Instant::now()); + match self.ensure_upstream() { + Ok(()) => { + if let Some(upstream) = self.upstream.as_mut() { + if let Err(error) = upstream.write_request(id, None, &payload) { + let text = format!("Could not send request: {error}"); + if let Some(socket) = ws.as_deref_mut() { + send_json(socket, &ServerMessage::Error { text: &text }); + } + self.drop_upstream = true; + self.upstream = None; + } + } + } + Err(text) => { + self.pending_id = None; + self.pending_since = None; + if let Some(socket) = ws.as_deref_mut() { + send_json(socket, &ServerMessage::Error { text: &text }); + } + } + } + } + Effect::Disconnect => { + self.upstream = None; + self.drop_upstream = true; + self.core.reset_after_disconnect(); + } + Effect::Render(_) | Effect::ClearRegion | Effect::Quit => {} + } + } + + if let Some(socket) = ws { + self.send_view(socket); + } + } + + fn send_view(&self, ws: &mut WebSocket) { + let Some(view) = self.core.view() else { + return; + }; + send_json(ws, &ServerMessage::View { view: &view }); + } +} + +fn send_message(ws: &mut WebSocket, message: Message) -> Result<(), WsError> { + // The read side is nonblocking; writes are small and should not fail + // half-way because a browser is briefly not draining its socket. + let _ = ws.get_ref().set_nonblocking(false); + let result = ws.send(message); + let _ = ws.get_ref().set_nonblocking(true); + result +} + +fn send_json(ws: &mut WebSocket, message: &ServerMessage<'_>) { + let Ok(text) = serde_json::to_string(message) else { + return; + }; + let _ = send_message(ws, Message::Text(text)); +} + +fn close_socket(ws: &mut WebSocket) { + let _ = ws.get_ref().set_nonblocking(false); + let _ = ws.close(None); + let _ = ws.get_ref().set_nonblocking(true); +} + +#[cfg(test)] +mod tests { + use super::*; + use zagan_bet::client::view::{LobbyView, View}; + + #[test] + fn reads_only_well_formed_session_cookies() { + let id = "a".repeat(64); + assert_eq!( + cookie_id(&format!("other=1; {COOKIE_NAME}={id}; theme=dark")), + Some(id) + ); + assert_eq!(cookie_id(&format!("{COOKIE_NAME}=short")), None); + assert_eq!(cookie_id("other=1"), None); + assert_eq!(cookie_id(""), None); + } + + #[test] + fn parses_a_browser_command_and_rejects_nonsense() { + let message: ClientMessage = serde_json::from_str( + r#"{"type":"command","command":{"Login":{"user":"alice","credential":"abc"}}}"#, + ) + .expect("login command"); + match message { + ClientMessage::Command { command } => { + assert_eq!( + command, + Command::Login { + user: "alice".to_string(), + credential: "abc".to_string() + } + ); + } + _ => panic!("expected a command"), + } + + assert!(serde_json::from_str::("{}").is_err()); + assert!(serde_json::from_str::(r#"{"type":"nope"}"#).is_err()); + } + + #[test] + fn serializes_a_view_for_the_browser() { + let view = View::Lobby(LobbyView { + connected: true, + user: Some("alice".to_string()), + account: Some("1".to_string()), + balance: Some(1000), + games: vec![("slots".to_string(), "Slots".to_string())], + login_error: None, + }); + let json = serde_json::to_string(&ServerMessage::View { view: &view }).unwrap(); + assert!(json.contains(r#""type":"view""#)); + assert!(json.contains(r#""screen":"lobby""#)); + assert!(json.contains(r#""user":"alice""#)); + } +} -- cgit v1.2.3