diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip | |
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.
62 files changed, 6783 insertions, 414 deletions
| @@ -5,3 +5,6 @@ | |||
| 5 | *.pem | 5 | *.pem |
| 6 | *.key | 6 | *.key |
| 7 | /text | 7 | /text |
| 8 | |||
| 9 | # The browser build pins its dependency tree. | ||
| 10 | !web/play/package-lock.json | ||
| @@ -37,20 +37,79 @@ | |||
| 37 | transaction. | 37 | transaction. |
| 38 | 38 | ||
| 39 | ## Crash | 39 | ## Crash |
| 40 | - The client draws the multiplier from its own clock for display only. A | 40 | - The client draws the multiplier from its own clock, corrected by the server |
| 41 | cashout carries a session id and nothing else; the multiplier and payout come | 41 | time in `HELLO`/`PONG`, for display only. A cashout carries a session id and |
| 42 | from the server's clock at receipt. | 42 | nothing else; the multiplier and payout come from the server's clock at |
| 43 | receipt. | ||
| 43 | - Never send the crash point to the client before the round ends. A client that | 44 | - Never send the crash point to the client before the round ends. A client that |
| 44 | knows it can cash out at `crash - epsilon` every round for a guaranteed win. | 45 | knows it can cash out at `crash - epsilon` every round for a guaranteed win. |
| 45 | - The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of | 46 | - The crash point is `max(1.0, (1 - edge) / (1 - u))`; do not "fix" the 1% of |
| 46 | rounds that crash at 1.00x. That is where the house edge is charged on an | 47 | rounds that crash at 1.00x. That is where the house edge is charged on an |
| 47 | instant cashout, and it is why no cashout lockout is needed. | 48 | instant cashout, and it is why no cashout lockout is needed. |
| 49 | - Each round draws a 32-byte seed. `commit = sha256(seed)` goes out with | ||
| 50 | `CRASH_START`; the seed itself is revealed only when the round ends. The | ||
| 51 | crash point is `crash_point_from_seed(seed)` (first eight bytes, big-endian, | ||
| 52 | over 2^64), so a client can recompute it. Never reveal the seed while the | ||
| 53 | round is live. The guarantee is "fixed before you acted", not "ungrindable"; | ||
| 54 | see ADR 0004. | ||
| 48 | - The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. | 55 | - The crash event is a server-initiated line (`CRASH_BUSTED`), not a reply. |
| 49 | Client code must drain the socket on a timer and route pushed lines by | 56 | Client code must drain the socket on a timer and route pushed lines by |
| 50 | prefix instead of assuming the next line answers the last request. | 57 | prefix instead of assuming the next line answers the last request. |
| 51 | - `ServerCore::poll` must be idempotent: it settles the round, removes the | 58 | - `ServerCore::poll` must be idempotent: it settles the round, removes the |
| 52 | session, and pushes once. A second poll must produce nothing. | 59 | session, and pushes once. A second poll must produce nothing. |
| 53 | 60 | ||
| 61 | ## Protocol v5 | ||
| 62 | - Every packet is `[u32 big-endian length][UTF-8 payload]`, at most 64 KiB. | ||
| 63 | Never read a declared length above the limit into memory; close instead. | ||
| 64 | - Payloads are `REQ <id> [idem=<token>] <command>`, `RES <id> <body>` and | ||
| 65 | `EVT <seq> <body>`. The command/body text grammar is unchanged. | ||
| 66 | - `ClientCore` allocates request ids. Adapters must return a reply with | ||
| 67 | `handle_reply_id`; a reply with an unknown id is ignored, never applied to | ||
| 68 | whatever request happens to be pending. | ||
| 69 | - The server caches the last 256 request ids **per connection** and replays a | ||
| 70 | matching reply. A reused id with a different payload is an error. Do not make | ||
| 71 | this durable without a new ADR. | ||
| 72 | - `HELLO` and `PONG` carry Unix milliseconds. The offset is for display and | ||
| 73 | interpolation; the server settles crash. Never derive a payout from a | ||
| 74 | browser or gateway clock. | ||
| 75 | - While a crash round runs, `poll` pushes at most one `crash.tick` per 100 ms | ||
| 76 | per session. Settlement (`CRASH_BUSTED`) is never dropped or replaced by a | ||
| 77 | tick. | ||
| 78 | - A wire change is a MAJOR bump. Do not add a compatibility mode for v4. | ||
| 79 | |||
| 80 | ## Web gateway (`webd/`) | ||
| 81 | - The browser is a frontend, not a client. It sends `Command` JSON and renders | ||
| 82 | `View` JSON. It must never parse the game wire, compute a payout, or hold a | ||
| 83 | credential. | ||
| 84 | - `webd/` is a separate workspace member. Its dependencies (tungstenite, | ||
| 85 | serde_json, cookie handling) must not enter the TUI, GUI or server build | ||
| 86 | graph. | ||
| 87 | - One session thread owns one `ClientCore` and one upstream TLS connection. | ||
| 88 | No async runtime, no browser TOFU: `ZAGAN_UPSTREAM_FINGERPRINT` is required | ||
| 89 | and a mismatch refuses to connect. | ||
| 90 | - The session cookie is HttpOnly, SameSite=Strict, and Secure on HTTPS. Never | ||
| 91 | store a password or credential in the browser or gateway. Session-only auth; | ||
| 92 | no remember-me without a new ADR. | ||
| 93 | - Close is not forfeit: the gateway holds upstream for `ZAGAN_GRACE_SECONDS` | ||
| 94 | (default 60). Expiry closes upstream, which is the server's forfeit. A new | ||
| 95 | socket with the same cookie takes over; tell the old tab it was replaced. | ||
| 96 | - Bind to loopback and let nginx terminate TLS. Check `Origin` against | ||
| 97 | `ZAGAN_WEB_ORIGIN`. `/ws` is the only gateway route; `/play/` is static. | ||
| 98 | - A gateway restart loses live sessions and forfeits their rounds. Do not add | ||
| 99 | protocol-level resume here without an ADR. | ||
| 100 | |||
| 101 | ## Web app (`web/play/`) | ||
| 102 | - Vanilla TypeScript + esbuild. No framework runtime, no game rules, no wire | ||
| 103 | protocol. DOM for the shell, canvas for the game surface. | ||
| 104 | - Colours and spacing come from `web/play/style.css`, which mirrors | ||
| 105 | `gui/src/theme.rs`. Felt green is only ever the roulette table. | ||
| 106 | - Build with `cd web/play && npm ci && npm run build`; commit the generated | ||
| 107 | `app.js` so the site needs no Node at deploy time. | ||
| 108 | - `/play/` is the app; `/` stays the download page. Web v1 is play-money only: | ||
| 109 | no deposits, withdrawals or KYC copy. | ||
| 110 | - The browser cookie covers refresh and the grace window only. A closed tab | ||
| 111 | that returns later logs in again. | ||
| 112 | |||
| 54 | ## GUI | 113 | ## GUI |
| 55 | - The GUI is a second frontend over `ClientCore`, not a second client. | 114 | - The GUI is a second frontend over `ClientCore`, not a second client. |
| 56 | Frontends send `Command` and render `View`; the core owns navigation and | 115 | Frontends send `Command` and render `View`; the core owns navigation and |
diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6fefd53 --- /dev/null +++ b/CONTEXT.md | |||
| @@ -0,0 +1,76 @@ | |||
| 1 | # Context | ||
| 2 | |||
| 3 | zagan-bet is a small play-money casino: one Rust workspace with a shared | ||
| 4 | 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 | ||
| 5 | current when a term changes meaning. | ||
| 6 | |||
| 7 | ## Product terms | ||
| 8 | |||
| 9 | - **Play credit** — the unit a player wins and loses. Registration grants a | ||
| 10 | starting balance. There are no deposits, withdrawals, cash-outs or KYC; the | ||
| 11 | web app is a game, not a regulated gambling service. | ||
| 12 | - **Server** — the authoritative process. It owns the database, the game | ||
| 13 | rules, the balances, the random draws and the clock used to settle crash. | ||
| 14 | - **Frontend** — a presentation adapter over `ClientCore`: the terminal client, | ||
| 15 | the egui desktop app, or the browser app behind the web gateway. Frontends | ||
| 16 | send `Command` values and render `View` values. They never compute payouts. | ||
| 17 | - **ClientCore** — the I/O-free state machine shared by every frontend. It owns | ||
| 18 | navigation, the current game view, the pending request, and the local clock | ||
| 19 | anchor for crash. It contains no terminal, network or egui types. | ||
| 20 | - **Gateway (`webd`)** — a server-side frontend adapter for the browser. It owns | ||
| 21 | one upstream TLS connection and one `ClientCore` per browser session, and | ||
| 22 | translates browser JSON into `Command`/`View`. The browser never speaks the | ||
| 23 | game wire protocol. | ||
| 24 | - **Upstream** — the game server the gateway connects to. Its address and SPKI | ||
| 25 | fingerprint are operator configuration. The browser never sees a certificate. | ||
| 26 | - **Session** — one browser login held by the gateway. Identified by an | ||
| 27 | HttpOnly cookie. A refresh or a new tab reattaches to the same session during | ||
| 28 | the grace window; a later visit starts a new login. | ||
| 29 | |||
| 30 | ## Wire terms | ||
| 31 | |||
| 32 | - **Frame** — `[u32 length][UTF-8 payload]`, length big-endian, at most 64 KiB. | ||
| 33 | The frame is the unit of transport; the payload is text. | ||
| 34 | - **Envelope** — the first token of a frame payload: | ||
| 35 | - `REQ <id> [idem=<token>] <command>` — client request. | ||
| 36 | - `RES <id> <body>` — the reply to that request. | ||
| 37 | - `EVT <seq> <body>` — a server-initiated event, numbered per connection. | ||
| 38 | - **Request id** — a client-chosen `u64`, unique on the connection while its | ||
| 39 | reply is cached. It correlates a reply with the request that caused it. | ||
| 40 | - **Idempotency key** — an optional opaque token on a request. The connection | ||
| 41 | caches the last 256 request ids; resending the same id and payload replays the | ||
| 42 | stored reply instead of performing the action twice. | ||
| 43 | - **Event sequence** — a server-chosen `u64`, incremented once per `EVT` on a | ||
| 44 | connection. Clients use it to drop duplicates and to notice gaps. | ||
| 45 | - **HELLO** — the first request after TLS. Client and server must share | ||
| 46 | `MAJOR.MINOR`; the reply carries the server version, download URL and the | ||
| 47 | server's Unix time in milliseconds. | ||
| 48 | - **Tick** — `EVT crash.tick session=<id> t=<server_ms> m=<multiplier>`, pushed | ||
| 49 | at most every 100 ms while a crash round runs. The server clock still settles | ||
| 50 | a cash-out. | ||
| 51 | - **Commit** — `sha256(seed)` in hex, sent with `CRASH_START` before the player | ||
| 52 | can act. **Reveal** — the seed itself, sent when the round ends. The crash | ||
| 53 | point is a pure function of the seed, so a player can check both. | ||
| 54 | |||
| 55 | ## Deployment terms | ||
| 56 | |||
| 57 | - **Download site** — `web/`: `index.php` lists builds, `latest.php` feeds the | ||
| 58 | stable installers, `install.sh`/`install.ps1` fetch and verify the newest app. | ||
| 59 | - **Play app** — the static browser bundle under `/play/`, built from | ||
| 60 | `web/play/` with esbuild and served by nginx. | ||
| 61 | - **Grace window** — the 60 seconds the gateway keeps an upstream connection | ||
| 62 | after the last browser socket closes. Reattaching within it preserves a live | ||
| 63 | round; expiry closes upstream and the server forfeits per its existing rule. | ||
| 64 | - **Takeover** — a new browser connection presenting an existing session cookie | ||
| 65 | replaces the old socket; the old tab is told it was replaced. | ||
| 66 | |||
| 67 | ## Invariants | ||
| 68 | |||
| 69 | - One payout formula, one place. `process_action` stores raw data; | ||
| 70 | `calculate_result` derives the result. Frontends only render. | ||
| 71 | - Every balance mutation is transactional, and an audit row exists before the | ||
| 72 | balance moves. | ||
| 73 | - A crash point never reaches a client before the round ends. | ||
| 74 | - The server clock, not a browser or gateway clock, settles cash-out. | ||
| 75 | - `MAJOR.MINOR` is lockstep between client and server; only the patch component | ||
| 76 | may differ. A wire change is a MAJOR bump. | ||
| @@ -1,9 +1,9 @@ | |||
| 1 | [workspace] | 1 | [workspace] |
| 2 | members = ["gui"] | 2 | members = ["gui", "webd"] |
| 3 | 3 | ||
| 4 | [package] | 4 | [package] |
| 5 | name = "zagan-bet" | 5 | name = "zagan-bet" |
| 6 | version = "4.4.0" | 6 | version = "5.0.0" |
| 7 | edition = "2024" | 7 | edition = "2024" |
| 8 | license = "GPL-3.0-or-later" | 8 | license = "GPL-3.0-or-later" |
| 9 | 9 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index c0370a0..95ec922 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -5,7 +5,10 @@ Decisions and rationale live in `docs/adr/0001-gui-frontend-architecture.md` and | |||
| 5 | 5 | ||
| 6 | ## Non-goals | 6 | ## Non-goals |
| 7 | 7 | ||
| 8 | Audio, 3D, macOS, auto-update, a browser client, and pixel-golden tests in CI. | 8 | Audio, 3D, macOS, auto-update, a webview inside the desktop app, and |
| 9 | pixel-golden tests in CI. A separate browser frontend is no longer a non-goal: | ||
| 10 | it shipped in v5.0.0 through the `webd/` gateway, planned in | ||
| 11 | `docs/WEB-PLAN.md`. | ||
| 9 | 12 | ||
| 10 | ## Phase 0 — version handshake (v4.0.0) | 13 | ## Phase 0 — version handshake (v4.0.0) |
| 11 | 14 | ||
diff --git a/docs/PROTOCOL-IDEAS.md b/docs/PROTOCOL-IDEAS.md new file mode 100644 index 0000000..3991a84 --- /dev/null +++ b/docs/PROTOCOL-IDEAS.md | |||
| @@ -0,0 +1,170 @@ | |||
| 1 | # Server ↔ client transport ideas | ||
| 2 | |||
| 3 | Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the | ||
| 4 | differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids, | ||
| 5 | per-connection idempotency, clock sync + ticks, commit–reveal crash, and | ||
| 6 | length-prefixed framing. Items 6–7 remain future work. | ||
| 7 | |||
| 8 | These came out of reviewing zagan-bet's transport, but they were prompted by a | ||
| 9 | *different* app. Nothing here is a backlog item for this repository. The | ||
| 10 | zagan-bet paths are included because that is where the patterns were worked | ||
| 11 | out; treat the ideas as portable. | ||
| 12 | |||
| 13 | zagan-bet's current shape, for context: | ||
| 14 | |||
| 15 | - TLS with TOFU over the certificate's SPKI fingerprint, trust decision after | ||
| 16 | the handshake and before credentials (`src/client/transport.rs`, | ||
| 17 | `src/tofu.rs`). | ||
| 18 | - Newline-delimited text lines over the TLS stream (`src/protocol.rs`). | ||
| 19 | - `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`). | ||
| 20 | - Request/reply plus exactly one server-initiated line, `CRASH_BUSTED` | ||
| 21 | (`src/bin/server/handler.rs`, `ServerCore::poll`). | ||
| 22 | - `ClientCore` is I/O-free; frontends send `Command` and render `View` | ||
| 23 | (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`. | ||
| 24 | |||
| 25 | Each idea below lists the wire shape, where it would attach, and the cost. | ||
| 26 | |||
| 27 | ## 1. Correlation IDs, so a single `Pending` can go away | ||
| 28 | |||
| 29 | One request in flight, and pushes distinguished by string prefix | ||
| 30 | (`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can | ||
| 31 | race a pushed bust; the code already has a special case for it | ||
| 32 | (`src/client/core.rs:211`). An envelope removes the whole class: | ||
| 33 | |||
| 34 | ``` | ||
| 35 | REQ 42 idem=f3a1... INFO | ||
| 36 | RES 42 OK balance=1200 account=... | ||
| 37 | EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42} | ||
| 38 | ``` | ||
| 39 | |||
| 40 | `REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers | ||
| 41 | anything. The core keeps a map of in-flight requests instead of one `Option`, | ||
| 42 | and "a stale reply must not clobber the result" becomes a generic rule rather | ||
| 43 | than a crash-specific branch. Also allows pipelining, e.g. clock probes while a | ||
| 44 | `HISTORY` is in flight, without app-layer head-of-line stalls. Keep line | ||
| 45 | framing for now; just prepend tokens. | ||
| 46 | |||
| 47 | ## 2. Idempotency keys on every balance mutation | ||
| 48 | |||
| 49 | A request can be applied server-side and the reply lost. Today that is safe | ||
| 50 | only because nothing retries automatically; the moment a client retries or | ||
| 51 | resumes, it can double-debit. | ||
| 52 | |||
| 53 | ``` | ||
| 54 | REQ 43 idem=9c2e... PLAY_START crash 50 | ||
| 55 | ``` | ||
| 56 | |||
| 57 | Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The | ||
| 58 | replay check, the debit, and the row insert sit in the same transaction as the | ||
| 59 | existing reservation (`database::reserve_play`). A duplicate returns the stored | ||
| 60 | reply and never re-executes. TTL-sweep at startup next to | ||
| 61 | `recover_orphaned_reservations`. Scope by user, not connection, so a resumed | ||
| 62 | session cannot replay someone else's key. | ||
| 63 | |||
| 64 | ## 3. Clock sync and a real tick stream | ||
| 65 | |||
| 66 | zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`, | ||
| 67 | `src/bin/server/handler.rs:7`) and the client draws crash locally from an | ||
| 68 | `Instant`. Payouts are correctly server-clock authoritative, but the client | ||
| 69 | does not know its offset. Two parts: | ||
| 70 | |||
| 71 | **Clock sync**, Cristian/NTP style over the existing connection: | ||
| 72 | |||
| 73 | ``` | ||
| 74 | REQ 7 idem=... TIME t0=837412 | ||
| 75 | RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms | ||
| 76 | ``` | ||
| 77 | |||
| 78 | `offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding | ||
| 79 | window. Render timed games from `server_now`. | ||
| 80 | |||
| 81 | **Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics: | ||
| 82 | |||
| 83 | ``` | ||
| 84 | EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44} | ||
| 85 | ``` | ||
| 86 | |||
| 87 | Coalesce in the writer: replace an unqueued tick, never drop a settlement. | ||
| 88 | That also enables lag compensation — render at `server_now - presentation_delay` | ||
| 89 | (e.g. 150 ms) so the displayed number never exceeds what the server accepts at | ||
| 90 | the moment of a click. A cleaner fairness story than trusting the client clock. | ||
| 91 | |||
| 92 | ## 4. Commit–reveal instead of a hidden `Option<f64>` | ||
| 93 | |||
| 94 | zagan-bet already withholds the crash point (`CrashState::crash_point`, | ||
| 95 | `src/crash.rs`). Make it verifiable: | ||
| 96 | |||
| 97 | ``` | ||
| 98 | RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id | ||
| 99 | ...round runs... | ||
| 100 | EVT seq=980 crash.busted {"seed":"<server_seed>","crash_point":2.37,...} | ||
| 101 | ``` | ||
| 102 | |||
| 103 | Player supplies a client seed; the server echoes it. Crash point derives from | ||
| 104 | `HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing | ||
| 105 | `crash_point_from_uniform`. Verification is a pure function and belongs in a | ||
| 106 | unit test, not a network test. A hash chain lets the server pre-commit every | ||
| 107 | future round at session start; that is stronger than needed in most cases. | ||
| 108 | |||
| 109 | ## 5. Framing: length prefix, then maybe QUIC | ||
| 110 | |||
| 111 | Weaknesses in newline framing: | ||
| 112 | |||
| 113 | - `LineBuffer` grows without bound; a client that never sends `\n` is a memory | ||
| 114 | DoS. Reject oversized frames *before* allocating. | ||
| 115 | - `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only | ||
| 116 | safe while `serde_json` keeps escaping newlines — an implicit dependency. | ||
| 117 | |||
| 118 | ``` | ||
| 119 | [u32 LE len][u8 proto_version][u8 kind][payload] | ||
| 120 | ``` | ||
| 121 | |||
| 122 | This allows a move to `postcard`/CBOR later without touching the read loop. | ||
| 123 | The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY` | ||
| 124 | cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds | ||
| 125 | mid-round, and unreliable datagrams for ticks where latest-wins beats | ||
| 126 | retransmission. zagan-bet already uses rustls on the client, so this would also | ||
| 127 | drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a | ||
| 128 | rustls bump. | ||
| 129 | |||
| 130 | ## 6. Resume tokens and a blocking reader thread | ||
| 131 | |||
| 132 | `AUTH` returns an opaque HMAC-signed resume token plus a session id; | ||
| 133 | `RESUME <token> since=<seq>` replays missed `EVT`s from a bounded per-session | ||
| 134 | buffer. Turns disconnect from "forfeit everything" into a policy choice; a | ||
| 135 | server-clocked round is trivially resumable. In zagan-bet this contradicts the | ||
| 136 | AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out | ||
| 137 | and a grace window. | ||
| 138 | |||
| 139 | Separately, both ends poll with timers: the server's 50 ms read timeout, and | ||
| 140 | the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in | ||
| 141 | `TlsStream::read_available_line`. A dedicated blocking reader thread feeding an | ||
| 142 | `mpsc` removes the per-call timeout mutation, `expects_push`, and | ||
| 143 | `POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick | ||
| 144 | deadline, and shutdown. Still no async runtime. | ||
| 145 | |||
| 146 | ## 7. Small ones worth batching | ||
| 147 | |||
| 148 | - `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of | ||
| 149 | strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing. | ||
| 150 | - Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the | ||
| 151 | frontend can tell "reconnect" from "show text". | ||
| 152 | - `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP | ||
| 153 | connection is otherwise noticed only on the next write. | ||
| 154 | - Per-connection token-bucket rate limiting. Every inbound line is logged, so a | ||
| 155 | chatty client is also a log-flood/disk problem. | ||
| 156 | - A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics | ||
| 157 | and `balance >= 0` for arbitrary input, plus transcript-replay tests. | ||
| 158 | |||
| 159 | ## If only three | ||
| 160 | |||
| 161 | 1. Correlation IDs + idempotency keys — small, kills two bug classes. | ||
| 162 | 2. Clock sync + coalesced ticks — turns a special-case push into a generic | ||
| 163 | event channel. | ||
| 164 | 3. Commit–reveal — cheap, and the strongest fairness story for a betting | ||
| 165 | server. | ||
| 166 | |||
| 167 | The envelope plus idempotency is the least invasive pair: it touches | ||
| 168 | `src/protocol.rs`, `ServerCore::handle_message`, and | ||
| 169 | `ClientCore::handle_reply`, and can sit behind a `caps=` flag so version | ||
| 170 | 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. | |||
| 210 | 210 | ||
| 211 | 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and | 211 | 1. `play crash` shows the ready board with a 1.00x multiplier; Space starts and |
| 212 | reserves the bet. | 212 | reserves the bet. |
| 213 | 2. The multiplier rises smoothly with no network traffic. Cash out with C and | 213 | 2. The multiplier rises smoothly from the server-clock anchor and is refreshed |
| 214 | confirm the board shows the cashout multiplier, the payout and the new | 214 | by 10 Hz ticks. Cash out with C and confirm the board shows the cashout |
| 215 | balance. | 215 | multiplier, the payout and the new balance. |
| 216 | 3. On another round, press nothing. The board must switch to `CRASHED at Xx` | 216 | 3. On another round, press nothing. The board must switch to `CRASHED at Xx` |
| 217 | by itself when the crash point is reached, and the bet must be lost. | 217 | by itself when the crash point is reached, and the bet must be lost. |
| 218 | 4. Press C just as a round crashes. Either it pays or the crashed result wins; | 218 | 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. | |||
| 220 | 5. Q during a live round forfeits the bet; Q when idle just leaves. | 220 | 5. Q during a live round forfeits the bet; Q when idle just leaves. |
| 221 | 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, | 221 | 6. Check the audit rows: one per round, `game_type = crash`, the reserved bet, |
| 222 | the payout, and `crash_point`/`multiplier`/`result` in `details`. | 222 | the payout, and `crash_point`/`multiplier`/`result` in `details`. |
| 223 | 7. Confirm the crash point is absent from the `CRASH_START` line (it must only | 223 | 7. Confirm the crash point and seed are absent from the `CRASH_START` message; |
| 224 | appear once the round is over). | 224 | only `commit` and `started_at` appear. When the round ends, `seed` appears |
| 225 | and `sha256(seed) == commit`; recompute the crash point from the seed. | ||
| 226 | 8. Confirm `CRASH_BUSTED` is an `EVT`, not a `RES`, and that a second poll | ||
| 227 | produces no repeated settlement. | ||
| 225 | 228 | ||
| 226 | ## Manual restart check | 229 | ## Manual restart check |
| 227 | 230 | ||
| @@ -237,7 +240,7 @@ terminal client; a saved login from one signs in the other. | |||
| 237 | 240 | ||
| 238 | 1. Connect a client and server from the same release: the client connects and | 241 | 1. Connect a client and server from the same release: the client connects and |
| 239 | the server log shows the `HELLO` exchange. | 242 | the server log shows the `HELLO` exchange. |
| 240 | 2. Connect a 4.x client to a 3.x server (or the reverse): the client must print | 243 | 2. Connect a 5.x client to a 4.x server (or the reverse): the client must print |
| 241 | `Version mismatch: client ..., server ...` and never reach the login prompt. | 244 | `Version mismatch: client ..., server ...` and never reach the login prompt. |
| 242 | 3. With a deliberately skew-built server (PATCH differs) the connection is | 245 | 3. With a deliberately skew-built server (PATCH differs) the connection is |
| 243 | accepted and, when the server's version is newer, the client prints | 246 | accepted and, when the server's version is newer, the client prints |
| @@ -269,9 +272,11 @@ terminal client; a saved login from one signs in the other. | |||
| 269 | 272 | ||
| 270 | ## Packaging checklist | 273 | ## Packaging checklist |
| 271 | 274 | ||
| 272 | 1. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), | 275 | 1. Set the version in `Cargo.toml`, `gui/Cargo.toml` and `webd/Cargo.toml` |
| 273 | run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree | 276 | (same MAJOR.MINOR), run `cargo test --workspace`, build the browser app |
| 274 | because the site links the committed git history as the GPL source. | 277 | (`cd web/play && npm ci && npm run build`), and commit. `publish.sh` refuses |
| 278 | a dirty tree because the site links the committed git history as the GPL | ||
| 279 | source. | ||
| 275 | 2. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI | 280 | 2. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI |
| 276 | with `gui-release` and the terminal clients and servers. The installers are | 281 | with `gui-release` and the terminal clients and servers. The installers are |
| 277 | stable site files and are not regenerated. | 282 | stable site files and are not regenerated. |
| @@ -284,4 +289,33 @@ terminal client; a saved login from one signs in the other. | |||
| 284 | menu entry. It must install the newest version even when an older app sits | 289 | menu entry. It must install the newest version even when an older app sits |
| 285 | next to it, and refuse a corrupted download. | 290 | next to it, and refuse a corrupted download. |
| 286 | 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. | 291 | 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. |
| 292 | 6. Start `webd` against the staging server with a pinned fingerprint and open | ||
| 293 | `/play/` through nginx. Run the manual web checklist below. | ||
| 294 | |||
| 295 | ## Manual web checklist | ||
| 296 | |||
| 297 | 1. Open `/play/` in a fresh browser. The auth card must appear; the top bar | ||
| 298 | must not claim a balance until login. | ||
| 299 | 2. Register, then log in with the password. The lobby must show 1 000 Kr and | ||
| 300 | every game. Refresh the page; the session cookie must reattach and the | ||
| 301 | balance must survive (within `ZAGAN_GRACE_SECONDS`). | ||
| 302 | 3. Open the same URL in a second tab. The first tab must show "Opened in | ||
| 303 | another tab" and stop reconnecting; the second must continue. | ||
| 304 | 4. Slots: spin and watch the reels settle on the server result; check the | ||
| 305 | payout and balance. Roulette: place straight, dozen, column and outside | ||
| 306 | chips, right-click one back, clear, spin, and confirm the result. | ||
| 307 | 5. Blackjack: deal, hit/stand/double, then deal again; forfeiting must warn | ||
| 308 | and lose the reserved bet. | ||
| 309 | 6. Crash: start a round, watch the curve and ticks, cash out; on another | ||
| 310 | round let it bust. Confirm the reveal line shows `sha256(seed) == commit` | ||
| 311 | and the recomputed crash point matches. | ||
| 312 | 7. Refresh in the middle of a live crash round. The current view must return | ||
| 313 | with the same `commit` and a rising multiplier; the round must still settle. | ||
| 314 | 8. Close the tab mid-round and wait past the grace window. The next visit must | ||
| 315 | log in again, and the server must have forfeited the round. | ||
| 316 | 9. Stop the gateway. The browser must show the reconnect overlay, then recover | ||
| 317 | when `webd` returns. Stop it for longer than the grace window: the round is | ||
| 318 | forfeited and a login is required. | ||
| 319 | 10. Check the browser console: no credential is stored, the cookie is HttpOnly, | ||
| 320 | and the only network target is `/ws`. | ||
| 287 | 321 | ||
diff --git a/docs/WEB-PLAN.md b/docs/WEB-PLAN.md new file mode 100644 index 0000000..895687b --- /dev/null +++ b/docs/WEB-PLAN.md | |||
| @@ -0,0 +1,129 @@ | |||
| 1 | # Browser frontend plan | ||
| 2 | |||
| 3 | Status: v1 implemented in 5.0.0. This is the working plan and the deployment | ||
| 4 | contract; see `CONTEXT.md` and ADRs 0003/0004 for the decisions. | ||
| 5 | |||
| 6 | ## Shape | ||
| 7 | |||
| 8 | ``` | ||
| 9 | browser ──wss──> nginx ──ws──> webd ──TLS──> game server | ||
| 10 | /play/ /ws 127.0.0.1:8890 127.0.0.1:11525 | ||
| 11 | static files gateway one thread per authoritative rules, | ||
| 12 | browser session balances, crash clock | ||
| 13 | ``` | ||
| 14 | |||
| 15 | - `web/play/` is a static TypeScript app. It sends `Command` JSON and renders | ||
| 16 | `View` JSON. It has no rules and no wire parser. | ||
| 17 | - `webd/` is a workspace member. It owns one `ClientCore`, one upstream TLS | ||
| 18 | connection and a 60-second grace window per browser session. | ||
| 19 | - `src/protocol.rs` is v5: bounded frames, `REQ`/`RES`/`EVT`, request ids, | ||
| 20 | server time and crash commit–reveal. | ||
| 21 | |||
| 22 | ## Phases | ||
| 23 | |||
| 24 | 1. **Protocol v5** — frames, envelope, correlation, replay cache, clock sync, | ||
| 25 | ticks, commit–reveal, TUI/GUI updates. Done in the 5.0.0 change. | ||
| 26 | 2. **Gateway** — WS accept, cookie session registry, takeover, grace, pinned | ||
| 27 | upstream, `Command`/`View` JSON. Done in the 5.0.0 change. | ||
| 28 | 3. **Web shell** — login/register, lobby, history, notices, route at `/play/`. | ||
| 29 | Done in the 5.0.0 change. | ||
| 30 | 4. **Games** — slots, roulette (felt + chips), blackjack, crash (curve + | ||
| 31 | verification). Done in the 5.0.0 change. | ||
| 32 | 5. **Deployment** — nginx routes, static build, systemd unit, verification in | ||
| 33 | `scripts/deploy.sh`. Done in the 5.0.0 change. | ||
| 34 | |||
| 35 | ## Browser contract | ||
| 36 | |||
| 37 | Browser → gateway: | ||
| 38 | |||
| 39 | ```json | ||
| 40 | {"type":"command","command":{"Login":{"user":"alice","credential":"<sha256 hex>"}}} | ||
| 41 | {"type":"command","command":{"Play":{"game":"crash","bet":10}}} | ||
| 42 | {"type":"command","command":"Start"} | ||
| 43 | {"type":"ping"} | ||
| 44 | ``` | ||
| 45 | |||
| 46 | `Command` uses serde's external tagging because several variants carry a | ||
| 47 | primitive or a string. Unit variants are bare strings (`"Spin"`, `"Start"`). | ||
| 48 | |||
| 49 | Gateway → browser: | ||
| 50 | |||
| 51 | ```json | ||
| 52 | {"type":"hello","version":"5.0.0","grace_seconds":60,"user":"alice","time":1700000000000} | ||
| 53 | {"type":"view","view":{"screen":"lobby", "...": "..."}} | ||
| 54 | {"type":"notice","text":"Logged in as alice"} | ||
| 55 | {"type":"error","text":"Upstream connection failed: ..."} | ||
| 56 | {"type":"replaced"} | ||
| 57 | {"type":"pong"} | ||
| 58 | ``` | ||
| 59 | |||
| 60 | `View` is internally tagged by `screen` (`lobby`, `history`, `slots`, | ||
| 61 | `roulette`, `blackjack`, `crash`). The browser renders each screen; it never | ||
| 62 | computes a result. | ||
| 63 | |||
| 64 | The cookie is `zagan_session`, HttpOnly, SameSite=Strict, Secure on HTTPS. It | ||
| 65 | is set on the WebSocket handshake. It covers refresh and the grace window only; | ||
| 66 | there is no remember-me. | ||
| 67 | |||
| 68 | ## Build and run | ||
| 69 | |||
| 70 | ```sh | ||
| 71 | # static app | ||
| 72 | cd web/play | ||
| 73 | npm ci | ||
| 74 | npm run build # writes app.js; commit it | ||
| 75 | |||
| 76 | # gateway | ||
| 77 | cargo build -p zagan-bet-webd --release | ||
| 78 | ZAGAN_UPSTREAM=127.0.0.1:11525 \ | ||
| 79 | ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> \ | ||
| 80 | ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz \ | ||
| 81 | ZAGAN_WEB_BIND=127.0.0.1:8890 \ | ||
| 82 | target/release/zagan-bet-webd | ||
| 83 | ``` | ||
| 84 | |||
| 85 | The fingerprint is the same SPKI SHA-256 the desktop client shows: | ||
| 86 | |||
| 87 | ```sh | ||
| 88 | openssl x509 -in cert.pem -pubkey -noout \ | ||
| 89 | | openssl pkey -pubin -outform DER \ | ||
| 90 | | sha256sum | ||
| 91 | ``` | ||
| 92 | |||
| 93 | Test against a staging site with | ||
| 94 | `ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899 ZAGAN_WEB_COOKIE_SECURE=0`. | ||
| 95 | |||
| 96 | ## Security boundaries | ||
| 97 | |||
| 98 | - The gateway binds loopback. nginx terminates TLS and is the only public | ||
| 99 | entry. Do not expose `ZAGAN_WEB_BIND` to the internet. | ||
| 100 | - The upstream key is pinned; there is no TOFU and no unsafe mode. A mismatch | ||
| 101 | logs both fingerprints and refuses credentials. | ||
| 102 | - The browser has no certificate UI and no credential storage. XSS cannot read | ||
| 103 | the HttpOnly cookie; the backend still must escape every notice. | ||
| 104 | - The browser speaks JSON, not the game wire. A malicious page cannot use the | ||
| 105 | gateway as a protocol proxy: `Origin` is checked and the session cookie is | ||
| 106 | SameSite=Strict. | ||
| 107 | - The web app is play-money only. No deposits, withdrawals, KYC or real-money | ||
| 108 | claims belong in v1 copy. | ||
| 109 | |||
| 110 | ## Failure behaviour | ||
| 111 | |||
| 112 | | Event | Result | | ||
| 113 | | --- | --- | | ||
| 114 | | Refresh / new tab, within grace | Same `ClientCore`, current `View` restored | | ||
| 115 | | New tab, same cookie | Old tab gets `replaced` and closes; new tab continues | | ||
| 116 | | Socket closed, grace expires | Upstream closes; server forfeits live hand | | ||
| 117 | | Gateway restart | Sessions lost; players log in again | | ||
| 118 | | Upstream disconnect | Browser gets an error, core resets, reconnect attempts | | ||
| 119 | | Browser retry of a request | Gateway does not retry; request ids are per connection | | ||
| 120 | |||
| 121 | ## Verification | ||
| 122 | |||
| 123 | - `cargo test --workspace` covers frames, envelope parsing, idempotent replay, | ||
| 124 | commit verification, ticks, core routing and session JSON. | ||
| 125 | - `web/play`: `npm run typecheck` and `npm run build`. | ||
| 126 | - Manual WS smoke test: connect, login, play slots/crash, refresh in a live | ||
| 127 | crash round, verify the revealed seed. | ||
| 128 | - `scripts/deploy.sh` checks `/play/`, `app.js`, `style.css` and the existing | ||
| 129 | download feed. | ||
diff --git a/docs/adr/0001-gui-frontend-architecture.md b/docs/adr/0001-gui-frontend-architecture.md index 44dca32..e4dd5ac 100644 --- a/docs/adr/0001-gui-frontend-architecture.md +++ b/docs/adr/0001-gui-frontend-architecture.md | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | # 0001 — GUI is a second frontend over a shared core | 1 | # 0001 — GUI is a second frontend over a shared core |
| 2 | 2 | ||
| 3 | Status: accepted, 2026-11-01 | 3 | Status: accepted, 2026-11-01; the "no browser" constraint is superseded by |
| 4 | [0003](0003-web-gateway.md), which adds a browser frontend through a gateway. | ||
| 4 | 5 | ||
| 5 | ## Context | 6 | ## Context |
| 6 | 7 | ||
diff --git a/docs/adr/0003-web-gateway.md b/docs/adr/0003-web-gateway.md new file mode 100644 index 0000000..5337e2b --- /dev/null +++ b/docs/adr/0003-web-gateway.md | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | # 0003 — Browser frontend through a server-side gateway | ||
| 2 | |||
| 3 | Status: accepted, 2026-11-08 | ||
| 4 | |||
| 5 | ## Context | ||
| 6 | |||
| 7 | The game already runs over TLS/TCP with a newline-delimited text protocol. A | ||
| 8 | browser cannot open a TCP socket, and putting game rules into JavaScript would | ||
| 9 | create a second implementation of payout, session and forfeit logic — exactly | ||
| 10 | what the project forbids. | ||
| 11 | |||
| 12 | The site must stay a website in the ordinary sense: open `zagan.bet`, log in, | ||
| 13 | play. Nothing to install. Balances are play credits; there are no deposits, | ||
| 14 | withdrawals or KYC in this decision. | ||
| 15 | |||
| 16 | ## Decision | ||
| 17 | |||
| 18 | 1. **The browser is a third frontend over `ClientCore`.** It sends `Command` | ||
| 19 | JSON and renders `View` JSON. It contains no rules, no wire-protocol parser | ||
| 20 | and no balance arithmetic. | ||
| 21 | 2. **A Rust gateway, `webd/`, owns each session.** One blocking thread per | ||
| 22 | browser session, one `ClientCore`, and one ordinary client TLS connection | ||
| 23 | upstream. The existing `client::transport` and `ClientCore` are reused. No | ||
| 24 | async runtime and no new concurrency style. | ||
| 25 | 3. **`webd/` is a separate workspace member.** Like `gui/`, its dependencies | ||
| 26 | (tungstenite, HTTP cookie handling) must never enter the TUI, GUI or server | ||
| 27 | build graph. | ||
| 28 | 4. **Everything is same-origin and operator-configured.** nginx terminates | ||
| 29 | TLS, serves the static app at `/play/`, and proxies `/ws` to the gateway on | ||
| 30 | loopback. The browser never sees a certificate, there is no CORS, and the | ||
| 31 | upstream address (`ZAGAN_UPSTREAM`, default `127.0.0.1:11525`) and SPKI | ||
| 32 | fingerprint (`ZAGAN_UPSTREAM_FINGERPRINT`, required) are gateway config. | ||
| 33 | A mismatch refuses to connect and logs both fingerprints. There is no TOFU | ||
| 34 | and no unsafe mode for the upstream leg. | ||
| 35 | 5. **Session-only authentication.** Log in per visit. The gateway issues a | ||
| 36 | random HttpOnly, SameSite=Strict, Secure session cookie; the cookie covers a | ||
| 37 | refresh and the grace window only. No credential is stored in the browser or | ||
| 38 | in the gateway. A new connection with the same cookie takes over the session | ||
| 39 | and the old socket is told it was replaced. | ||
| 40 | 6. **60-second grace window.** When the last browser socket for a session | ||
| 41 | closes, the session thread keeps the upstream connection and `ClientCore` | ||
| 42 | alive for 60 seconds (configurable). A reconnecting socket with the same | ||
| 43 | cookie reattaches and immediately receives the current `View`. Expiry closes | ||
| 44 | upstream, and the server forfeits a live hand per the existing disconnect | ||
| 45 | rule. There is no wire-level resume: the gateway, not the browser, holds the | ||
| 46 | TCP connection. | ||
| 47 | 7. **Vanilla TypeScript + esbuild.** DOM for login, lobby, history and notices; | ||
| 48 | one canvas for the game surface. No framework runtime. Theme tokens are | ||
| 49 | reused from the desktop palette; felt green is reserved for roulette. | ||
| 50 | 8. **Web v1 has full parity after trust.** Register, log in, lobby, all four | ||
| 51 | games, history, log out, notices, refresh grace. The connect/trust screens | ||
| 52 | do not exist in the browser because the gateway owns the upstream key. | ||
| 53 | |||
| 54 | ## Consequences | ||
| 55 | |||
| 56 | - One implementation of the rules and parsers remains authoritative, and the | ||
| 57 | TUI/GUI/gateway all exercise the same `ClientCore` tests. | ||
| 58 | - The gateway becomes an operational component: it needs a service unit, | ||
| 59 | deployment, logging, a required upstream fingerprint, and a restart story. | ||
| 60 | A gateway restart loses live sessions and therefore forfeits their rounds; | ||
| 61 | this is accepted for v1. | ||
| 62 | - The browser cannot recover from a gateway crash by itself. The player logs in | ||
| 63 | again. | ||
| 64 | - A 60-second grace window means a live round can outlive a refresh, but not a | ||
| 65 | closed laptop for a minute and a half. | ||
| 66 | - WebSocket frames are same-origin JSON, so the page cannot be used as an open | ||
| 67 | proxy by another origin. The gateway also checks the `Origin` header against | ||
| 68 | `ZAGAN_WEB_ORIGIN`. | ||
| 69 | - The web app is play-money only. Adding payments later would be a separate | ||
| 70 | product and compliance decision, not a UI change. | ||
| 71 | |||
| 72 | ## Alternatives considered | ||
| 73 | |||
| 74 | - **WASM `ClientCore` in the browser, dumb relay**: reuses Rust in the page, | ||
| 75 | but needs transport, TOFU and credential shims in wasm and a multi-megabyte | ||
| 76 | download for every player. Rejected. | ||
| 77 | - **JavaScript rewrite speaking the game protocol**: fastest UI iteration, but | ||
| 78 | a second implementation of rules and payout invariants. Rejected. | ||
| 79 | - **Tokio/axum gateway**: idiomatic WebSocket server, but a large async tree | ||
| 80 | and a second concurrency style for one blocking, low-traffic product. | ||
| 81 | Rejected. | ||
| 82 | - **Long-lived remember-me in the gateway**: better return visits, but a new | ||
| 83 | credential store and revocation flow. Deferred; session-only first. | ||
| 84 | - **Protocol-level resume tokens**: survives a gateway restart, but is a wire | ||
| 85 | change and more state on the server. Deferred. | ||
diff --git a/docs/adr/0004-protocol-v5.md b/docs/adr/0004-protocol-v5.md new file mode 100644 index 0000000..c74ad83 --- /dev/null +++ b/docs/adr/0004-protocol-v5.md | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | # 0004 — Protocol v5: frames, envelopes, time and commit–reveal | ||
| 2 | |||
| 3 | Status: accepted, 2026-11-08 | ||
| 4 | |||
| 5 | ## Context | ||
| 6 | |||
| 7 | The current wire is newline-delimited text over TLS. It has served the TUI and | ||
| 8 | GUI well, but four problems are now worth fixing before a browser gateway is | ||
| 9 | built on top: | ||
| 10 | |||
| 11 | - `LineBuffer` grows without bound if a peer never sends a newline, and there | ||
| 12 | is no explicit maximum message size. | ||
| 13 | - A reply is assumed to answer the request that is currently pending. A late | ||
| 14 | reply, a retry or a second in-flight request can clobber state. | ||
| 15 | - There is no idempotency: a gateway retry after a timeout can charge a bet | ||
| 16 | twice. | ||
| 17 | - The crash multiplier is anchored to each frontend's local clock, and the | ||
| 18 | crash point is only "trust the server". | ||
| 19 | |||
| 20 | The web gateway makes the first three worse (it retries and multiplexes) and | ||
| 21 | the browser makes the fourth visible (a page and the desktop can disagree). | ||
| 22 | |||
| 23 | ## Decision | ||
| 24 | |||
| 25 | This is one wire change and one `MAJOR` release (`5.0.0`); there is no | ||
| 26 | compatibility mode. | ||
| 27 | |||
| 28 | ### Framing | ||
| 29 | |||
| 30 | - Every packet is `[u32 length][payload]`, length big-endian. | ||
| 31 | - `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a | ||
| 32 | protocol error and the connection closes; buffers never grow past one frame. | ||
| 33 | - The payload has no trailing newline. | ||
| 34 | |||
| 35 | ### Envelope | ||
| 36 | |||
| 37 | - `REQ <id> [idem=<token>] <command>` — client request. `<id>` is a `u64` | ||
| 38 | chosen by the client, monotonically increasing on the connection. | ||
| 39 | - `RES <id> <body>` — the reply to that request. | ||
| 40 | - `EVT <seq> <body>` — a server-initiated event. `<seq>` is a `u64`, | ||
| 41 | incremented once per event on the connection. | ||
| 42 | |||
| 43 | The existing text grammar is the `<command>` and `<body>`; `HELLO 5.0.0`, | ||
| 44 | `PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning. | ||
| 45 | |||
| 46 | `HELLO` is the first framed request. The server ignores every other command | ||
| 47 | before a successful handshake. The reply is | ||
| 48 | `HELLO <version> latest=<v> url=<url> time=<unix_ms>`. | ||
| 49 | |||
| 50 | ### Correlation and idempotency | ||
| 51 | |||
| 52 | - The client keeps a request id until its `RES` arrives. Replies are routed by | ||
| 53 | id, never by "the next line". | ||
| 54 | - The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each | ||
| 55 | connection. A repeated id with the same payload returns the stored reply | ||
| 56 | without executing the command again. A repeated id with a different payload | ||
| 57 | returns `ERROR Request id reused with a different command`. | ||
| 58 | - The cache is per connection and in memory. A reconnect is a new game session | ||
| 59 | by design; durable idempotency is deferred. | ||
| 60 | - `idem=<token>` is optional metadata for logs and for a future cross- | ||
| 61 | connection cache. The request id is the cache key. | ||
| 62 | |||
| 63 | ### Clock and ticks | ||
| 64 | |||
| 65 | - `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING` | ||
| 66 | replies `PONG <unix_ms>`. | ||
| 67 | - Clients keep an offset between server time and their own clock. The offset is | ||
| 68 | for display and interpolation only; the server clock settles a cash-out. | ||
| 69 | - A crash start carries `started_at=<unix_ms>` and `commit=<hex>`. | ||
| 70 | - While a crash round runs, the server pushes at most one | ||
| 71 | `EVT <seq> crash.tick session=<id> t=<unix_ms> m=<multiplier>` per 100 ms. | ||
| 72 | The client interpolates between ticks and may keep rendering locally. | ||
| 73 | - `CRASH_BUSTED` remains an event, now with the reveal attached. | ||
| 74 | |||
| 75 | ### Commit–reveal crash | ||
| 76 | |||
| 77 | - At round start the server draws 32 random bytes as the round seed and sends | ||
| 78 | `commit = sha256(seed)` in hex. It also sends the crash point's start time. | ||
| 79 | - The crash point is a pure function of the seed: | ||
| 80 | `u = u64_be(seed[0..8]) / 2^64`, then | ||
| 81 | `crash_point_from_uniform(u)`. The seed never reaches the client before the | ||
| 82 | round ends. | ||
| 83 | - When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the | ||
| 84 | server includes `seed=<hex>`. A client verifies `sha256(seed) == commit` and | ||
| 85 | recomputes the crash point. The UI can show "verified". | ||
| 86 | - This proves the crash point was fixed before the player acted. It does not | ||
| 87 | prove the seed was not ground to a favourable value; a hash chain with client | ||
| 88 | entropy is the documented next step if that matters. | ||
| 89 | |||
| 90 | ## Consequences | ||
| 91 | |||
| 92 | - The TUI, GUI, server and gateway all move to v5 together; a v4 peer is | ||
| 93 | refused at `HELLO`. | ||
| 94 | - `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based | ||
| 95 | tests and helpers are rewritten around frames. | ||
| 96 | - `ClientCore` allocates request ids and routes replies by id. Its `Effect` | ||
| 97 | carries the id, so an adapter writes the envelope and hands the `RES` back | ||
| 98 | with the same id. | ||
| 99 | - Crash rendering can be smoother and consistent across frontends, and the | ||
| 100 | crash result is independently checkable from the revealed seed. | ||
| 101 | - The per-connection cache is deliberately small and forgets on reconnect; a | ||
| 102 | retry after a reconnect is a new action, not a replay. | ||
| 103 | |||
| 104 | ## Alternatives considered | ||
| 105 | |||
| 106 | - **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite | ||
| 107 | of every parser for no scale need. Text inside a bounded frame keeps logs | ||
| 108 | readable and the change small. | ||
| 109 | - **Newline-delimited frames with a maximum line length**: still needs a | ||
| 110 | separate envelope and does not bound the buffer as cleanly; a declared length | ||
| 111 | is simpler. | ||
| 112 | - **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the | ||
| 113 | gateway owns retries on one connection and a reconnect is a fresh session. | ||
| 114 | Deferred. | ||
| 115 | - **Server seed plus client seed without a commit round-trip**: the server | ||
| 116 | sees the client seed before committing and can bias the pair; a half-measure | ||
| 117 | is worse than an honest per-round commitment. | ||
| 118 | - **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain | ||
| 119 | state and a restart story. Deferred. | ||
diff --git a/gui/Cargo.toml b/gui/Cargo.toml index e216309..8dc09df 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml | |||
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet-gui" | 2 | name = "zagan-bet-gui" |
| 3 | version = "4.4.0" | 3 | version = "5.0.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | description = "Desktop frontend for zagan-bet" | 6 | 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; | |||
| 16 | use zagan_bet::client::paths; | 16 | use zagan_bet::client::paths; |
| 17 | use zagan_bet::client::view::{Command, LobbyView, View}; | 17 | use zagan_bet::client::view::{Command, LobbyView, View}; |
| 18 | use zagan_bet::history::PlayRecord; | 18 | use zagan_bet::history::PlayRecord; |
| 19 | use zagan_bet::protocol::Packet; | ||
| 19 | use zagan_bet::version::{VERSION, is_newer}; | 20 | use zagan_bet::version::{VERSION, is_newer}; |
| 20 | 21 | ||
| 21 | use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; | 22 | use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; |
| @@ -280,8 +281,8 @@ impl ZaganApp { | |||
| 280 | } | 281 | } |
| 281 | } | 282 | } |
| 282 | } | 283 | } |
| 283 | Effect::SendLine(line) => { | 284 | Effect::SendRequest { id, payload } => { |
| 284 | self.net.send(NetCommand::Send(line)); | 285 | self.net.send(NetCommand::Send { id, payload }); |
| 285 | self.awaiting = Some(Instant::now()); | 286 | self.awaiting = Some(Instant::now()); |
| 286 | } | 287 | } |
| 287 | Effect::Disconnect => { | 288 | Effect::Disconnect => { |
| @@ -355,17 +356,18 @@ impl ZaganApp { | |||
| 355 | failure, | 356 | failure, |
| 356 | message, | 357 | message, |
| 357 | } => self.failed(addr, failure, message), | 358 | } => self.failed(addr, failure, message), |
| 358 | Event::Line(line) => { | 359 | Event::Packet(packet) => match packet { |
| 359 | if self.core.is_async(&line) { | 360 | Packet::Event { body, .. } => { |
| 360 | if let Some(effects) = self.core.handle_async_line(&line) { | 361 | if let Some(effects) = self.core.handle_async_line(&body) { |
| 361 | self.apply(ctx, effects); | 362 | self.apply(ctx, effects); |
| 362 | } | 363 | } |
| 363 | } else { | 364 | } |
| 365 | Packet::Reply { id, body } => { | ||
| 364 | self.awaiting = None; | 366 | self.awaiting = None; |
| 365 | let effects = self.core.handle_reply(&line); | 367 | let effects = self.core.handle_reply_id(id, &body); |
| 366 | self.apply(ctx, effects); | 368 | self.apply(ctx, effects); |
| 367 | } | 369 | } |
| 368 | } | 370 | }, |
| 369 | Event::Closed => { | 371 | Event::Closed => { |
| 370 | self.session = None; | 372 | self.session = None; |
| 371 | self.awaiting = None; | 373 | self.awaiting = None; |
| @@ -384,6 +386,7 @@ impl ZaganApp { | |||
| 384 | self.core | 386 | self.core |
| 385 | .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); | 387 | .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); |
| 386 | self.core.set_logged_in(connected.user.clone()); | 388 | self.core.set_logged_in(connected.user.clone()); |
| 389 | self.core.set_server_time(connected.server_time); | ||
| 387 | self.login.error = connected.auth_error.clone(); | 390 | self.login.error = connected.auth_error.clone(); |
| 388 | self.login.user = connected.user.clone().unwrap_or_default(); | 391 | self.login.user = connected.user.clone().unwrap_or_default(); |
| 389 | 392 | ||
diff --git a/gui/src/net.rs b/gui/src/net.rs index 7cba604..7882cd3 100644 --- a/gui/src/net.rs +++ b/gui/src/net.rs | |||
| @@ -14,6 +14,7 @@ use eframe::egui; | |||
| 14 | use rusqlite::Connection; | 14 | use rusqlite::Connection; |
| 15 | use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; | 15 | use zagan_bet::client::transport::{self, TlsStream, TransportError, TrustPrompt}; |
| 16 | use zagan_bet::client::{database, paths}; | 16 | use zagan_bet::client::{database, paths}; |
| 17 | use zagan_bet::protocol::Packet; | ||
| 17 | 18 | ||
| 18 | /// How often the worker checks the socket for pushes when idle. | 19 | /// How often the worker checks the socket for pushes when idle. |
| 19 | const POLL: Duration = Duration::from_millis(8); | 20 | const POLL: Duration = Duration::from_millis(8); |
| @@ -24,7 +25,7 @@ const TRUST_TIMEOUT: Duration = Duration::from_secs(120); | |||
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] | 25 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 25 | pub enum Command { | 26 | pub enum Command { |
| 26 | Connect { addr: String }, | 27 | Connect { addr: String }, |
| 27 | Send(String), | 28 | Send { id: u64, payload: String }, |
| 28 | Disconnect, | 29 | Disconnect, |
| 29 | Stop, | 30 | Stop, |
| 30 | } | 31 | } |
| @@ -49,6 +50,7 @@ pub struct Connected { | |||
| 49 | pub latest: String, | 50 | pub latest: String, |
| 50 | pub url: String, | 51 | pub url: String, |
| 51 | pub elapsed_ms: f32, | 52 | pub elapsed_ms: f32, |
| 53 | pub server_time: u64, | ||
| 52 | } | 54 | } |
| 53 | 55 | ||
| 54 | /// What the worker tells the UI. | 56 | /// What the worker tells the UI. |
| @@ -65,7 +67,7 @@ pub enum Event { | |||
| 65 | failure: Failure, | 67 | failure: Failure, |
| 66 | message: String, | 68 | message: String, |
| 67 | }, | 69 | }, |
| 68 | Line(String), | 70 | Packet(Packet), |
| 69 | Closed, | 71 | Closed, |
| 70 | } | 72 | } |
| 71 | 73 | ||
| @@ -164,6 +166,7 @@ fn worker( | |||
| 164 | latest: connected.latest, | 166 | latest: connected.latest, |
| 165 | url: connected.url, | 167 | url: connected.url, |
| 166 | elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, | 168 | elapsed_ms: connected.elapsed.as_secs_f64() as f32 * 1000.0, |
| 169 | server_time: connected.server_time, | ||
| 167 | })); | 170 | })); |
| 168 | stream = Some(connected.stream); | 171 | stream = Some(connected.stream); |
| 169 | } | 172 | } |
| @@ -177,9 +180,9 @@ fn worker( | |||
| 177 | } | 180 | } |
| 178 | ctx.request_repaint(); | 181 | ctx.request_repaint(); |
| 179 | } | 182 | } |
| 180 | Ok(Command::Send(line)) => { | 183 | Ok(Command::Send { id, payload }) => { |
| 181 | if let Some(open) = &mut stream { | 184 | if let Some(open) = &mut stream { |
| 182 | if open.write_line(&line).is_err() { | 185 | if open.write_request(id, None, &payload).is_err() { |
| 183 | stream = None; | 186 | stream = None; |
| 184 | let _ = events.send(Event::Closed); | 187 | let _ = events.send(Event::Closed); |
| 185 | } | 188 | } |
| @@ -198,9 +201,9 @@ fn worker( | |||
| 198 | // Drain pushes and replies without blocking the command channel. | 201 | // Drain pushes and replies without blocking the command channel. |
| 199 | if let Some(open) = &mut stream { | 202 | if let Some(open) = &mut stream { |
| 200 | loop { | 203 | loop { |
| 201 | match open.read_available_line() { | 204 | match open.read_available_packet() { |
| 202 | Ok(Some(line)) => { | 205 | Ok(Some(packet)) => { |
| 203 | if events.send(Event::Line(line)).is_err() { | 206 | if events.send(Event::Packet(packet)).is_err() { |
| 204 | return; | 207 | return; |
| 205 | } | 208 | } |
| 206 | } | 209 | } |
diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 63bd31c..0527505 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh | |||
| @@ -67,6 +67,7 @@ if [ "$VERIFY_ONLY" -eq 0 ]; then | |||
| 67 | RSYNC_FLAGS=( | 67 | RSYNC_FLAGS=( |
| 68 | -a --delay-updates --human-readable --chmod=D755,F644 | 68 | -a --delay-updates --human-readable --chmod=D755,F644 |
| 69 | --exclude=README.md --exclude=.gitignore | 69 | --exclude=README.md --exclude=.gitignore |
| 70 | --exclude=play/node_modules | ||
| 70 | ) | 71 | ) |
| 71 | 72 | ||
| 72 | if [[ "$TARGET" == *:* ]]; then | 73 | if [[ "$TARGET" == *:* ]]; then |
| @@ -160,6 +161,16 @@ for local in "${artifacts[@]}"; do | |||
| 160 | verified=$((verified + 1)) | 161 | verified=$((verified + 1)) |
| 161 | done | 162 | done |
| 162 | 163 | ||
| 164 | curl -fsSL --max-time 30 "$origin/play/" -o "$tmp/play.html" \ | ||
| 165 | || die "could not fetch the browser app at /play/" | ||
| 166 | grep -q 'app.js' "$tmp/play.html" || die "/play/ does not reference app.js" | ||
| 167 | for asset in app.js style.css; do | ||
| 168 | curl -fsSL --max-time 30 "$origin/play/$asset" -o "$tmp/$asset" \ | ||
| 169 | || die "could not fetch /play/$asset" | ||
| 170 | cmp -s "web/play/$asset" "$tmp/$asset" \ | ||
| 171 | || die "/play/$asset served from the site differs from the local file" | ||
| 172 | done | ||
| 173 | |||
| 163 | [ "$failed" -eq 0 ] || die "verification failed" | 174 | [ "$failed" -eq 0 ] || die "verification failed" |
| 164 | 175 | ||
| 165 | echo "==> $verified artifact(s) verified at $URL" | 176 | 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; | |||
| 8 | use std::error::Error; | 8 | use std::error::Error; |
| 9 | use zagan_bet::client::transport::{self, TrustPrompt}; | 9 | use zagan_bet::client::transport::{self, TrustPrompt}; |
| 10 | use zagan_bet::client::ui::first_contact_lines; | 10 | use zagan_bet::client::ui::first_contact_lines; |
| 11 | use zagan_bet::version::{is_newer, VERSION}; | 11 | use zagan_bet::version::{VERSION, is_newer}; |
| 12 | 12 | ||
| 13 | pub use zagan_bet::client::transport::TlsStream; | 13 | pub use zagan_bet::client::transport::TlsStream; |
| 14 | 14 | ||
| @@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { | |||
| 21 | pub fn connect( | 21 | pub fn connect( |
| 22 | addr: &str, | 22 | addr: &str, |
| 23 | conn: &rusqlite::Connection, | 23 | conn: &rusqlite::Connection, |
| 24 | ) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { | 24 | ) -> Result<(TlsStream, Option<String>, u64), Box<dyn Error>> { |
| 25 | let unsafe_mode = *UNSAFE_MODE.lock(); | 25 | let unsafe_mode = *UNSAFE_MODE.lock(); |
| 26 | 26 | ||
| 27 | // The terminal has no dialog: print the first-contact notice and trust. | 27 | // The terminal has no dialog: print the first-contact notice and trust. |
| @@ -53,5 +53,5 @@ pub fn connect( | |||
| 53 | println!("Authentication failed: {error}"); | 53 | println!("Authentication failed: {error}"); |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | Ok((connected.stream, connected.user)) | 56 | Ok((connected.stream, connected.user, connected.server_time)) |
| 57 | } | 57 | } |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index cfdcdd5..deb5743 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -14,15 +14,15 @@ use crossterm::{ | |||
| 14 | use sha2::{Digest, Sha256}; | 14 | use sha2::{Digest, Sha256}; |
| 15 | 15 | ||
| 16 | use zagan_bet::client::core::{ClientCore, Effect}; | 16 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 17 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | ||
| 18 | use zagan_bet::client::shell::{self, Shell}; | ||
| 19 | use zagan_bet::client::view::{RoulettePhase, View}; | ||
| 20 | use zagan_bet::client::database; | 17 | use zagan_bet::client::database; |
| 18 | use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; | ||
| 19 | use zagan_bet::client::shell::{self, Shell}; | ||
| 21 | use zagan_bet::client::ui::{ | 20 | use zagan_bet::client::ui::{ |
| 22 | display_width, format_pong, help_lines, host_lines, input_cursor_column, | 21 | MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column, |
| 23 | lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, | 22 | lines_to_move_up, terminal_size_warning, visual_line_count, |
| 24 | }; | 23 | }; |
| 25 | use zagan_bet::protocol::io_failure_message; | 24 | use zagan_bet::client::view::{RoulettePhase, View}; |
| 25 | use zagan_bet::protocol::{Packet, io_failure_message}; | ||
| 26 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; | 26 | use zagan_bet::roulette::{WHEEL, color as roulette_color}; |
| 27 | 27 | ||
| 28 | use crate::connection::TlsStream; | 28 | use crate::connection::TlsStream; |
| @@ -78,9 +78,10 @@ impl ClientState { | |||
| 78 | pub fn connect_to(&mut self, addr: &str) { | 78 | pub fn connect_to(&mut self, addr: &str) { |
| 79 | self.addr = Some(addr.to_string()); | 79 | self.addr = Some(addr.to_string()); |
| 80 | match crate::connection::connect(addr, &self.conn) { | 80 | match crate::connection::connect(addr, &self.conn) { |
| 81 | Ok((stream, authed_user)) => { | 81 | Ok((stream, authed_user, server_time)) => { |
| 82 | self.stream = Some(stream); | 82 | self.stream = Some(stream); |
| 83 | self.core.set_connected(true); | 83 | self.core.set_connected(true); |
| 84 | self.core.set_server_time(server_time); | ||
| 84 | self.core.set_logged_in(authed_user); | 85 | self.core.set_logged_in(authed_user); |
| 85 | let fingerprint = database::get_fingerprint(&self.conn, addr); | 86 | let fingerprint = database::get_fingerprint(&self.conn, addr); |
| 86 | self.core.set_host_info(Some(addr.to_string()), fingerprint); | 87 | self.core.set_host_info(Some(addr.to_string()), fingerprint); |
| @@ -126,9 +127,7 @@ impl ClientState { | |||
| 126 | key, | 127 | key, |
| 127 | ) { | 128 | ) { |
| 128 | GameInput::Quit => vec![Effect::Quit], | 129 | GameInput::Quit => vec![Effect::Quit], |
| 129 | GameInput::Command(command) => { | 130 | GameInput::Command(command) => self.core.command(command), |
| 130 | self.core.command(command) | ||
| 131 | } | ||
| 132 | GameInput::None => Vec::new(), | 131 | GameInput::None => Vec::new(), |
| 133 | } | 132 | } |
| 134 | } else { | 133 | } else { |
| @@ -189,10 +188,10 @@ impl ClientState { | |||
| 189 | let Some(mut stream) = self.stream.take() else { | 188 | let Some(mut stream) = self.stream.take() else { |
| 190 | return false; | 189 | return false; |
| 191 | }; | 190 | }; |
| 192 | let line = match stream.read_available_line() { | 191 | let packet = match stream.read_available_packet() { |
| 193 | Ok(line) => { | 192 | Ok(packet) => { |
| 194 | self.stream = Some(stream); | 193 | self.stream = Some(stream); |
| 195 | line | 194 | packet |
| 196 | } | 195 | } |
| 197 | Err(_) => { | 196 | Err(_) => { |
| 198 | self.core.set_connected(false); | 197 | self.core.set_connected(false); |
| @@ -201,13 +200,15 @@ impl ClientState { | |||
| 201 | } | 200 | } |
| 202 | }; | 201 | }; |
| 203 | 202 | ||
| 204 | let Some(line) = line else { | 203 | let Some(packet) = packet else { |
| 205 | return false; | 204 | return false; |
| 206 | }; | 205 | }; |
| 207 | 206 | ||
| 208 | let effects = match self.core.handle_async_line(&line) { | 207 | let effects = match packet { |
| 209 | Some(effects) => effects, | 208 | Packet::Event { body, .. } => { |
| 210 | None => self.core.handle_reply(&line), | 209 | self.core.handle_async_line(&body).unwrap_or_default() |
| 210 | } | ||
| 211 | Packet::Reply { id, body } => self.core.handle_reply_id(id, &body), | ||
| 211 | }; | 212 | }; |
| 212 | if self.apply_effects(effects) { | 213 | if self.apply_effects(effects) { |
| 213 | return true; | 214 | return true; |
| @@ -259,13 +260,13 @@ impl ClientState { | |||
| 259 | self.draw_region(&lines); | 260 | self.draw_region(&lines); |
| 260 | } | 261 | } |
| 261 | Effect::ClearRegion => self.clear_region(), | 262 | Effect::ClearRegion => self.clear_region(), |
| 262 | Effect::SendLine(line) => { | 263 | Effect::SendRequest { id, payload } => { |
| 263 | let is_ping = line == "PING"; | 264 | let is_ping = payload == "PING"; |
| 264 | let started = Instant::now(); | 265 | let started = Instant::now(); |
| 265 | let reply = self.send_and_read(&line); | 266 | let reply = self.send_and_read(id, &payload); |
| 266 | let elapsed = started.elapsed(); | 267 | let elapsed = started.elapsed(); |
| 267 | let mut more = self.core.handle_reply(&reply); | 268 | let mut more = self.core.handle_reply_id(id, &reply); |
| 268 | if is_ping && reply.trim() == "PONG" { | 269 | if is_ping && reply.trim().starts_with("PONG") { |
| 269 | for effect in &mut more { | 270 | for effect in &mut more { |
| 270 | if let Effect::Print(lines) = effect { | 271 | if let Effect::Print(lines) = effect { |
| 271 | for text in lines { | 272 | for text in lines { |
| @@ -488,31 +489,30 @@ impl ClientState { | |||
| 488 | self.prompt_text = text.to_string(); | 489 | self.prompt_text = text.to_string(); |
| 489 | } | 490 | } |
| 490 | 491 | ||
| 491 | fn send_and_read(&mut self, line: &str) -> String { | 492 | fn send_and_read(&mut self, id: u64, line: &str) -> String { |
| 492 | let Some(mut stream) = self.stream.take() else { | 493 | let Some(mut stream) = self.stream.take() else { |
| 493 | return "ERROR Not connected".to_string(); | 494 | return "ERROR Not connected".to_string(); |
| 494 | }; | 495 | }; |
| 495 | 496 | ||
| 496 | // A crash event can be pushed while a request is in flight. Route those | 497 | // A crash event can be pushed while a request is in flight. Route those |
| 497 | // lines as they arrive and keep waiting for the actual reply. | 498 | // events as they arrive and keep waiting for the reply with this id. |
| 498 | let mut pushed: Vec<String> = Vec::new(); | 499 | let mut pushed: Vec<String> = Vec::new(); |
| 499 | let result = (|| -> std::io::Result<String> { | 500 | let result = (|| -> std::io::Result<String> { |
| 500 | stream.write_line(line)?; | 501 | stream.write_request(id, None, line)?; |
| 501 | loop { | 502 | loop { |
| 502 | let mut response = String::new(); | 503 | match stream.read_packet()? { |
| 503 | let read = stream.read_line(&mut response)?; | 504 | None => { |
| 504 | if read == 0 { | 505 | return Err(std::io::Error::new( |
| 505 | return Err(std::io::Error::new( | 506 | std::io::ErrorKind::UnexpectedEof, |
| 506 | std::io::ErrorKind::UnexpectedEof, | 507 | "connection closed", |
| 507 | "connection closed", | 508 | )); |
| 508 | )); | 509 | } |
| 509 | } | 510 | Some(Packet::Event { body, .. }) => pushed.push(body), |
| 510 | let response = response.trim_end().to_string(); | 511 | Some(Packet::Reply { id: reply_id, body }) if reply_id == id => { |
| 511 | if self.core.is_async(&response) { | 512 | return Ok(body); |
| 512 | pushed.push(response); | 513 | } |
| 513 | continue; | 514 | Some(Packet::Reply { .. }) => continue, |
| 514 | } | 515 | } |
| 515 | return Ok(response); | ||
| 516 | } | 516 | } |
| 517 | })(); | 517 | })(); |
| 518 | 518 | ||
| @@ -614,7 +614,11 @@ impl ClientState { | |||
| 614 | short_fingerprint(&identity), | 614 | short_fingerprint(&identity), |
| 615 | host | 615 | host |
| 616 | ), | 616 | ), |
| 617 | Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), | 617 | Ok(false) => println!( |
| 618 | "No saved login for server {} ({}).", | ||
| 619 | short_fingerprint(&identity), | ||
| 620 | host | ||
| 621 | ), | ||
| 618 | Err(error) => println!("Failed to forget the saved login: {}", error), | 622 | Err(error) => println!("Failed to forget the saved login: {}", error), |
| 619 | } | 623 | } |
| 620 | } | 624 | } |
| @@ -625,10 +629,9 @@ impl ClientState { | |||
| 625 | return; | 629 | return; |
| 626 | }; | 630 | }; |
| 627 | match database::forget_host(&self.conn, &target) { | 631 | match database::forget_host(&self.conn, &target) { |
| 628 | Ok(outcome) if outcome.credentials_removed => println!( | 632 | Ok(outcome) if outcome.credentials_removed => { |
| 629 | "Cleared trust and stored credentials for {}.", | 633 | println!("Cleared trust and stored credentials for {}.", target) |
| 630 | target | 634 | } |
| 631 | ), | ||
| 632 | Ok(outcome) => { | 635 | Ok(outcome) => { |
| 633 | println!("Cleared trust for {}.", target); | 636 | println!("Cleared trust for {}.", target); |
| 634 | if !outcome.siblings.is_empty() { | 637 | if !outcome.siblings.is_empty() { |
| @@ -665,7 +668,8 @@ impl ClientState { | |||
| 665 | 668 | ||
| 666 | let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); | 669 | let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); |
| 667 | let message = format!("AUTH {} {}", username, hashed); | 670 | let message = format!("AUTH {} {}", username, hashed); |
| 668 | let response = self.send_and_read(&message); | 671 | let id = self.core.allocate_request_id(); |
| 672 | let response = self.send_and_read(id, &message); | ||
| 669 | let response = response.trim(); | 673 | let response = response.trim(); |
| 670 | 674 | ||
| 671 | if response.starts_with("OK") { | 675 | if response.starts_with("OK") { |
| @@ -709,7 +713,8 @@ impl ClientState { | |||
| 709 | 713 | ||
| 710 | let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); | 714 | let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); |
| 711 | let message = format!("REGISTER {} {}", username, hashed); | 715 | let message = format!("REGISTER {} {}", username, hashed); |
| 712 | let response = self.send_and_read(&message); | 716 | let id = self.core.allocate_request_id(); |
| 717 | let response = self.send_and_read(id, &message); | ||
| 713 | let response = response.trim(); | 718 | let response = response.trim(); |
| 714 | 719 | ||
| 715 | if response == format!("REGISTERED {}", username) { | 720 | if response == format!("REGISTERED {}", username) { |
diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index dffae69..e6b623f 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | use std::time::Duration; | 1 | use std::time::Duration; |
| 2 | 2 | ||
| 3 | use crate::tls::TlsStream; | 3 | use crate::tls::TlsStream; |
| 4 | use zagan_bet::protocol::{event_line, parse_request, reply_line}; | ||
| 4 | use zagan_bet::server::core::{ConnectionState, ServerCore}; | 5 | use zagan_bet::server::core::{ConnectionState, ServerCore}; |
| 5 | 6 | ||
| 6 | /// How long the connection waits for a request before advancing timed games. | 7 | /// How long the connection waits for a request before advancing timed games. |
| @@ -30,24 +31,35 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | let mut state = ConnectionState::default(); | 33 | let mut state = ConnectionState::default(); |
| 34 | let mut event_seq = 0u64; | ||
| 33 | println!("[TLS Client connected] {client_addr}"); | 35 | println!("[TLS Client connected] {client_addr}"); |
| 34 | 36 | ||
| 35 | 'connection: loop { | 37 | 'connection: loop { |
| 36 | let mut line = String::new(); | 38 | let mut frame = String::new(); |
| 37 | match stream.read_line(&mut line) { | 39 | match stream.read_frame(&mut frame) { |
| 38 | Ok(0) => break, | 40 | Ok(0) => break, |
| 39 | Ok(_) => { | 41 | Ok(_) => { |
| 40 | let line = line.trim(); | 42 | let user = state.user().unwrap_or("guest").to_string(); |
| 41 | if !line.is_empty() { | 43 | println!("[{user} @ {client_addr}] Received: {frame}"); |
| 42 | let user = state.user().unwrap_or("guest").to_string(); | 44 | match parse_request(&frame) { |
| 43 | println!("[{user} @ {client_addr}] Received: {line}"); | 45 | Ok(request) => { |
| 44 | let response = core.handle_message(&mut state, line); | 46 | let response = core.handle_request(&mut state, &request); |
| 45 | if let Err(error) = stream.write_line(&response) { | 47 | let reply = reply_line(request.id, &response); |
| 46 | eprintln!("Write error: {error}"); | 48 | if let Err(error) = stream.write_frame(&reply) { |
| 47 | break; | 49 | eprintln!("Write error: {error}"); |
| 50 | break; | ||
| 51 | } | ||
| 52 | let user = state.user().unwrap_or("guest"); | ||
| 53 | println!("[{user} @ {client_addr}] Sent: {reply}"); | ||
| 54 | } | ||
| 55 | Err(message) => { | ||
| 56 | let reply = reply_line(0, &format!("ERROR {message}")); | ||
| 57 | if let Err(error) = stream.write_frame(&reply) { | ||
| 58 | eprintln!("Write error: {error}"); | ||
| 59 | break; | ||
| 60 | } | ||
| 61 | eprintln!("Malformed request from {client_addr}: {reply}"); | ||
| 48 | } | 62 | } |
| 49 | let user = state.user().unwrap_or("guest"); | ||
| 50 | println!("[{user} @ {client_addr}] Sent: {response}"); | ||
| 51 | } | 63 | } |
| 52 | } | 64 | } |
| 53 | Err(error) | 65 | Err(error) |
| @@ -65,12 +77,17 @@ pub fn handle_client_tls(mut stream: TlsStream) { | |||
| 65 | } | 77 | } |
| 66 | 78 | ||
| 67 | for push in core.poll(&state) { | 79 | for push in core.poll(&state) { |
| 68 | if let Err(error) = stream.write_line(&push) { | 80 | event_seq = event_seq.wrapping_add(1); |
| 81 | let event = event_line(event_seq, &push); | ||
| 82 | if let Err(error) = stream.write_frame(&event) { | ||
| 69 | eprintln!("Write error: {error}"); | 83 | eprintln!("Write error: {error}"); |
| 70 | break 'connection; | 84 | break 'connection; |
| 71 | } | 85 | } |
| 72 | let user = state.user().unwrap_or("guest"); | 86 | // Ticks are 10 Hz per live round; logging them floods the disk. |
| 73 | println!("[{user} @ {client_addr}] Pushed: {push}"); | 87 | if !push.starts_with("crash.tick ") { |
| 88 | let user = state.user().unwrap_or("guest"); | ||
| 89 | println!("[{user} @ {client_addr}] Pushed: {event}"); | ||
| 90 | } | ||
| 74 | } | 91 | } |
| 75 | } | 92 | } |
| 76 | 93 | ||
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs index 420b5b4..eb94637 100644 --- a/src/bin/server/tls.rs +++ b/src/bin/server/tls.rs | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | use std::io::{Read, Write}; | 1 | use std::io::{Read, Write}; |
| 2 | use std::net::TcpStream; | 2 | use std::net::TcpStream; |
| 3 | 3 | ||
| 4 | use zagan_bet::protocol::LineBuffer; | 4 | use zagan_bet::protocol::{FrameBuffer, write_frame}; |
| 5 | 5 | ||
| 6 | #[derive(Clone)] | 6 | #[derive(Clone)] |
| 7 | pub struct TlsServer { | 7 | pub struct TlsServer { |
| @@ -44,34 +44,31 @@ impl TlsServer { | |||
| 44 | 44 | ||
| 45 | pub struct TlsStream { | 45 | pub struct TlsStream { |
| 46 | stream: native_tls::TlsStream<TcpStream>, | 46 | stream: native_tls::TlsStream<TcpStream>, |
| 47 | lines: LineBuffer, | 47 | frames: FrameBuffer, |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | impl TlsStream { | 50 | impl TlsStream { |
| 51 | pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { | 51 | pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { |
| 52 | Self { | 52 | Self { |
| 53 | stream, | 53 | stream, |
| 54 | lines: LineBuffer::new(), | 54 | frames: FrameBuffer::new(), |
| 55 | } | 55 | } |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { | 58 | /// Read one protocol frame. `Ok(0)` is EOF with no partial frame. |
| 59 | match self.lines.read_line(&mut self.stream)? { | 59 | pub fn read_frame(&mut self, out: &mut String) -> std::io::Result<usize> { |
| 60 | Some(line) => { | 60 | match self.frames.read_frame(&mut self.stream)? { |
| 61 | s.push_str(&line); | 61 | Some(frame) => { |
| 62 | s.push('\n'); | 62 | let length = frame.len(); |
| 63 | Ok(line.len() + 1) | 63 | out.push_str(&frame); |
| 64 | Ok(length) | ||
| 64 | } | 65 | } |
| 65 | None => Ok(0), | 66 | None => Ok(0), |
| 66 | } | 67 | } |
| 67 | } | 68 | } |
| 68 | 69 | ||
| 69 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | 70 | pub fn write_frame(&mut self, msg: &str) -> std::io::Result<()> { |
| 70 | use std::io::Write; | 71 | write_frame(&mut self.stream, msg) |
| 71 | self.stream.write_all(msg.as_bytes())?; | ||
| 72 | self.stream.write_all(b"\n")?; | ||
| 73 | self.stream.flush()?; | ||
| 74 | Ok(()) | ||
| 75 | } | 72 | } |
| 76 | 73 | ||
| 77 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { | 74 | pub fn peer_addr(&self) -> std::io::Result<std::net::SocketAddr> { |
diff --git a/src/client/core.rs b/src/client/core.rs index 6d8294c..278dc00 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -3,17 +3,16 @@ use crate::client::games::response::{ | |||
| 3 | parse_blackjack_response, parse_crash_response, parse_history_response, | 3 | parse_blackjack_response, parse_crash_response, parse_history_response, |
| 4 | parse_roulette_response, parse_spin_response, | 4 | parse_roulette_response, parse_spin_response, |
| 5 | }; | 5 | }; |
| 6 | use crate::client::ui::{game_lines, history_lines}; | ||
| 6 | use crate::client::view::{ | 7 | use crate::client::view::{ |
| 7 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, | 8 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, |
| 8 | }; | 9 | }; |
| 9 | use crate::client::ui::{game_lines, history_lines}; | ||
| 10 | use crate::crash::{CrashState, multiplier_at}; | 10 | use crate::crash::{CrashState, multiplier_at}; |
| 11 | use crate::history::{PlayRecord, MAX_HISTORY}; | 11 | use crate::history::{MAX_HISTORY, PlayRecord}; |
| 12 | use crate::roulette::{BetTarget, Bets}; | 12 | use crate::roulette::{BetTarget, Bets}; |
| 13 | use crate::version::{parse_pong, unix_time_ms}; | ||
| 13 | use std::time::{Duration, Instant}; | 14 | use std::time::{Duration, Instant}; |
| 14 | 15 | ||
| 15 | |||
| 16 | |||
| 17 | /// High-level actions produced by the client core. The terminal adapter is | 16 | /// High-level actions produced by the client core. The terminal adapter is |
| 18 | /// responsible for turning these into crossterm calls and network writes. | 17 | /// responsible for turning these into crossterm calls and network writes. |
| 19 | #[derive(Debug, Clone, PartialEq)] | 18 | #[derive(Debug, Clone, PartialEq)] |
| @@ -24,8 +23,8 @@ pub enum Effect { | |||
| 24 | Render(View), | 23 | Render(View), |
| 25 | /// Remove the game board region. | 24 | /// Remove the game board region. |
| 26 | ClearRegion, | 25 | ClearRegion, |
| 27 | /// Send one protocol line and feed the reply to `handle_reply`. | 26 | /// Send one enveloped request and route its reply back by `id`. |
| 28 | SendLine(String), | 27 | SendRequest { id: u64, payload: String }, |
| 29 | /// Drop the connection. | 28 | /// Drop the connection. |
| 30 | Disconnect, | 29 | Disconnect, |
| 31 | /// Leave the program. | 30 | /// Leave the program. |
| @@ -61,6 +60,10 @@ pub struct ClientCore { | |||
| 61 | connected: bool, | 60 | connected: bool, |
| 62 | logged_in: Option<String>, | 61 | logged_in: Option<String>, |
| 63 | pending: Option<Pending>, | 62 | pending: Option<Pending>, |
| 63 | /// Id of the request whose reply fills `pending`. | ||
| 64 | pending_id: Option<u64>, | ||
| 65 | /// Next correlation id allocated on this connection. | ||
| 66 | next_request_id: u64, | ||
| 64 | balance: Option<i64>, | 67 | balance: Option<i64>, |
| 65 | /// Account number from the last `INFO`, shown by both frontends. | 68 | /// Account number from the last `INFO`, shown by both frontends. |
| 66 | account: Option<String>, | 69 | account: Option<String>, |
| @@ -68,6 +71,13 @@ pub struct ClientCore { | |||
| 68 | fingerprint: Option<String>, | 71 | fingerprint: Option<String>, |
| 69 | /// Local anchor for the crash multiplier, set when a round starts. | 72 | /// Local anchor for the crash multiplier, set when a round starts. |
| 70 | crash_started: Option<Instant>, | 73 | crash_started: Option<Instant>, |
| 74 | /// Server-time anchor for the crash multiplier, when the server supplied | ||
| 75 | /// `started_at`. | ||
| 76 | crash_started_ms: Option<u64>, | ||
| 77 | /// Server clock minus local clock, in milliseconds, from `HELLO`/`PONG`. | ||
| 78 | server_offset_ms: i64, | ||
| 79 | /// True once a frontend has supplied a server timestamp. | ||
| 80 | clock_synced: bool, | ||
| 71 | /// App screen shown when no game is open. | 81 | /// App screen shown when no game is open. |
| 72 | screen: Screen, | 82 | screen: Screen, |
| 73 | /// Last plays fetched with `history`. | 83 | /// Last plays fetched with `history`. |
| @@ -83,11 +93,16 @@ impl ClientCore { | |||
| 83 | connected: false, | 93 | connected: false, |
| 84 | logged_in: None, | 94 | logged_in: None, |
| 85 | pending: None, | 95 | pending: None, |
| 96 | pending_id: None, | ||
| 97 | next_request_id: 1, | ||
| 86 | balance: None, | 98 | balance: None, |
| 87 | account: None, | 99 | account: None, |
| 88 | host: None, | 100 | host: None, |
| 89 | fingerprint: None, | 101 | fingerprint: None, |
| 90 | crash_started: None, | 102 | crash_started: None, |
| 103 | crash_started_ms: None, | ||
| 104 | server_offset_ms: 0, | ||
| 105 | clock_synced: false, | ||
| 91 | screen: Screen::Lobby, | 106 | screen: Screen::Lobby, |
| 92 | play_history: Vec::new(), | 107 | play_history: Vec::new(), |
| 93 | login_error: None, | 108 | login_error: None, |
| @@ -99,11 +114,19 @@ impl ClientCore { | |||
| 99 | if !connected { | 114 | if !connected { |
| 100 | self.logged_in = None; | 115 | self.logged_in = None; |
| 101 | self.pending = None; | 116 | self.pending = None; |
| 117 | self.pending_id = None; | ||
| 102 | self.crash_started = None; | 118 | self.crash_started = None; |
| 119 | self.crash_started_ms = None; | ||
| 120 | self.clock_synced = false; | ||
| 103 | self.screen = Screen::Lobby; | 121 | self.screen = Screen::Lobby; |
| 104 | } | 122 | } |
| 105 | } | 123 | } |
| 106 | 124 | ||
| 125 | /// The signed-in username, if any. | ||
| 126 | pub fn user(&self) -> Option<&str> { | ||
| 127 | self.logged_in.as_deref() | ||
| 128 | } | ||
| 129 | |||
| 107 | pub fn set_logged_in(&mut self, user: Option<String>) { | 130 | pub fn set_logged_in(&mut self, user: Option<String>) { |
| 108 | if user.is_some() { | 131 | if user.is_some() { |
| 109 | self.login_error = None; | 132 | self.login_error = None; |
| @@ -147,7 +170,44 @@ impl ClientCore { | |||
| 147 | self.board_effects() | 170 | self.board_effects() |
| 148 | } | 171 | } |
| 149 | 172 | ||
| 173 | /// Record the server's clock and the local time it was received. The | ||
| 174 | /// offset is for display; cash-out still settles on the server. | ||
| 175 | pub fn set_server_time(&mut self, server_ms: u64) { | ||
| 176 | self.server_offset_ms = server_ms as i64 - unix_time_ms() as i64; | ||
| 177 | self.clock_synced = true; | ||
| 178 | } | ||
| 179 | |||
| 180 | /// The game server's clock, as this client currently estimates it. | ||
| 181 | pub fn server_now_ms(&self) -> u64 { | ||
| 182 | (unix_time_ms() as i64 + self.server_offset_ms).max(0) as u64 | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Allocate a correlation id for a request the adapter sends directly | ||
| 186 | /// (the terminal's login prompt bypasses `ClientCore`). | ||
| 187 | pub fn allocate_request_id(&mut self) -> u64 { | ||
| 188 | let id = self.next_request_id; | ||
| 189 | self.next_request_id = self.next_request_id.wrapping_add(1).max(1); | ||
| 190 | id | ||
| 191 | } | ||
| 192 | |||
| 193 | fn begin_request(&mut self, pending: Pending, payload: String) -> Effect { | ||
| 194 | let id = self.allocate_request_id(); | ||
| 195 | self.pending = Some(pending); | ||
| 196 | self.pending_id = Some(id); | ||
| 197 | Effect::SendRequest { id, payload } | ||
| 198 | } | ||
| 199 | |||
| 200 | /// Route a reply by correlation id. A reply we no longer expect (a late | ||
| 201 | /// response after a timeout) is ignored instead of corrupting the board. | ||
| 202 | pub fn handle_reply_id(&mut self, id: u64, reply: &str) -> Vec<Effect> { | ||
| 203 | if self.pending_id != Some(id) { | ||
| 204 | return Vec::new(); | ||
| 205 | } | ||
| 206 | self.handle_reply(reply) | ||
| 207 | } | ||
| 208 | |||
| 150 | pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { | 209 | pub fn handle_reply(&mut self, reply: &str) -> Vec<Effect> { |
| 210 | self.pending_id = None; | ||
| 151 | match self.pending.take() { | 211 | match self.pending.take() { |
| 152 | Some(Pending::Spin) => match parse_spin_response(reply) { | 212 | Some(Pending::Spin) => match parse_spin_response(reply) { |
| 153 | Ok(result) => { | 213 | Ok(result) => { |
| @@ -265,7 +325,8 @@ impl ClientCore { | |||
| 265 | } | 325 | } |
| 266 | }, | 326 | }, |
| 267 | Some(Pending::Ping) => { | 327 | Some(Pending::Ping) => { |
| 268 | if reply.trim() == "PONG" { | 328 | if let Some(server_ms) = parse_pong(reply) { |
| 329 | self.set_server_time(server_ms); | ||
| 269 | print_line("Pong") | 330 | print_line("Pong") |
| 270 | } else { | 331 | } else { |
| 271 | self.connected = false; | 332 | self.connected = false; |
| @@ -305,10 +366,8 @@ impl ClientCore { | |||
| 305 | if self.game.begin_spin().is_err() { | 366 | if self.game.begin_spin().is_err() { |
| 306 | return Vec::new(); | 367 | return Vec::new(); |
| 307 | } | 368 | } |
| 308 | self.pending = Some(Pending::Spin); | ||
| 309 | |||
| 310 | let mut effects = self.board_effects(); | 369 | let mut effects = self.board_effects(); |
| 311 | effects.push(Effect::SendLine(request)); | 370 | effects.push(self.begin_request(Pending::Spin, request)); |
| 312 | effects | 371 | effects |
| 313 | } | 372 | } |
| 314 | 373 | ||
| @@ -322,24 +381,24 @@ impl ClientCore { | |||
| 322 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 381 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 323 | } | 382 | } |
| 324 | self.login_error = None; | 383 | self.login_error = None; |
| 325 | self.pending = Some(Pending::Login { user: user.clone() }); | 384 | vec![self.begin_request( |
| 326 | vec![Effect::SendLine(format!("AUTH {user} {credential}"))] | 385 | Pending::Login { user: user.clone() }, |
| 386 | format!("AUTH {user} {credential}"), | ||
| 387 | )] | ||
| 327 | } | 388 | } |
| 328 | Command::Register { user, credential } => { | 389 | Command::Register { user, credential } => { |
| 329 | if !self.connected { | 390 | if !self.connected { |
| 330 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 391 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 331 | } | 392 | } |
| 332 | self.login_error = None; | 393 | self.login_error = None; |
| 333 | self.pending = Some(Pending::Register); | 394 | vec![self.begin_request(Pending::Register, format!("REGISTER {user} {credential}"))] |
| 334 | vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] | ||
| 335 | } | 395 | } |
| 336 | Command::Games => vec![Effect::Print(game_lines(&self.games()))], | 396 | Command::Games => vec![Effect::Print(game_lines(&self.games()))], |
| 337 | Command::Ping => { | 397 | Command::Ping => { |
| 338 | if !self.connected { | 398 | if !self.connected { |
| 339 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 399 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 340 | } | 400 | } |
| 341 | self.pending = Some(Pending::Ping); | 401 | vec![self.begin_request(Pending::Ping, "PING".to_string())] |
| 342 | vec![Effect::SendLine("PING".to_string())] | ||
| 343 | } | 402 | } |
| 344 | Command::Echo(message) => { | 403 | Command::Echo(message) => { |
| 345 | if !self.connected { | 404 | if !self.connected { |
| @@ -348,8 +407,7 @@ impl ClientCore { | |||
| 348 | if message.trim().is_empty() { | 407 | if message.trim().is_empty() { |
| 349 | return print_line("Usage: send <message>"); | 408 | return print_line("Usage: send <message>"); |
| 350 | } | 409 | } |
| 351 | self.pending = Some(Pending::Echo); | 410 | vec![self.begin_request(Pending::Echo, message)] |
| 352 | vec![Effect::SendLine(message)] | ||
| 353 | } | 411 | } |
| 354 | Command::Info => self.info_effects(), | 412 | Command::Info => self.info_effects(), |
| 355 | Command::Play { game, bet } => self.play_game(&game, bet), | 413 | Command::Play { game, bet } => self.play_game(&game, bet), |
| @@ -458,6 +516,7 @@ impl ClientCore { | |||
| 458 | 516 | ||
| 459 | fn exit_game(&mut self) -> Vec<Effect> { | 517 | fn exit_game(&mut self) -> Vec<Effect> { |
| 460 | self.crash_started = None; | 518 | self.crash_started = None; |
| 519 | self.crash_started_ms = None; | ||
| 461 | self.screen = Screen::Lobby; | 520 | self.screen = Screen::Lobby; |
| 462 | self.game.end_game(); | 521 | self.game.end_game(); |
| 463 | vec![ | 522 | vec![ |
| @@ -475,19 +534,17 @@ impl ClientCore { | |||
| 475 | return self.exit_game(); | 534 | return self.exit_game(); |
| 476 | }; | 535 | }; |
| 477 | self.game.set_blackjack_status("forfeiting"); | 536 | self.game.set_blackjack_status("forfeiting"); |
| 478 | self.pending = Some(Pending::BlackjackForfeit); | 537 | vec![self.begin_request( |
| 479 | vec![Effect::SendLine(format!( | 538 | Pending::BlackjackForfeit, |
| 480 | "BLACKJACK_ACTION {} forfeit", | 539 | format!("BLACKJACK_ACTION {} forfeit", hand.session_id), |
| 481 | hand.session_id | 540 | )] |
| 482 | ))] | ||
| 483 | } | 541 | } |
| 484 | 542 | ||
| 485 | fn forfeit_crash(&mut self) -> Vec<Effect> { | 543 | fn forfeit_crash(&mut self) -> Vec<Effect> { |
| 486 | let Some(session) = self.game.crash_session_id() else { | 544 | let Some(session) = self.game.crash_session_id() else { |
| 487 | return self.exit_game(); | 545 | return self.exit_game(); |
| 488 | }; | 546 | }; |
| 489 | self.pending = Some(Pending::CrashForfeit); | 547 | vec![self.begin_request(Pending::CrashForfeit, format!("CRASH_FORFEIT {session}"))] |
| 490 | vec![Effect::SendLine(format!("CRASH_FORFEIT {session}"))] | ||
| 491 | } | 548 | } |
| 492 | 549 | ||
| 493 | fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { | 550 | fn place_roulette_chip_on(&mut self, target: BetTarget) -> Vec<Effect> { |
| @@ -533,10 +590,8 @@ impl ClientCore { | |||
| 533 | if self.game.begin_spin().is_err() { | 590 | if self.game.begin_spin().is_err() { |
| 534 | return Vec::new(); | 591 | return Vec::new(); |
| 535 | } | 592 | } |
| 536 | self.pending = Some(Pending::RouletteSpin); | ||
| 537 | |||
| 538 | let mut effects = self.board_effects(); | 593 | let mut effects = self.board_effects(); |
| 539 | effects.push(Effect::SendLine(request)); | 594 | effects.push(self.begin_request(Pending::RouletteSpin, request)); |
| 540 | effects | 595 | effects |
| 541 | } | 596 | } |
| 542 | 597 | ||
| @@ -566,9 +621,8 @@ impl ClientCore { | |||
| 566 | } | 621 | } |
| 567 | 622 | ||
| 568 | self.game.set_blackjack_status("dealing"); | 623 | self.game.set_blackjack_status("dealing"); |
| 569 | self.pending = Some(Pending::BlackjackStart); | ||
| 570 | let mut effects = self.board_effects(); | 624 | let mut effects = self.board_effects(); |
| 571 | effects.push(Effect::SendLine(request)); | 625 | effects.push(self.begin_request(Pending::BlackjackStart, request)); |
| 572 | effects | 626 | effects |
| 573 | } | 627 | } |
| 574 | 628 | ||
| @@ -582,15 +636,21 @@ impl ClientCore { | |||
| 582 | if action == "double" && !hand.actions.iter().any(|available| available == "double") { | 636 | if action == "double" && !hand.actions.iter().any(|available| available == "double") { |
| 583 | return Vec::new(); | 637 | return Vec::new(); |
| 584 | } | 638 | } |
| 585 | self.pending = Some(Pending::BlackjackAction); | 639 | vec![self.begin_request( |
| 586 | vec![Effect::SendLine(format!( | 640 | Pending::BlackjackAction, |
| 587 | "BLACKJACK_ACTION {} {}", | 641 | format!("BLACKJACK_ACTION {} {}", hand.session_id, action), |
| 588 | hand.session_id, action | 642 | )] |
| 589 | ))] | ||
| 590 | } | 643 | } |
| 591 | 644 | ||
| 592 | /// Redraw the rising multiplier. Called by the terminal loop on a timer. | 645 | /// Redraw the rising multiplier. Called by the terminal loop on a timer. |
| 593 | pub fn tick(&mut self) -> Vec<Effect> { | 646 | pub fn tick(&mut self) -> Vec<Effect> { |
| 647 | if self.clock_synced { | ||
| 648 | if let Some(started_ms) = self.crash_started_ms { | ||
| 649 | let now = unix_time_ms() as i64 + self.server_offset_ms; | ||
| 650 | let elapsed_ms = (now - started_ms as i64).max(0) as u64; | ||
| 651 | return self.tick_at(Duration::from_millis(elapsed_ms)); | ||
| 652 | } | ||
| 653 | } | ||
| 594 | let Some(started) = self.crash_started else { | 654 | let Some(started) = self.crash_started else { |
| 595 | return Vec::new(); | 655 | return Vec::new(); |
| 596 | }; | 656 | }; |
| @@ -619,12 +679,16 @@ impl ClientCore { | |||
| 619 | } | 679 | } |
| 620 | 680 | ||
| 621 | pub fn is_async(&self, line: &str) -> bool { | 681 | pub fn is_async(&self, line: &str) -> bool { |
| 622 | line.trim_start().starts_with("CRASH_BUSTED ") | 682 | let line = line.trim_start(); |
| 683 | line.starts_with("CRASH_BUSTED ") || line.starts_with("crash.tick ") | ||
| 623 | } | 684 | } |
| 624 | 685 | ||
| 625 | /// Handle a server-initiated line. Returns `None` when the line is a normal | 686 | /// Handle a server-initiated line. Returns `None` when the line is a normal |
| 626 | /// request/response reply and belongs to `handle_reply`. | 687 | /// request/response reply and belongs to `handle_reply`. |
| 627 | pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { | 688 | pub fn handle_async_line(&mut self, line: &str) -> Option<Vec<Effect>> { |
| 689 | if let Some(tick) = line.trim_start().strip_prefix("crash.tick ") { | ||
| 690 | return Some(self.apply_crash_tick(tick)); | ||
| 691 | } | ||
| 628 | if !self.is_async(line) { | 692 | if !self.is_async(line) { |
| 629 | return None; | 693 | return None; |
| 630 | } | 694 | } |
| @@ -643,13 +707,43 @@ impl ClientCore { | |||
| 643 | if self.crash_started.is_none() { | 707 | if self.crash_started.is_none() { |
| 644 | self.crash_started = Some(Instant::now()); | 708 | self.crash_started = Some(Instant::now()); |
| 645 | } | 709 | } |
| 710 | if state.started_at > 0 { | ||
| 711 | self.crash_started_ms = Some(state.started_at); | ||
| 712 | } | ||
| 646 | } else { | 713 | } else { |
| 647 | self.crash_started = None; | 714 | self.crash_started = None; |
| 715 | self.crash_started_ms = None; | ||
| 648 | } | 716 | } |
| 649 | self.game.apply_crash_state(state); | 717 | self.game.apply_crash_state(state); |
| 650 | self.board_effects() | 718 | self.board_effects() |
| 651 | } | 719 | } |
| 652 | 720 | ||
| 721 | /// Apply `crash.tick session=<id> t=<server_ms> m=<multiplier>`. | ||
| 722 | fn apply_crash_tick(&mut self, tick: &str) -> Vec<Effect> { | ||
| 723 | let mut session = None; | ||
| 724 | let mut multiplier = None; | ||
| 725 | for token in tick.split_whitespace() { | ||
| 726 | if let Some(value) = token.strip_prefix("session=") { | ||
| 727 | session = Some(value); | ||
| 728 | } else if let Some(value) = token.strip_prefix("m=") { | ||
| 729 | multiplier = value.parse::<f64>().ok(); | ||
| 730 | } | ||
| 731 | } | ||
| 732 | let Some(multiplier) = multiplier else { | ||
| 733 | return Vec::new(); | ||
| 734 | }; | ||
| 735 | if let Some(session) = session { | ||
| 736 | if self.game.crash_session_id().as_deref() != Some(session) { | ||
| 737 | return Vec::new(); | ||
| 738 | } | ||
| 739 | } | ||
| 740 | if self.game.crash_status() != "running" { | ||
| 741 | return Vec::new(); | ||
| 742 | } | ||
| 743 | self.game.set_crash_multiplier(multiplier); | ||
| 744 | self.board_effects() | ||
| 745 | } | ||
| 746 | |||
| 653 | fn start_crash(&mut self) -> Vec<Effect> { | 747 | fn start_crash(&mut self) -> Vec<Effect> { |
| 654 | let status = self.game.crash_status(); | 748 | let status = self.game.crash_status(); |
| 655 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { | 749 | if !matches!(status.as_str(), "ready" | "cashed" | "crashed" | "forfeit") { |
| @@ -671,9 +765,8 @@ impl ClientCore { | |||
| 671 | } | 765 | } |
| 672 | 766 | ||
| 673 | self.game.set_crash_status("starting"); | 767 | self.game.set_crash_status("starting"); |
| 674 | self.pending = Some(Pending::CrashStart); | ||
| 675 | let mut effects = self.board_effects(); | 768 | let mut effects = self.board_effects(); |
| 676 | effects.push(Effect::SendLine(request)); | 769 | effects.push(self.begin_request(Pending::CrashStart, request)); |
| 677 | effects | 770 | effects |
| 678 | } | 771 | } |
| 679 | 772 | ||
| @@ -681,8 +774,7 @@ impl ClientCore { | |||
| 681 | let Some(session) = self.game.crash_session_id() else { | 774 | let Some(session) = self.game.crash_session_id() else { |
| 682 | return Vec::new(); | 775 | return Vec::new(); |
| 683 | }; | 776 | }; |
| 684 | self.pending = Some(Pending::CrashCashout); | 777 | vec![self.begin_request(Pending::CrashCashout, format!("CRASH_CASHOUT {session}"))] |
| 685 | vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] | ||
| 686 | } | 778 | } |
| 687 | 779 | ||
| 688 | fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { | 780 | fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { |
| @@ -706,8 +798,7 @@ impl ClientCore { | |||
| 706 | } | 798 | } |
| 707 | 799 | ||
| 708 | let mut effects = self.board_effects(); | 800 | let mut effects = self.board_effects(); |
| 709 | self.pending = Some(Pending::Info); | 801 | effects.push(self.begin_request(Pending::Info, "INFO".to_string())); |
| 710 | effects.push(Effect::SendLine("INFO".to_string())); | ||
| 711 | effects | 802 | effects |
| 712 | } | 803 | } |
| 713 | Err(_) => print_line("Failed to enter game"), | 804 | Err(_) => print_line("Failed to enter game"), |
| @@ -726,13 +817,12 @@ impl ClientCore { | |||
| 726 | } | 817 | } |
| 727 | 818 | ||
| 728 | self.screen = Screen::History; | 819 | self.screen = Screen::History; |
| 729 | self.pending = Some(Pending::History); | ||
| 730 | let request = if limit == crate::history::DEFAULT_HISTORY { | 820 | let request = if limit == crate::history::DEFAULT_HISTORY { |
| 731 | "HISTORY".to_string() | 821 | "HISTORY".to_string() |
| 732 | } else { | 822 | } else { |
| 733 | format!("HISTORY {limit}") | 823 | format!("HISTORY {limit}") |
| 734 | }; | 824 | }; |
| 735 | vec![Effect::SendLine(request)] | 825 | vec![self.begin_request(Pending::History, request)] |
| 736 | } | 826 | } |
| 737 | 827 | ||
| 738 | fn info_effects(&mut self) -> Vec<Effect> { | 828 | fn info_effects(&mut self) -> Vec<Effect> { |
| @@ -754,8 +844,7 @@ impl ClientCore { | |||
| 754 | ])), | 844 | ])), |
| 755 | } | 845 | } |
| 756 | 846 | ||
| 757 | self.pending = Some(Pending::Info); | 847 | effects.push(self.begin_request(Pending::Info, "INFO".to_string())); |
| 758 | effects.push(Effect::SendLine("INFO".to_string())); | ||
| 759 | effects | 848 | effects |
| 760 | } | 849 | } |
| 761 | 850 | ||
| @@ -809,7 +898,6 @@ impl ClientCore { | |||
| 809 | } | 898 | } |
| 810 | self.view().map(Effect::Render).into_iter().collect() | 899 | self.view().map(Effect::Render).into_iter().collect() |
| 811 | } | 900 | } |
| 812 | |||
| 813 | } | 901 | } |
| 814 | 902 | ||
| 815 | fn auth_result(reply: &str) -> Result<(), String> { | 903 | 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; | |||
| 5 | use super::slots::SlotsGame; | 5 | use super::slots::SlotsGame; |
| 6 | use super::traits::{Game, GameError, GamePhase, GameState}; | 6 | use super::traits::{Game, GameError, GamePhase, GameState}; |
| 7 | use crate::blackjack::BlackjackState; | 7 | use crate::blackjack::BlackjackState; |
| 8 | use crate::crash::{payout, CrashState}; | 8 | use crate::crash::{CrashState, payout}; |
| 9 | use crate::roulette::Bets; | 9 | use crate::roulette::Bets; |
| 10 | use std::collections::HashMap; | 10 | use std::collections::HashMap; |
| 11 | 11 | ||
| @@ -244,9 +244,7 @@ impl GameManager { | |||
| 244 | 244 | ||
| 245 | pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { | 245 | pub fn set_roulette_cursor(&mut self, cursor: &crate::client::games::roulette::Cursor) { |
| 246 | if let Some(session) = self.current_session.as_mut() { | 246 | if let Some(session) = self.current_session.as_mut() { |
| 247 | session | 247 | session.data.insert("cursor".to_string(), cursor.encoded()); |
| 248 | .data | ||
| 249 | .insert("cursor".to_string(), cursor.encoded()); | ||
| 250 | } | 248 | } |
| 251 | } | 249 | } |
| 252 | 250 | ||
| @@ -339,7 +337,7 @@ impl GameManager { | |||
| 339 | }; | 337 | }; |
| 340 | session.bet_amount = state.bet; | 338 | session.bet_amount = state.bet; |
| 341 | session.data.remove("error"); | 339 | session.data.remove("error"); |
| 342 | for key in ["crash_point", "payout"] { | 340 | for key in ["crash_point", "payout", "commit", "seed"] { |
| 343 | session.data.remove(key); | 341 | session.data.remove(key); |
| 344 | } | 342 | } |
| 345 | session | 343 | session |
| @@ -360,10 +358,21 @@ impl GameManager { | |||
| 360 | session | 358 | session |
| 361 | .data | 359 | .data |
| 362 | .insert("payout".to_string(), state.payout.to_string()); | 360 | .insert("payout".to_string(), state.payout.to_string()); |
| 361 | session | ||
| 362 | .data | ||
| 363 | .insert("started_at".to_string(), state.started_at.to_string()); | ||
| 364 | if let Some(commit) = &state.commit { | ||
| 365 | session.data.insert("commit".to_string(), commit.clone()); | ||
| 366 | } | ||
| 367 | if let Some(seed) = &state.seed { | ||
| 368 | session.data.insert("seed".to_string(), seed.clone()); | ||
| 369 | } | ||
| 363 | if let Some(crash_point) = state.crash_point { | 370 | if let Some(crash_point) = state.crash_point { |
| 371 | // Keep full precision: the browser recomputes this from the | ||
| 372 | // revealed seed, and the GUI charts it. | ||
| 364 | session | 373 | session |
| 365 | .data | 374 | .data |
| 366 | .insert("crash_point".to_string(), format!("{crash_point:.2}")); | 375 | .insert("crash_point".to_string(), crash_point.to_string()); |
| 367 | } | 376 | } |
| 368 | } | 377 | } |
| 369 | 378 | ||
diff --git a/src/client/games/roulette.rs b/src/client/games/roulette.rs index f0ed816..15cc6ed 100644 --- a/src/client/games/roulette.rs +++ b/src/client/games/roulette.rs | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | use super::traits::{Game, GameState}; | 1 | use super::traits::{Game, GameState}; |
| 2 | use crate::client::view::{RoulettePhase, RouletteView}; | 2 | use crate::client::view::{RoulettePhase, RouletteView}; |
| 3 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; | 3 | use crate::roulette::{BetTarget, Bets, Outside, is_even, is_odd}; |
| 4 | use serde::{Deserialize, Serialize}; | ||
| 4 | 5 | ||
| 5 | pub const ROULETTE_BOARD_LINES: usize = 12; | 6 | pub const ROULETTE_BOARD_LINES: usize = 12; |
| 6 | 7 | ||
| @@ -21,7 +22,7 @@ const OUTSIDE_ORDER: [Outside; 6] = [ | |||
| 21 | 22 | ||
| 22 | pub struct RouletteGame; | 23 | pub struct RouletteGame; |
| 23 | 24 | ||
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 25 | pub enum Direction { | 26 | pub enum Direction { |
| 26 | Up, | 27 | Up, |
| 27 | Down, | 28 | Down, |
| @@ -41,7 +42,7 @@ pub enum Direction { | |||
| 41 | /// odd,even line between rows split of the two numbers | 42 | /// odd,even line between rows split of the two numbers |
| 42 | /// even,even corner of four cells corner | 43 | /// even,even corner of four cells corner |
| 43 | /// ``` | 44 | /// ``` |
| 44 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 45 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 45 | pub enum Cursor { | 46 | pub enum Cursor { |
| 46 | Zero, | 47 | Zero, |
| 47 | Lattice { x: usize, y: usize }, | 48 | Lattice { x: usize, y: usize }, |
| @@ -67,7 +68,9 @@ impl Cursor { | |||
| 67 | match parts.as_slice() { | 68 | match parts.as_slice() { |
| 68 | ["zero"] => Cursor::Zero, | 69 | ["zero"] => Cursor::Zero, |
| 69 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { | 70 | ["lattice", x, y] => match (x.parse::<usize>(), y.parse::<usize>()) { |
| 70 | (Ok(x), Ok(y)) if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => { | 71 | (Ok(x), Ok(y)) |
| 72 | if (1..=FELT_X_MAX).contains(&x) && (1..=FELT_Y_MAX).contains(&y) => | ||
| 73 | { | ||
| 71 | Cursor::Lattice { x, y } | 74 | Cursor::Lattice { x, y } |
| 72 | } | 75 | } |
| 73 | _ => Cursor::default(), | 76 | _ => Cursor::default(), |
diff --git a/src/client/transport.rs b/src/client/transport.rs index 97c9227..60e7ab6 100644 --- a/src/client/transport.rs +++ b/src/client/transport.rs | |||
| @@ -7,20 +7,27 @@ | |||
| 7 | use std::fmt; | 7 | use std::fmt; |
| 8 | use std::io::{Read, Write}; | 8 | use std::io::{Read, Write}; |
| 9 | use std::net::TcpStream; | 9 | use std::net::TcpStream; |
| 10 | use std::sync::mpsc; | ||
| 11 | use std::sync::Arc; | 10 | use std::sync::Arc; |
| 11 | use std::sync::mpsc; | ||
| 12 | use std::thread; | 12 | use std::thread; |
| 13 | use std::time::{Duration, Instant}; | 13 | use std::time::{Duration, Instant}; |
| 14 | 14 | ||
| 15 | use parking_lot::Mutex; | 15 | use parking_lot::Mutex; |
| 16 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 17 | use rustls::DigitallySignedStruct; | ||
| 18 | use rusqlite::Connection; | 16 | use rusqlite::Connection; |
| 17 | use rustls::DigitallySignedStruct; | ||
| 18 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | ||
| 19 | 19 | ||
| 20 | use crate::client::database; | 20 | use crate::client::database; |
| 21 | use crate::protocol::LineBuffer; | 21 | use crate::protocol::{ |
| 22 | use crate::tofu::{decide, spki_fingerprint, TrustDecision}; | 22 | FrameBuffer, Packet, parse_packet, request_line, write_frame as write_protocol_frame, |
| 23 | use crate::version::{parse_hello, HelloReply, VERSION}; | 23 | }; |
| 24 | use crate::tofu::{TrustDecision, decide, spki_fingerprint}; | ||
| 25 | use crate::version::{HelloReply, VERSION, parse_hello}; | ||
| 26 | |||
| 27 | /// Reserved request ids for the transport handshake. Application requests | ||
| 28 | /// start at 1, so the two ranges cannot collide on one connection. | ||
| 29 | const HELLO_REQUEST_ID: u64 = u64::MAX; | ||
| 30 | const AUTH_REQUEST_ID: u64 = u64::MAX - 1; | ||
| 24 | 31 | ||
| 25 | /// How long to wait for TCP + TLS before giving up. | 32 | /// How long to wait for TCP + TLS before giving up. |
| 26 | const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); | 33 | const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); |
| @@ -43,6 +50,8 @@ pub struct Connected { | |||
| 43 | pub server_version: String, | 50 | pub server_version: String, |
| 44 | pub latest: String, | 51 | pub latest: String, |
| 45 | pub url: String, | 52 | pub url: String, |
| 53 | /// Server Unix time reported by `HELLO`, milliseconds. | ||
| 54 | pub server_time: u64, | ||
| 46 | /// Round trip of the HELLO exchange. | 55 | /// Round trip of the HELLO exchange. |
| 47 | pub elapsed: Duration, | 56 | pub elapsed: Duration, |
| 48 | } | 57 | } |
| @@ -51,10 +60,7 @@ pub struct Connected { | |||
| 51 | pub enum TransportError { | 60 | pub enum TransportError { |
| 52 | Timeout, | 61 | Timeout, |
| 53 | TrustDeclined, | 62 | TrustDeclined, |
| 54 | KeyChanged { | 63 | KeyChanged { expected: String, observed: String }, |
| 55 | expected: String, | ||
| 56 | observed: String, | ||
| 57 | }, | ||
| 58 | VersionMismatch(String), | 64 | VersionMismatch(String), |
| 59 | Closed, | 65 | Closed, |
| 60 | Unexpected(String), | 66 | Unexpected(String), |
| @@ -121,7 +127,7 @@ pub fn connect( | |||
| 121 | return Err(TransportError::KeyChanged { | 127 | return Err(TransportError::KeyChanged { |
| 122 | expected, | 128 | expected, |
| 123 | observed: actual, | 129 | observed: actual, |
| 124 | }) | 130 | }); |
| 125 | } | 131 | } |
| 126 | } | 132 | } |
| 127 | } | 133 | } |
| @@ -131,12 +137,7 @@ pub fn connect( | |||
| 131 | .map_err(io_failure)?; | 137 | .map_err(io_failure)?; |
| 132 | 138 | ||
| 133 | let start = Instant::now(); | 139 | let start = Instant::now(); |
| 134 | tls.write_line(&format!("HELLO {VERSION}")) | 140 | let response = exchange(&mut tls, HELLO_REQUEST_ID, &format!("HELLO {VERSION}"))?; |
| 135 | .map_err(io_failure)?; | ||
| 136 | let mut response = String::new(); | ||
| 137 | if tls.read_line(&mut response).map_err(io_failure)? == 0 { | ||
| 138 | return Err(TransportError::Closed); | ||
| 139 | } | ||
| 140 | let elapsed = start.elapsed(); | 141 | let elapsed = start.elapsed(); |
| 141 | 142 | ||
| 142 | let response = response.trim(); | 143 | let response = response.trim(); |
| @@ -151,10 +152,11 @@ pub fn connect( | |||
| 151 | let mut user = None; | 152 | let mut user = None; |
| 152 | let mut auth_error = None; | 153 | let mut auth_error = None; |
| 153 | if let Some((username, password)) = database::get_credentials(conn, &observed) { | 154 | if let Some((username, password)) = database::get_credentials(conn, &observed) { |
| 154 | tls.write_line(&format!("AUTH {username} {password}")) | 155 | let auth = exchange( |
| 155 | .map_err(io_failure)?; | 156 | &mut tls, |
| 156 | let mut auth = String::new(); | 157 | AUTH_REQUEST_ID, |
| 157 | tls.read_line(&mut auth).map_err(io_failure)?; | 158 | &format!("AUTH {username} {password}"), |
| 159 | )?; | ||
| 158 | if auth.trim().starts_with("OK") { | 160 | if auth.trim().starts_with("OK") { |
| 159 | user = Some(username); | 161 | user = Some(username); |
| 160 | } else { | 162 | } else { |
| @@ -169,19 +171,31 @@ pub fn connect( | |||
| 169 | server_version: hello.version, | 171 | server_version: hello.version, |
| 170 | latest: hello.latest, | 172 | latest: hello.latest, |
| 171 | url: hello.url, | 173 | url: hello.url, |
| 174 | server_time: hello.time, | ||
| 172 | elapsed, | 175 | elapsed, |
| 173 | }) | 176 | }) |
| 174 | } | 177 | } |
| 175 | 178 | ||
| 179 | /// Send one request and wait for the reply with the same id, ignoring events | ||
| 180 | /// that arrive in between. Used only for the handshake. | ||
| 181 | fn exchange(stream: &mut TlsStream, id: u64, payload: &str) -> Result<String, TransportError> { | ||
| 182 | stream | ||
| 183 | .write_request(id, None, payload) | ||
| 184 | .map_err(io_failure)?; | ||
| 185 | loop { | ||
| 186 | match stream.read_packet().map_err(io_failure)? { | ||
| 187 | None => return Err(TransportError::Closed), | ||
| 188 | Some(Packet::Reply { id: reply_id, body }) if reply_id == id => return Ok(body), | ||
| 189 | Some(Packet::Reply { .. }) | Some(Packet::Event { .. }) => continue, | ||
| 190 | } | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 176 | /// TCP + TLS on a thread so a blocked handshake can time out. | 194 | /// TCP + TLS on a thread so a blocked handshake can time out. |
| 177 | fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { | 195 | fn tls_connect(addr: &str) -> Result<(TlsStream, Option<String>), TransportError> { |
| 178 | let (sender, receiver) = mpsc::channel(); | 196 | let (sender, receiver) = mpsc::channel(); |
| 179 | let addr = addr.to_string(); | 197 | let addr = addr.to_string(); |
| 180 | let server_name = addr | 198 | let server_name = addr.split(':').next().unwrap_or("127.0.0.1").to_string(); |
| 181 | .split(':') | ||
| 182 | .next() | ||
| 183 | .unwrap_or("127.0.0.1") | ||
| 184 | .to_string(); | ||
| 185 | 199 | ||
| 186 | thread::spawn(move || { | 200 | thread::spawn(move || { |
| 187 | let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { | 201 | let outcome = (|| -> Result<(TlsStream, Option<String>), TransportError> { |
| @@ -241,7 +255,7 @@ impl TlsConnector { | |||
| 241 | session: Arc::new(Mutex::new(Session { | 255 | session: Arc::new(Mutex::new(Session { |
| 242 | sock, | 256 | sock, |
| 243 | tls: connection, | 257 | tls: connection, |
| 244 | lines: LineBuffer::new(), | 258 | frames: FrameBuffer::new(), |
| 245 | read_timeout: Some(READ_TIMEOUT), | 259 | read_timeout: Some(READ_TIMEOUT), |
| 246 | })), | 260 | })), |
| 247 | }) | 261 | }) |
| @@ -268,8 +282,9 @@ impl ServerCertVerifier for RecordingVerifier { | |||
| 268 | _ocsp_response: &[u8], | 282 | _ocsp_response: &[u8], |
| 269 | _now: std::time::SystemTime, | 283 | _now: std::time::SystemTime, |
| 270 | ) -> Result<ServerCertVerified, rustls::Error> { | 284 | ) -> Result<ServerCertVerified, rustls::Error> { |
| 271 | let fingerprint = spki_fingerprint(&end_entity.0) | 285 | let fingerprint = spki_fingerprint(&end_entity.0).map_err(|error| { |
| 272 | .map_err(|error| rustls::Error::General(format!("certificate parse failed: {error}")))?; | 286 | rustls::Error::General(format!("certificate parse failed: {error}")) |
| 287 | })?; | ||
| 273 | *self.observed.lock() = Some(fingerprint); | 288 | *self.observed.lock() = Some(fingerprint); |
| 274 | Ok(ServerCertVerified::assertion()) | 289 | Ok(ServerCertVerified::assertion()) |
| 275 | } | 290 | } |
| @@ -313,7 +328,7 @@ impl ServerCertVerifier for RecordingVerifier { | |||
| 313 | struct Session { | 328 | struct Session { |
| 314 | sock: TcpStream, | 329 | sock: TcpStream, |
| 315 | tls: rustls::ClientConnection, | 330 | tls: rustls::ClientConnection, |
| 316 | lines: LineBuffer, | 331 | frames: FrameBuffer, |
| 317 | read_timeout: Option<Duration>, | 332 | read_timeout: Option<Duration>, |
| 318 | } | 333 | } |
| 319 | 334 | ||
| @@ -329,37 +344,58 @@ pub struct TlsStream { | |||
| 329 | } | 344 | } |
| 330 | 345 | ||
| 331 | impl TlsStream { | 346 | impl TlsStream { |
| 332 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | 347 | /// Write one enveloped request. Application code allocates `id`. |
| 348 | pub fn write_request( | ||
| 349 | &mut self, | ||
| 350 | id: u64, | ||
| 351 | idem: Option<&str>, | ||
| 352 | payload: &str, | ||
| 353 | ) -> std::io::Result<()> { | ||
| 354 | self.write_frame(&request_line(id, idem, payload)) | ||
| 355 | } | ||
| 356 | |||
| 357 | /// Write one raw frame payload. | ||
| 358 | pub fn write_frame(&mut self, payload: &str) -> std::io::Result<()> { | ||
| 359 | let mut s = self.session.lock(); | ||
| 360 | write_protocol_frame(&mut s.tls.writer(), payload)?; | ||
| 361 | s.drive() | ||
| 362 | } | ||
| 363 | |||
| 364 | /// Block until one whole frame arrives. | ||
| 365 | pub fn read_frame(&mut self) -> std::io::Result<Option<String>> { | ||
| 333 | let mut s = self.session.lock(); | 366 | let mut s = self.session.lock(); |
| 334 | loop { | 367 | loop { |
| 335 | if let Some(line) = s.lines.next_line() { | 368 | match s.frames.next_frame() { |
| 336 | buf.push_str(&line); | 369 | Some(Ok(frame)) => return Ok(Some(frame)), |
| 337 | buf.push('\n'); | 370 | Some(Err(error)) => { |
| 338 | return Ok(line.len() + 1); | 371 | return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); |
| 372 | } | ||
| 373 | None => {} | ||
| 339 | } | 374 | } |
| 340 | 375 | ||
| 341 | let mut chunk = [0u8; 4096]; | 376 | let mut chunk = [0u8; 4096]; |
| 342 | match s.tls.reader().read(&mut chunk) { | 377 | match s.tls.reader().read(&mut chunk) { |
| 343 | Ok(0) => { | 378 | Ok(0) => return Ok(None), |
| 344 | if let Some(rest) = s.lines.take_rest() { | 379 | Ok(n) => s.frames.push(&chunk[..n]), |
| 345 | let read = rest.len(); | ||
| 346 | buf.push_str(&rest); | ||
| 347 | return Ok(read); | ||
| 348 | } | ||
| 349 | return Ok(0); | ||
| 350 | } | ||
| 351 | Ok(n) => s.lines.push(&chunk[..n]), | ||
| 352 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | 380 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, |
| 353 | Err(error) => return Err(error), | 381 | Err(error) => return Err(error), |
| 354 | } | 382 | } |
| 355 | } | 383 | } |
| 356 | } | 384 | } |
| 357 | 385 | ||
| 358 | pub fn write_line(&mut self, message: &str) -> std::io::Result<()> { | 386 | /// Block until one `RES` or `EVT` arrives. |
| 359 | let mut s = self.session.lock(); | 387 | pub fn read_packet(&mut self) -> std::io::Result<Option<Packet>> { |
| 360 | s.tls.writer().write_all(message.as_bytes())?; | 388 | loop { |
| 361 | s.tls.writer().write_all(b"\n")?; | 389 | let Some(frame) = self.read_frame()? else { |
| 362 | s.drive() | 390 | return Ok(None); |
| 391 | }; | ||
| 392 | match parse_packet(&frame) { | ||
| 393 | Ok(packet) => return Ok(Some(packet)), | ||
| 394 | Err(error) => { | ||
| 395 | return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); | ||
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 363 | } | 399 | } |
| 364 | 400 | ||
| 365 | pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { | 401 | pub fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> { |
| @@ -368,24 +404,36 @@ impl TlsStream { | |||
| 368 | s.sock.set_read_timeout(duration) | 404 | s.sock.set_read_timeout(duration) |
| 369 | } | 405 | } |
| 370 | 406 | ||
| 371 | /// Return one complete line if the socket already has one, without | 407 | /// Return one packet if the socket already has one, without blocking. |
| 372 | /// blocking. Used to pick up server pushes between requests. | 408 | /// Used to pick up server pushes between requests. |
| 373 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | 409 | pub fn read_available_packet(&mut self) -> std::io::Result<Option<Packet>> { |
| 374 | let mut s = self.session.lock(); | 410 | let mut s = self.session.lock(); |
| 375 | if let Some(line) = s.lines.next_line() { | ||
| 376 | return Ok(Some(line)); | ||
| 377 | } | ||
| 378 | 411 | ||
| 379 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the | 412 | // `set_read_timeout(Some(0))` is rejected by std, so poll with the |
| 380 | // shortest timeout the socket accepts instead of a nonblocking mode. | 413 | // shortest timeout the socket accepts instead of a nonblocking mode. |
| 381 | s.sock | 414 | s.sock |
| 382 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; | 415 | .set_read_timeout(Some(std::time::Duration::from_millis(1)))?; |
| 383 | let drained = (|| -> std::io::Result<()> { | 416 | let drained = (|| -> std::io::Result<Option<Packet>> { |
| 384 | loop { | 417 | loop { |
| 418 | match s.frames.next_frame() { | ||
| 419 | Some(Ok(frame)) => { | ||
| 420 | return match parse_packet(&frame) { | ||
| 421 | Ok(packet) => Ok(Some(packet)), | ||
| 422 | Err(error) => { | ||
| 423 | Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) | ||
| 424 | } | ||
| 425 | }; | ||
| 426 | } | ||
| 427 | Some(Err(error)) => { | ||
| 428 | return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)); | ||
| 429 | } | ||
| 430 | None => {} | ||
| 431 | } | ||
| 432 | |||
| 385 | let mut buf = [0u8; 4096]; | 433 | let mut buf = [0u8; 4096]; |
| 386 | match s.tls.reader().read(&mut buf) { | 434 | match s.tls.reader().read(&mut buf) { |
| 387 | Ok(0) => return Ok(()), | 435 | Ok(0) => return Ok(None), |
| 388 | Ok(n) => s.lines.push(&buf[..n]), | 436 | Ok(n) => s.frames.push(&buf[..n]), |
| 389 | // The reader only drains plaintext rustls has already | 437 | // The reader only drains plaintext rustls has already |
| 390 | // decrypted; `drive` moves bytes off the socket. | 438 | // decrypted; `drive` moves bytes off the socket. |
| 391 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { | 439 | Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { |
| @@ -397,7 +445,7 @@ impl TlsStream { | |||
| 397 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | 445 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut |
| 398 | ) => | 446 | ) => |
| 399 | { | 447 | { |
| 400 | return Ok(()); | 448 | return Ok(None); |
| 401 | } | 449 | } |
| 402 | Err(error) => return Err(error), | 450 | Err(error) => return Err(error), |
| 403 | } | 451 | } |
| @@ -408,15 +456,14 @@ impl TlsStream { | |||
| 408 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | 456 | std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut |
| 409 | ) => | 457 | ) => |
| 410 | { | 458 | { |
| 411 | return Ok(()); | 459 | return Ok(None); |
| 412 | } | 460 | } |
| 413 | Err(error) => return Err(error), | 461 | Err(error) => return Err(error), |
| 414 | } | 462 | } |
| 415 | } | 463 | } |
| 416 | })(); | 464 | })(); |
| 417 | s.sock.set_read_timeout(s.read_timeout)?; | 465 | s.sock.set_read_timeout(s.read_timeout)?; |
| 418 | drained?; | 466 | drained |
| 419 | Ok(s.lines.next_line()) | ||
| 420 | } | 467 | } |
| 421 | } | 468 | } |
| 422 | 469 | ||
diff --git a/src/client/view.rs b/src/client/view.rs index 4615720..0886d64 100644 --- a/src/client/view.rs +++ b/src/client/view.rs | |||
| @@ -7,15 +7,17 @@ use crate::blackjack::BlackjackState; | |||
| 7 | use crate::client::games::roulette::{Cursor, Direction}; | 7 | use crate::client::games::roulette::{Cursor, Direction}; |
| 8 | use crate::client::games::traits::{GamePhase, GameState}; | 8 | use crate::client::games::traits::{GamePhase, GameState}; |
| 9 | use crate::roulette::{BetTarget, Bets}; | 9 | use crate::roulette::{BetTarget, Bets}; |
| 10 | use serde::{Deserialize, Serialize}; | ||
| 10 | 11 | ||
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 13 | #[serde(rename_all = "snake_case")] | ||
| 12 | pub enum RoulettePhase { | 14 | pub enum RoulettePhase { |
| 13 | Placing, | 15 | Placing, |
| 14 | Spinning, | 16 | Spinning, |
| 15 | Result, | 17 | Result, |
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | #[derive(Debug, Clone, PartialEq, Eq)] | 20 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 19 | pub struct RouletteView { | 21 | pub struct RouletteView { |
| 20 | pub bet_amount: u64, | 22 | pub bet_amount: u64, |
| 21 | pub balance: Option<i64>, | 23 | pub balance: Option<i64>, |
| @@ -51,7 +53,10 @@ impl RouletteView { | |||
| 51 | Some("result") => RoulettePhase::Result, | 53 | Some("result") => RoulettePhase::Result, |
| 52 | _ => RoulettePhase::Placing, | 54 | _ => RoulettePhase::Placing, |
| 53 | }; | 55 | }; |
| 54 | let number = state.data.get("number").and_then(|value| value.parse().ok()); | 56 | let number = state |
| 57 | .data | ||
| 58 | .get("number") | ||
| 59 | .and_then(|value| value.parse().ok()); | ||
| 55 | let color = state.data.get("color").cloned(); | 60 | let color = state.data.get("color").cloned(); |
| 56 | let winnings = state | 61 | let winnings = state |
| 57 | .data | 62 | .data |
| @@ -82,14 +87,15 @@ impl RouletteView { | |||
| 82 | } | 87 | } |
| 83 | } | 88 | } |
| 84 | 89 | ||
| 85 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 90 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 91 | #[serde(rename_all = "snake_case")] | ||
| 86 | pub enum SlotsPhase { | 92 | pub enum SlotsPhase { |
| 87 | Ready, | 93 | Ready, |
| 88 | Spinning, | 94 | Spinning, |
| 89 | Result, | 95 | Result, |
| 90 | } | 96 | } |
| 91 | 97 | ||
| 92 | #[derive(Debug, Clone, PartialEq, Eq)] | 98 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 93 | pub struct SlotsView { | 99 | pub struct SlotsView { |
| 94 | pub bet: u64, | 100 | pub bet: u64, |
| 95 | pub balance: Option<i64>, | 101 | pub balance: Option<i64>, |
| @@ -149,7 +155,7 @@ impl SlotsView { | |||
| 149 | } | 155 | } |
| 150 | } | 156 | } |
| 151 | 157 | ||
| 152 | #[derive(Debug, Clone, PartialEq, Eq)] | 158 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 153 | pub struct BlackjackView { | 159 | pub struct BlackjackView { |
| 154 | pub status: String, | 160 | pub status: String, |
| 155 | pub balance: Option<i64>, | 161 | pub balance: Option<i64>, |
| @@ -186,7 +192,7 @@ impl BlackjackView { | |||
| 186 | } | 192 | } |
| 187 | } | 193 | } |
| 188 | 194 | ||
| 189 | #[derive(Debug, Clone, PartialEq)] | 195 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 190 | pub struct CrashView { | 196 | pub struct CrashView { |
| 191 | pub status: String, | 197 | pub status: String, |
| 192 | pub balance: Option<i64>, | 198 | pub balance: Option<i64>, |
| @@ -195,6 +201,11 @@ pub struct CrashView { | |||
| 195 | pub potential: u64, | 201 | pub potential: u64, |
| 196 | pub crash_point: Option<f64>, | 202 | pub crash_point: Option<f64>, |
| 197 | pub payout: u64, | 203 | pub payout: u64, |
| 204 | pub started_at: u64, | ||
| 205 | /// `sha256(seed)` for this round, before it ends. | ||
| 206 | pub commit: Option<String>, | ||
| 207 | /// The revealed seed, after the round ends. | ||
| 208 | pub seed: Option<String>, | ||
| 198 | pub error: Option<String>, | 209 | pub error: Option<String>, |
| 199 | } | 210 | } |
| 200 | 211 | ||
| @@ -232,6 +243,13 @@ impl CrashView { | |||
| 232 | .get("payout") | 243 | .get("payout") |
| 233 | .and_then(|value| value.parse::<u64>().ok()) | 244 | .and_then(|value| value.parse::<u64>().ok()) |
| 234 | .unwrap_or(0), | 245 | .unwrap_or(0), |
| 246 | started_at: state | ||
| 247 | .data | ||
| 248 | .get("started_at") | ||
| 249 | .and_then(|value| value.parse::<u64>().ok()) | ||
| 250 | .unwrap_or(0), | ||
| 251 | commit: state.data.get("commit").cloned(), | ||
| 252 | seed: state.data.get("seed").cloned(), | ||
| 235 | error: state | 253 | error: state |
| 236 | .data | 254 | .data |
| 237 | .get("error") | 255 | .get("error") |
| @@ -242,7 +260,7 @@ impl CrashView { | |||
| 242 | } | 260 | } |
| 243 | 261 | ||
| 244 | /// The lobby: who is logged in, the balance, and what can be played. | 262 | /// The lobby: who is logged in, the balance, and what can be played. |
| 245 | #[derive(Debug, Clone, PartialEq, Eq)] | 263 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 246 | pub struct LobbyView { | 264 | pub struct LobbyView { |
| 247 | pub connected: bool, | 265 | pub connected: bool, |
| 248 | pub user: Option<String>, | 266 | pub user: Option<String>, |
| @@ -254,13 +272,14 @@ pub struct LobbyView { | |||
| 254 | } | 272 | } |
| 255 | 273 | ||
| 256 | /// The play log, already parsed. | 274 | /// The play log, already parsed. |
| 257 | #[derive(Debug, Clone, PartialEq)] | 275 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 258 | pub struct HistoryView { | 276 | pub struct HistoryView { |
| 259 | pub records: Vec<crate::history::PlayRecord>, | 277 | pub records: Vec<crate::history::PlayRecord>, |
| 260 | } | 278 | } |
| 261 | 279 | ||
| 262 | /// Everything a frontend can draw. | 280 | /// Everything a frontend can draw. |
| 263 | #[derive(Debug, Clone, PartialEq)] | 281 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 282 | #[serde(tag = "screen", rename_all = "snake_case")] | ||
| 264 | pub enum View { | 283 | pub enum View { |
| 265 | Lobby(LobbyView), | 284 | Lobby(LobbyView), |
| 266 | History(HistoryView), | 285 | History(HistoryView), |
| @@ -271,17 +290,28 @@ pub enum View { | |||
| 271 | } | 290 | } |
| 272 | 291 | ||
| 273 | /// Semantic input. Frontends turn keys and clicks into these. | 292 | /// Semantic input. Frontends turn keys and clicks into these. |
| 274 | #[derive(Debug, Clone, PartialEq, Eq)] | 293 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 275 | pub enum Command { | 294 | pub enum Command { |
| 276 | /// The credential is already hashed by the frontend. | 295 | /// The credential is already hashed by the frontend. |
| 277 | Login { user: String, credential: String }, | 296 | Login { |
| 297 | user: String, | ||
| 298 | credential: String, | ||
| 299 | }, | ||
| 278 | Games, | 300 | Games, |
| 279 | Ping, | 301 | Ping, |
| 280 | Echo(String), | 302 | Echo(String), |
| 281 | Info, | 303 | Info, |
| 282 | Register { user: String, credential: String }, | 304 | Register { |
| 283 | Play { game: String, bet: Option<u64> }, | 305 | user: String, |
| 284 | History { limit: u32 }, | 306 | credential: String, |
| 307 | }, | ||
| 308 | Play { | ||
| 309 | game: String, | ||
| 310 | bet: Option<u64>, | ||
| 311 | }, | ||
| 312 | History { | ||
| 313 | limit: u32, | ||
| 314 | }, | ||
| 285 | ShowLobby, | 315 | ShowLobby, |
| 286 | Logout, | 316 | Logout, |
| 287 | SetBet(u64), | 317 | SetBet(u64), |
diff --git a/src/crash.rs b/src/crash.rs index 9d6e98c..40b8743 100644 --- a/src/crash.rs +++ b/src/crash.rs | |||
| @@ -6,6 +6,7 @@ | |||
| 6 | //! clock, never from anything the client sends. | 6 | //! clock, never from anything the client sends. |
| 7 | 7 | ||
| 8 | use serde::{Deserialize, Serialize}; | 8 | use serde::{Deserialize, Serialize}; |
| 9 | use sha2::{Digest, Sha256}; | ||
| 9 | use std::time::Duration; | 10 | use std::time::Duration; |
| 10 | 11 | ||
| 11 | /// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. | 12 | /// `exp(GROWTH_RATE * t)` doubles the multiplier every five seconds. |
| @@ -47,6 +48,53 @@ pub fn crash_point_from_uniform(u: f64) -> f64 { | |||
| 47 | ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) | 48 | ((1.0 - HOUSE_EDGE) / (1.0 - u)).clamp(1.0, MAX_MULTIPLIER) |
| 48 | } | 49 | } |
| 49 | 50 | ||
| 51 | /// How many bytes a round seed contains. The first eight bytes choose the | ||
| 52 | /// crash point; the rest are revealed and hashed with it. | ||
| 53 | pub const SEED_LEN: usize = 32; | ||
| 54 | |||
| 55 | /// Hex-encoded SHA-256 of a round seed, sent before the round starts. | ||
| 56 | pub fn commit_seed(seed: &[u8]) -> String { | ||
| 57 | let digest = Sha256::digest(seed); | ||
| 58 | format!("{digest:x}") | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Uniform draw encoded by a seed: the first eight bytes, big-endian. | ||
| 62 | pub fn seed_to_uniform(seed: &[u8]) -> f64 { | ||
| 63 | let mut bytes = [0u8; 8]; | ||
| 64 | let take = seed.len().min(8); | ||
| 65 | bytes[..take].copy_from_slice(&seed[..take]); | ||
| 66 | u64::from_be_bytes(bytes) as f64 / 18_446_744_073_709_551_616.0 | ||
| 67 | } | ||
| 68 | |||
| 69 | /// The crash point a seed commits to. A client recomputes this after the | ||
| 70 | /// reveal and checks it against the published commit. | ||
| 71 | pub fn crash_point_from_seed(seed: &[u8]) -> f64 { | ||
| 72 | crash_point_from_uniform(seed_to_uniform(seed)) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Decode a lowercase or uppercase hex seed. Returns `None` for odd lengths, | ||
| 76 | /// bad digits or a seed that is not exactly [`SEED_LEN`] bytes. | ||
| 77 | pub fn seed_from_hex(raw: &str) -> Option<Vec<u8>> { | ||
| 78 | if raw.len() != SEED_LEN * 2 { | ||
| 79 | return None; | ||
| 80 | } | ||
| 81 | let mut seed = Vec::with_capacity(SEED_LEN); | ||
| 82 | for pair in raw.as_bytes().chunks_exact(2) { | ||
| 83 | let high = (pair[0] as char).to_digit(16)?; | ||
| 84 | let low = (pair[1] as char).to_digit(16)?; | ||
| 85 | seed.push((high * 16 + low) as u8); | ||
| 86 | } | ||
| 87 | Some(seed) | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Check a revealed seed against the commit published before the round. | ||
| 91 | pub fn verify_seed(seed_hex: &str, commit: &str) -> bool { | ||
| 92 | let Some(seed) = seed_from_hex(seed_hex) else { | ||
| 93 | return false; | ||
| 94 | }; | ||
| 95 | commit_seed(&seed).eq_ignore_ascii_case(commit) | ||
| 96 | } | ||
| 97 | |||
| 50 | /// Whole-Kr cashout value. Balances are integers, so the payout is floored. | 98 | /// Whole-Kr cashout value. Balances are integers, so the payout is floored. |
| 51 | pub fn payout(bet: u64, multiplier: f64) -> u64 { | 99 | pub fn payout(bet: u64, multiplier: f64) -> u64 { |
| 52 | if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { | 100 | if bet == 0 || !multiplier.is_finite() || multiplier <= 0.0 { |
| @@ -72,10 +120,16 @@ pub struct CrashState { | |||
| 72 | pub multiplier: f64, | 120 | pub multiplier: f64, |
| 73 | /// `floor(bet * multiplier)` for the value above. | 121 | /// `floor(bet * multiplier)` for the value above. |
| 74 | pub potential: u64, | 122 | pub potential: u64, |
| 123 | /// Server Unix time when the round started, milliseconds. | ||
| 124 | pub started_at: u64, | ||
| 75 | /// Hidden until the round ends. | 125 | /// Hidden until the round ends. |
| 76 | pub crash_point: Option<f64>, | 126 | pub crash_point: Option<f64>, |
| 77 | pub payout: u64, | 127 | pub payout: u64, |
| 78 | pub result: String, | 128 | pub result: String, |
| 129 | /// `sha256(seed)`, present for the whole round. | ||
| 130 | pub commit: Option<String>, | ||
| 131 | /// The seed itself, present only after the round ends. | ||
| 132 | pub seed: Option<String>, | ||
| 79 | } | 133 | } |
| 80 | 134 | ||
| 81 | impl CrashState { | 135 | 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 @@ | |||
| 1 | //! Newline-delimited protocol helpers shared by the client and server. | 1 | //! Protocol v5: bounded frames and the request/reply/event envelope. |
| 2 | //! | 2 | //! |
| 3 | //! TLS reads can return more than one line at a time. Keeping the leftover | 3 | //! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and |
| 4 | //! bytes is essential: dropping them silently loses the next message. | 4 | //! capped, so a peer cannot make the reader allocate without bound. Inside a |
| 5 | //! frame the payload is text: | ||
| 6 | //! | ||
| 7 | //! ```text | ||
| 8 | //! REQ <id> [idem=<token>] <command> | ||
| 9 | //! RES <id> <body> | ||
| 10 | //! EVT <seq> <body> | ||
| 11 | //! ``` | ||
| 12 | //! | ||
| 13 | //! The command and body grammar is the same text protocol the games already | ||
| 14 | //! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds | ||
| 15 | //! correlation, event numbering and a hard size limit. | ||
| 16 | |||
| 17 | use std::fmt; | ||
| 18 | use std::io::{self, Read, Write}; | ||
| 19 | |||
| 20 | /// Largest payload a peer may send in one frame. A game message is a few | ||
| 21 | /// hundred bytes; 64 KiB is generous and still bounds the reader. | ||
| 22 | pub const MAX_FRAME_LEN: usize = 64 * 1024; | ||
| 23 | |||
| 24 | /// How many request ids a connection remembers for idempotent retries. | ||
| 25 | pub const REPLAY_CACHE_LEN: usize = 256; | ||
| 26 | |||
| 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 28 | pub enum FrameError { | ||
| 29 | /// The declared payload length exceeds [`MAX_FRAME_LEN`]. | ||
| 30 | TooLarge(usize), | ||
| 31 | /// The payload was not valid UTF-8. | ||
| 32 | InvalidUtf8, | ||
| 33 | } | ||
| 34 | |||
| 35 | impl fmt::Display for FrameError { | ||
| 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 37 | match self { | ||
| 38 | FrameError::TooLarge(length) => { | ||
| 39 | write!( | ||
| 40 | f, | ||
| 41 | "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit" | ||
| 42 | ) | ||
| 43 | } | ||
| 44 | FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"), | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 5 | 48 | ||
| 6 | use std::io::{self, Read}; | 49 | impl std::error::Error for FrameError {} |
| 7 | 50 | ||
| 51 | /// A bounded frame decoder. Feed it bytes from a socket, take whole frames. | ||
| 8 | #[derive(Debug, Default)] | 52 | #[derive(Debug, Default)] |
| 9 | pub struct LineBuffer { | 53 | pub struct FrameBuffer { |
| 10 | buffer: Vec<u8>, | 54 | buffer: Vec<u8>, |
| 11 | } | 55 | } |
| 12 | 56 | ||
| 13 | impl LineBuffer { | 57 | impl FrameBuffer { |
| 14 | pub fn new() -> Self { | 58 | pub fn new() -> Self { |
| 15 | Self::default() | 59 | Self::default() |
| 16 | } | 60 | } |
| @@ -19,46 +63,172 @@ impl LineBuffer { | |||
| 19 | self.buffer.extend_from_slice(bytes); | 63 | self.buffer.extend_from_slice(bytes); |
| 20 | } | 64 | } |
| 21 | 65 | ||
| 22 | /// Return the next complete line, including a line that ended at EOF. | 66 | /// Return the next complete frame, if the buffer holds one. |
| 23 | pub fn next_line(&mut self) -> Option<String> { | 67 | /// |
| 24 | let end = self.buffer.iter().position(|byte| *byte == b'\n')?; | 68 | /// A declared length above the limit is reported immediately instead of |
| 25 | let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); | 69 | /// waiting for bytes that will never be accepted; the caller should close |
| 26 | line.pop(); // '\n' | 70 | /// the connection. |
| 27 | if line.last() == Some(&b'\r') { | 71 | pub fn next_frame(&mut self) -> Option<Result<String, FrameError>> { |
| 28 | line.pop(); | 72 | if self.buffer.len() < 4 { |
| 73 | return None; | ||
| 29 | } | 74 | } |
| 30 | Some(String::from_utf8_lossy(&line).into_owned()) | ||
| 31 | } | ||
| 32 | 75 | ||
| 33 | /// Return the unterminated remainder at EOF, if any. | 76 | let length = u32::from_be_bytes([ |
| 34 | pub fn take_rest(&mut self) -> Option<String> { | 77 | self.buffer[0], |
| 35 | if self.buffer.is_empty() { | 78 | self.buffer[1], |
| 79 | self.buffer[2], | ||
| 80 | self.buffer[3], | ||
| 81 | ]) as usize; | ||
| 82 | |||
| 83 | if length > MAX_FRAME_LEN { | ||
| 84 | self.buffer.clear(); | ||
| 85 | return Some(Err(FrameError::TooLarge(length))); | ||
| 86 | } | ||
| 87 | if self.buffer.len() < 4 + length { | ||
| 36 | return None; | 88 | return None; |
| 37 | } | 89 | } |
| 38 | let mut rest = std::mem::take(&mut self.buffer); | 90 | |
| 39 | if rest.last() == Some(&b'\r') { | 91 | let payload: Vec<u8> = self.buffer[4..4 + length].to_vec(); |
| 40 | rest.pop(); | 92 | self.buffer.drain(..4 + length); |
| 93 | match String::from_utf8(payload) { | ||
| 94 | Ok(text) => Some(Ok(text)), | ||
| 95 | Err(_) => Some(Err(FrameError::InvalidUtf8)), | ||
| 41 | } | 96 | } |
| 42 | Some(String::from_utf8_lossy(&rest).into_owned()) | ||
| 43 | } | 97 | } |
| 44 | 98 | ||
| 45 | /// Read one line from a blocking reader, preserving any extra lines for | 99 | /// Read one frame from a blocking reader, preserving leftover bytes. |
| 46 | /// the next call. Returns `None` at EOF with nothing buffered. | 100 | /// `None` means EOF with no complete frame buffered. |
| 47 | pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { | 101 | pub fn read_frame<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { |
| 48 | loop { | 102 | loop { |
| 49 | if let Some(line) = self.next_line() { | 103 | match self.next_frame() { |
| 50 | return Ok(Some(line)); | 104 | Some(Ok(frame)) => return Ok(Some(frame)), |
| 105 | Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), | ||
| 106 | None => {} | ||
| 51 | } | 107 | } |
| 52 | 108 | ||
| 53 | let mut chunk = [0u8; 4096]; | 109 | let mut chunk = [0u8; 4096]; |
| 54 | match reader.read(&mut chunk) { | 110 | match reader.read(&mut chunk) { |
| 55 | Ok(0) => return Ok(self.take_rest()), | 111 | Ok(0) => return Ok(None), |
| 56 | Ok(n) => self.push(&chunk[..n]), | 112 | Ok(n) => self.push(&chunk[..n]), |
| 57 | Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, | 113 | Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, |
| 58 | Err(error) => return Err(error), | 114 | Err(error) => return Err(error), |
| 59 | } | 115 | } |
| 60 | } | 116 | } |
| 61 | } | 117 | } |
| 118 | |||
| 119 | /// True when a partial frame is buffered (used by tests and diagnostics). | ||
| 120 | pub fn is_empty(&self) -> bool { | ||
| 121 | self.buffer.is_empty() | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Encode one payload as a length-prefixed frame. | ||
| 126 | pub fn encode_frame(payload: &str) -> Vec<u8> { | ||
| 127 | let length = payload.len() as u32; | ||
| 128 | let mut bytes = Vec::with_capacity(4 + payload.len()); | ||
| 129 | bytes.extend_from_slice(&length.to_be_bytes()); | ||
| 130 | bytes.extend_from_slice(payload.as_bytes()); | ||
| 131 | bytes | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Write one payload as a length-prefixed frame. | ||
| 135 | pub fn write_frame<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> { | ||
| 136 | writer.write_all(&encode_frame(payload))?; | ||
| 137 | writer.flush() | ||
| 138 | } | ||
| 139 | |||
| 140 | /// One client request. `id` is the correlation key; `idem` is optional | ||
| 141 | /// metadata for logs and a future cross-connection cache. | ||
| 142 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 143 | pub struct Request { | ||
| 144 | pub id: u64, | ||
| 145 | pub idem: Option<String>, | ||
| 146 | pub payload: String, | ||
| 147 | } | ||
| 148 | |||
| 149 | /// A frame received by a client. | ||
| 150 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 151 | pub enum Packet { | ||
| 152 | Reply { id: u64, body: String }, | ||
| 153 | Event { seq: u64, body: String }, | ||
| 154 | } | ||
| 155 | |||
| 156 | pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String { | ||
| 157 | match idem { | ||
| 158 | Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"), | ||
| 159 | _ => format!("REQ {id} {payload}"), | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | pub fn reply_line(id: u64, body: &str) -> String { | ||
| 164 | format!("RES {id} {body}") | ||
| 165 | } | ||
| 166 | |||
| 167 | pub fn event_line(seq: u64, body: &str) -> String { | ||
| 168 | format!("EVT {seq} {body}") | ||
| 169 | } | ||
| 170 | |||
| 171 | /// Parse a `REQ` payload. Errors are safe to return to the peer. | ||
| 172 | pub fn parse_request(frame: &str) -> Result<Request, String> { | ||
| 173 | let frame = frame.trim_end(); | ||
| 174 | let rest = frame | ||
| 175 | .strip_prefix("REQ ") | ||
| 176 | .ok_or_else(|| "Malformed request: expected REQ".to_string())?; | ||
| 177 | |||
| 178 | let (id_token, mut rest) = split_token(rest); | ||
| 179 | let id: u64 = id_token | ||
| 180 | .parse() | ||
| 181 | .map_err(|_| "Malformed request id".to_string())?; | ||
| 182 | |||
| 183 | let mut idem = None; | ||
| 184 | let (next, after) = split_token(rest); | ||
| 185 | if let Some(value) = next.strip_prefix("idem=") { | ||
| 186 | if value.is_empty() { | ||
| 187 | return Err("Malformed idempotency key".to_string()); | ||
| 188 | } | ||
| 189 | idem = Some(value.to_string()); | ||
| 190 | rest = after; | ||
| 191 | } | ||
| 192 | |||
| 193 | Ok(Request { | ||
| 194 | id, | ||
| 195 | idem, | ||
| 196 | payload: rest.to_string(), | ||
| 197 | }) | ||
| 198 | } | ||
| 199 | |||
| 200 | /// Parse a `RES` or `EVT` payload on the client side. | ||
| 201 | pub fn parse_packet(frame: &str) -> Result<Packet, String> { | ||
| 202 | let frame = frame.trim_end(); | ||
| 203 | if let Some(rest) = frame.strip_prefix("RES ") { | ||
| 204 | let (id_token, body) = split_token(rest); | ||
| 205 | let id: u64 = id_token | ||
| 206 | .parse() | ||
| 207 | .map_err(|_| "Malformed reply id".to_string())?; | ||
| 208 | return Ok(Packet::Reply { | ||
| 209 | id, | ||
| 210 | body: body.to_string(), | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | if let Some(rest) = frame.strip_prefix("EVT ") { | ||
| 214 | let (seq_token, body) = split_token(rest); | ||
| 215 | let seq: u64 = seq_token | ||
| 216 | .parse() | ||
| 217 | .map_err(|_| "Malformed event sequence".to_string())?; | ||
| 218 | return Ok(Packet::Event { | ||
| 219 | seq, | ||
| 220 | body: body.to_string(), | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | Err(format!("Unexpected packet: {frame}")) | ||
| 224 | } | ||
| 225 | |||
| 226 | fn split_token(rest: &str) -> (&str, &str) { | ||
| 227 | let rest = rest.trim_start(); | ||
| 228 | match rest.find(' ') { | ||
| 229 | Some(index) => (&rest[..index], rest[index + 1..].trim_start()), | ||
| 230 | None => (rest, ""), | ||
| 231 | } | ||
| 62 | } | 232 | } |
| 63 | 233 | ||
| 64 | /// A stable, user-facing message for a failed request. | 234 | /// A stable, user-facing message for a failed request. |
diff --git a/src/server/core.rs b/src/server/core.rs index 3cd6469..0b2efb4 100644 --- a/src/server/core.rs +++ b/src/server/core.rs | |||
| @@ -1,22 +1,26 @@ | |||
| 1 | use bcrypt::{hash, verify, DEFAULT_COST}; | 1 | use bcrypt::{DEFAULT_COST, hash, verify}; |
| 2 | use rusqlite::Connection; | 2 | use rusqlite::Connection; |
| 3 | use std::collections::HashMap; | 3 | use std::collections::{HashMap, VecDeque}; |
| 4 | use std::sync::Arc; | 4 | use std::sync::Arc; |
| 5 | 5 | ||
| 6 | use crate::blackjack::BlackjackState; | 6 | use crate::blackjack::BlackjackState; |
| 7 | use crate::crash::CrashState; | 7 | use crate::crash::CrashState; |
| 8 | use crate::protocol::{REPLAY_CACHE_LEN, Request}; | ||
| 8 | use crate::server::database; | 9 | use crate::server::database; |
| 9 | use crate::version::{download_url, hello_line, Version, VERSION}; | ||
| 10 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; | 10 | use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; |
| 11 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; | 11 | use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; |
| 12 | use crate::server::games::registry::GameRegistry; | 12 | use crate::server::games::registry::GameRegistry; |
| 13 | use crate::server::games::session::SessionManager; | 13 | use crate::server::games::session::SessionManager; |
| 14 | use crate::server::games::traits::{GameError, GamePhase, GameState}; | 14 | use crate::server::games::traits::{GameError, GamePhase, GameState}; |
| 15 | use crate::version::{VERSION, Version, download_url, hello_line, pong_line, unix_time_ms}; | ||
| 15 | 16 | ||
| 16 | #[derive(Debug, Default)] | 17 | #[derive(Debug, Default)] |
| 17 | pub struct ConnectionState { | 18 | pub struct ConnectionState { |
| 18 | user: Option<String>, | 19 | user: Option<String>, |
| 19 | greeted: bool, | 20 | greeted: bool, |
| 21 | /// Last replies on this connection, oldest first. Request ids are unique | ||
| 22 | /// while cached, so a retried request replays its first result. | ||
| 23 | replays: VecDeque<(u64, String, String)>, | ||
| 20 | } | 24 | } |
| 21 | 25 | ||
| 22 | /// Settle reservations left behind by a previous process. | 26 | /// Settle reservations left behind by a previous process. |
| @@ -39,6 +43,27 @@ impl ConnectionState { | |||
| 39 | pub fn greeted(&self) -> bool { | 43 | pub fn greeted(&self) -> bool { |
| 40 | self.greeted | 44 | self.greeted |
| 41 | } | 45 | } |
| 46 | |||
| 47 | fn replay(&self, id: u64, payload: &str) -> Option<Result<String, String>> { | ||
| 48 | self.replays | ||
| 49 | .iter() | ||
| 50 | .find(|(cached_id, _, _)| *cached_id == id) | ||
| 51 | .map(|(_, cached_payload, reply)| { | ||
| 52 | if cached_payload == payload { | ||
| 53 | Ok(reply.clone()) | ||
| 54 | } else { | ||
| 55 | Err("ERROR Request id reused with a different command".to_string()) | ||
| 56 | } | ||
| 57 | }) | ||
| 58 | } | ||
| 59 | |||
| 60 | fn remember(&mut self, id: u64, payload: &str, reply: &str) { | ||
| 61 | if self.replays.len() >= REPLAY_CACHE_LEN { | ||
| 62 | self.replays.pop_front(); | ||
| 63 | } | ||
| 64 | self.replays | ||
| 65 | .push_back((id, payload.to_string(), reply.to_string())); | ||
| 66 | } | ||
| 42 | } | 67 | } |
| 43 | 68 | ||
| 44 | pub struct ServerCore { | 69 | pub struct ServerCore { |
| @@ -49,6 +74,8 @@ pub struct ServerCore { | |||
| 49 | blackjack: BlackjackGame, | 74 | blackjack: BlackjackGame, |
| 50 | crash: CrashGame, | 75 | crash: CrashGame, |
| 51 | clock: Arc<dyn Clock>, | 76 | clock: Arc<dyn Clock>, |
| 77 | /// Last time a crash tick was pushed, in seconds on the game clock. | ||
| 78 | last_tick: HashMap<String, f64>, | ||
| 52 | } | 79 | } |
| 53 | 80 | ||
| 54 | impl ServerCore { | 81 | impl ServerCore { |
| @@ -65,6 +92,7 @@ impl ServerCore { | |||
| 65 | blackjack: BlackjackGame::new(), | 92 | blackjack: BlackjackGame::new(), |
| 66 | crash: CrashGame::new(), | 93 | crash: CrashGame::new(), |
| 67 | clock: Arc::new(RealClock::new()), | 94 | clock: Arc::new(RealClock::new()), |
| 95 | last_tick: HashMap::new(), | ||
| 68 | } | 96 | } |
| 69 | } | 97 | } |
| 70 | 98 | ||
| @@ -96,6 +124,21 @@ impl ServerCore { | |||
| 96 | Ok(Self::new(conn)) | 124 | Ok(Self::new(conn)) |
| 97 | } | 125 | } |
| 98 | 126 | ||
| 127 | /// Handle one enveloped request, replaying a cached reply for a repeated | ||
| 128 | /// request id. The payload grammar is unchanged; the envelope only adds | ||
| 129 | /// correlation and idempotency. | ||
| 130 | pub fn handle_request(&mut self, state: &mut ConnectionState, request: &Request) -> String { | ||
| 131 | if let Some(reply) = state.replay(request.id, &request.payload) { | ||
| 132 | return match reply { | ||
| 133 | Ok(reply) => reply, | ||
| 134 | Err(message) => message, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | let reply = self.handle_message(state, &request.payload); | ||
| 138 | state.remember(request.id, &request.payload, &reply); | ||
| 139 | reply | ||
| 140 | } | ||
| 141 | |||
| 99 | pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { | 142 | pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { |
| 100 | let parts: Vec<&str> = msg.splitn(5, ' ').collect(); | 143 | let parts: Vec<&str> = msg.splitn(5, ' ').collect(); |
| 101 | 144 | ||
| @@ -201,7 +244,7 @@ impl ServerCore { | |||
| 201 | } | 244 | } |
| 202 | } | 245 | } |
| 203 | } | 246 | } |
| 204 | "PING" => "PONG".to_string(), | 247 | "PING" => pong_line(unix_time_ms()), |
| 205 | "INFO" => self.handle_info(state), | 248 | "INFO" => self.handle_info(state), |
| 206 | _ => msg.to_string(), | 249 | _ => msg.to_string(), |
| 207 | } | 250 | } |
| @@ -212,7 +255,8 @@ impl ServerCore { | |||
| 212 | return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string(); | 255 | return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string(); |
| 213 | } | 256 | } |
| 214 | 257 | ||
| 215 | let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { | 258 | let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) |
| 259 | else { | ||
| 216 | return "ERROR Invalid version".to_string(); | 260 | return "ERROR Invalid version".to_string(); |
| 217 | }; | 261 | }; |
| 218 | 262 | ||
| @@ -225,7 +269,7 @@ impl ServerCore { | |||
| 225 | } | 269 | } |
| 226 | 270 | ||
| 227 | state.greeted = true; | 271 | state.greeted = true; |
| 228 | hello_line(VERSION, VERSION, &download_url()) | 272 | hello_line(VERSION, VERSION, &download_url(), unix_time_ms()) |
| 229 | } | 273 | } |
| 230 | 274 | ||
| 231 | fn handle_register(&mut self, username: &str, credential: &str) -> String { | 275 | fn handle_register(&mut self, username: &str, credential: &str) -> String { |
| @@ -529,8 +573,8 @@ impl ServerCore { | |||
| 529 | 573 | ||
| 530 | // The session is the audit guard for the deduction, so it exists before | 574 | // The session is the audit guard for the deduction, so it exists before |
| 531 | // the balance is touched. | 575 | // the balance is touched. |
| 532 | let crash_point = self.crash.next_crash_point(); | 576 | let seed = self.crash.next_seed(); |
| 533 | let round = CrashRound::new(bet as u64, crash_point, self.clock.now()); | 577 | let round = CrashRound::new(bet as u64, seed, self.clock.now(), unix_time_ms()); |
| 534 | let mut data = HashMap::new(); | 578 | let mut data = HashMap::new(); |
| 535 | data.insert("round".to_string(), round.to_json()); | 579 | data.insert("round".to_string(), round.to_json()); |
| 536 | data.insert("balance".to_string(), balance.to_string()); | 580 | data.insert("balance".to_string(), balance.to_string()); |
| @@ -643,11 +687,27 @@ impl ServerCore { | |||
| 643 | if !round.is_running() { | 687 | if !round.is_running() { |
| 644 | continue; | 688 | continue; |
| 645 | } | 689 | } |
| 646 | let elapsed = (self.clock.now() - round.started_at).max(0.0); | 690 | let now = self.clock.now(); |
| 647 | if crate::crash::multiplier_at_secs(elapsed) < round.crash_point { | 691 | let elapsed = (now - round.started_at).max(0.0); |
| 692 | let multiplier = crate::crash::multiplier_at_secs(elapsed); | ||
| 693 | if multiplier < round.crash_point { | ||
| 694 | let last = self | ||
| 695 | .last_tick | ||
| 696 | .get(&session.session_id) | ||
| 697 | .copied() | ||
| 698 | .unwrap_or(f64::NEG_INFINITY); | ||
| 699 | if now - last >= 0.1 { | ||
| 700 | self.last_tick.insert(session.session_id.clone(), now); | ||
| 701 | out.push(format!( | ||
| 702 | "crash.tick session={} t={} m={multiplier:.4}", | ||
| 703 | session.session_id, | ||
| 704 | unix_time_ms() | ||
| 705 | )); | ||
| 706 | } | ||
| 648 | continue; | 707 | continue; |
| 649 | } | 708 | } |
| 650 | round.crash(); | 709 | round.crash(); |
| 710 | self.last_tick.remove(&session.session_id); | ||
| 651 | match self.settle_crash(user, &session.session_id, &round, 0) { | 711 | match self.settle_crash(user, &session.session_id, &round, 0) { |
| 652 | Ok(balance) => { | 712 | Ok(balance) => { |
| 653 | let state = round.state(&session.session_id, balance); | 713 | let state = round.state(&session.session_id, balance); |
| @@ -678,6 +738,7 @@ impl ServerCore { | |||
| 678 | payout as i64, | 738 | payout as i64, |
| 679 | &details, | 739 | &details, |
| 680 | ); | 740 | ); |
| 741 | self.last_tick.remove(session_id); | ||
| 681 | let _ = self.sessions.remove_session(session_id); | 742 | let _ = self.sessions.remove_session(session_id); |
| 682 | result.map_err(|error| error.to_string()) | 743 | result.map_err(|error| error.to_string()) |
| 683 | } | 744 | } |
diff --git a/src/server/games/crash.rs b/src/server/games/crash.rs index ba73c7f..1728afc 100644 --- a/src/server/games/crash.rs +++ b/src/server/games/crash.rs | |||
| @@ -1,5 +1,8 @@ | |||
| 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; | 1 | use super::traits::{Game, GameError, GamePhase, GameResult, GameState}; |
| 2 | use crate::crash::{crash_point_from_uniform, payout, CrashState}; | 2 | use crate::crash::{ |
| 3 | CrashState, SEED_LEN, commit_seed, crash_point_from_seed, payout, seed_to_uniform, | ||
| 4 | }; | ||
| 5 | use rand::RngCore; | ||
| 3 | use serde::{Deserialize, Serialize}; | 6 | use serde::{Deserialize, Serialize}; |
| 4 | use std::collections::{HashMap, VecDeque}; | 7 | use std::collections::{HashMap, VecDeque}; |
| 5 | use std::sync::{Arc, Mutex}; | 8 | use std::sync::{Arc, Mutex}; |
| @@ -64,7 +67,14 @@ pub struct CrashRound { | |||
| 64 | pub bet: u64, | 67 | pub bet: u64, |
| 65 | /// Drawn at start, revealed only when the round ends. | 68 | /// Drawn at start, revealed only when the round ends. |
| 66 | pub crash_point: f64, | 69 | pub crash_point: f64, |
| 70 | /// The 32 random bytes the crash point commits to, hex encoded. Kept on | ||
| 71 | /// the server until the round ends. | ||
| 72 | pub seed: String, | ||
| 73 | /// `sha256(seed)` in hex, sent with the start reply. | ||
| 74 | pub commit: String, | ||
| 67 | pub started_at: f64, | 75 | pub started_at: f64, |
| 76 | /// Server Unix time at start, milliseconds, for clock sync. | ||
| 77 | pub started_at_ms: u64, | ||
| 68 | /// `running`, `cashed`, `crashed` or `forfeit`. | 78 | /// `running`, `cashed`, `crashed` or `forfeit`. |
| 69 | pub phase: String, | 79 | pub phase: String, |
| 70 | pub result: String, | 80 | pub result: String, |
| @@ -74,11 +84,15 @@ pub struct CrashRound { | |||
| 74 | } | 84 | } |
| 75 | 85 | ||
| 76 | impl CrashRound { | 86 | impl CrashRound { |
| 77 | pub fn new(bet: u64, crash_point: f64, started_at: f64) -> Self { | 87 | pub fn new(bet: u64, seed: [u8; SEED_LEN], started_at: f64, started_at_ms: u64) -> Self { |
| 88 | let crash_point = crash_point_from_seed(&seed); | ||
| 78 | Self { | 89 | Self { |
| 79 | bet, | 90 | bet, |
| 80 | crash_point, | 91 | crash_point, |
| 92 | seed: seed_hex(&seed), | ||
| 93 | commit: commit_seed(&seed), | ||
| 81 | started_at, | 94 | started_at, |
| 95 | started_at_ms, | ||
| 82 | phase: "running".to_string(), | 96 | phase: "running".to_string(), |
| 83 | result: "playing".to_string(), | 97 | result: "playing".to_string(), |
| 84 | multiplier: 1.0, | 98 | multiplier: 1.0, |
| @@ -120,6 +134,7 @@ impl CrashRound { | |||
| 120 | balance, | 134 | balance, |
| 121 | multiplier: self.multiplier, | 135 | multiplier: self.multiplier, |
| 122 | potential: payout(self.bet, self.multiplier), | 136 | potential: payout(self.bet, self.multiplier), |
| 137 | started_at: self.started_at_ms, | ||
| 123 | crash_point: if self.is_running() { | 138 | crash_point: if self.is_running() { |
| 124 | None | 139 | None |
| 125 | } else { | 140 | } else { |
| @@ -127,6 +142,12 @@ impl CrashRound { | |||
| 127 | }, | 142 | }, |
| 128 | payout: self.payout, | 143 | payout: self.payout, |
| 129 | result: self.result.clone(), | 144 | result: self.result.clone(), |
| 145 | commit: Some(self.commit.clone()), | ||
| 146 | seed: if self.is_running() { | ||
| 147 | None | ||
| 148 | } else { | ||
| 149 | Some(self.seed.clone()) | ||
| 150 | }, | ||
| 130 | } | 151 | } |
| 131 | } | 152 | } |
| 132 | 153 | ||
| @@ -146,6 +167,34 @@ pub struct CrashGame { | |||
| 146 | uniforms: VecDeque<f64>, | 167 | uniforms: VecDeque<f64>, |
| 147 | } | 168 | } |
| 148 | 169 | ||
| 170 | /// Hex encode a round seed. Lowercase, no separators. | ||
| 171 | fn seed_hex(seed: &[u8]) -> String { | ||
| 172 | let mut out = String::with_capacity(seed.len() * 2); | ||
| 173 | for byte in seed { | ||
| 174 | use std::fmt::Write as _; | ||
| 175 | let _ = write!(out, "{byte:02x}"); | ||
| 176 | } | ||
| 177 | out | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Draw a seed whose committed point matches a queued test uniform. | ||
| 181 | /// | ||
| 182 | /// The first eight bytes are the big-endian integer closest to | ||
| 183 | /// `uniform * 2^64`; the remaining bytes are zero. This keeps the existing | ||
| 184 | /// deterministic crash-point tests meaningful while every round still has a | ||
| 185 | /// real seed. | ||
| 186 | fn seed_from_uniform(uniform: f64) -> [u8; SEED_LEN] { | ||
| 187 | let uniform = if uniform.is_finite() { | ||
| 188 | uniform.clamp(0.0, 0.999_999_999_999_999_9) | ||
| 189 | } else { | ||
| 190 | 0.0 | ||
| 191 | }; | ||
| 192 | let value = (uniform * 18_446_744_073_709_551_616.0) as u64; | ||
| 193 | let mut seed = [0u8; SEED_LEN]; | ||
| 194 | seed[..8].copy_from_slice(&value.to_be_bytes()); | ||
| 195 | seed | ||
| 196 | } | ||
| 197 | |||
| 149 | impl CrashGame { | 198 | impl CrashGame { |
| 150 | pub fn new() -> Self { | 199 | pub fn new() -> Self { |
| 151 | Self { | 200 | Self { |
| @@ -163,12 +212,25 @@ impl CrashGame { | |||
| 163 | } | 212 | } |
| 164 | } | 213 | } |
| 165 | 214 | ||
| 215 | /// Draw the next round seed. Tests supply uniforms; production uses the | ||
| 216 | /// operating system's random source. | ||
| 217 | pub fn next_seed(&mut self) -> [u8; SEED_LEN] { | ||
| 218 | if let Some(uniform) = self.uniforms.pop_front() { | ||
| 219 | return seed_from_uniform(uniform); | ||
| 220 | } | ||
| 221 | let mut seed = [0u8; SEED_LEN]; | ||
| 222 | rand::thread_rng().fill_bytes(&mut seed); | ||
| 223 | seed | ||
| 224 | } | ||
| 225 | |||
| 226 | /// Kept for callers that only need the point a queued uniform encodes. | ||
| 166 | pub fn next_crash_point(&mut self) -> f64 { | 227 | pub fn next_crash_point(&mut self) -> f64 { |
| 167 | let uniform = self | 228 | crash_point_from_seed(&self.next_seed()) |
| 168 | .uniforms | 229 | } |
| 169 | .pop_front() | 230 | |
| 170 | .unwrap_or_else(rand::random::<f64>); | 231 | /// The uniform encoded by a seed, exposed for verification tests. |
| 171 | crash_point_from_uniform(uniform) | 232 | pub fn uniform_of(seed: &[u8; SEED_LEN]) -> f64 { |
| 233 | seed_to_uniform(seed) | ||
| 172 | } | 234 | } |
| 173 | } | 235 | } |
| 174 | 236 | ||
diff --git a/src/version.rs b/src/version.rs index c1dcee5..adc2a8d 100644 --- a/src/version.rs +++ b/src/version.rs | |||
| @@ -5,6 +5,7 @@ | |||
| 5 | //! accepted until the server has answered it. | 5 | //! accepted until the server has answered it. |
| 6 | 6 | ||
| 7 | use std::env; | 7 | use std::env; |
| 8 | use std::time::{SystemTime, UNIX_EPOCH}; | ||
| 8 | 9 | ||
| 9 | /// Version this build reports. The client and the server share it. | 10 | /// Version this build reports. The client and the server share it. |
| 10 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); | 11 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); |
| @@ -73,9 +74,9 @@ pub fn is_newer(latest: &str, current: &str) -> bool { | |||
| 73 | } | 74 | } |
| 74 | } | 75 | } |
| 75 | 76 | ||
| 76 | /// The server line that accepts a handshake. | 77 | /// The server line that accepts a handshake. `time` is Unix milliseconds. |
| 77 | pub fn hello_line(version: &str, latest: &str, url: &str) -> String { | 78 | pub fn hello_line(version: &str, latest: &str, url: &str, time: u64) -> String { |
| 78 | format!("HELLO {version} latest={latest} url={url}") | 79 | format!("HELLO {version} latest={latest} url={url} time={time}") |
| 79 | } | 80 | } |
| 80 | 81 | ||
| 81 | #[derive(Debug, Clone, PartialEq, Eq)] | 82 | #[derive(Debug, Clone, PartialEq, Eq)] |
| @@ -83,9 +84,11 @@ pub struct HelloReply { | |||
| 83 | pub version: String, | 84 | pub version: String, |
| 84 | pub latest: String, | 85 | pub latest: String, |
| 85 | pub url: String, | 86 | pub url: String, |
| 87 | /// Server clock at the moment the reply was built, Unix milliseconds. | ||
| 88 | pub time: u64, | ||
| 86 | } | 89 | } |
| 87 | 90 | ||
| 88 | /// Parse `HELLO <version> latest=<version> url=<url>`. | 91 | /// Parse `HELLO <version> latest=<version> url=<url> time=<unix_ms>`. |
| 89 | pub fn parse_hello(line: &str) -> Option<HelloReply> { | 92 | pub fn parse_hello(line: &str) -> Option<HelloReply> { |
| 90 | let rest = line.trim().strip_prefix("HELLO ")?; | 93 | let rest = line.trim().strip_prefix("HELLO ")?; |
| 91 | let mut tokens = rest.split_whitespace(); | 94 | let mut tokens = rest.split_whitespace(); |
| @@ -93,11 +96,14 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> { | |||
| 93 | 96 | ||
| 94 | let mut latest = None; | 97 | let mut latest = None; |
| 95 | let mut url = None; | 98 | let mut url = None; |
| 99 | let mut time = None; | ||
| 96 | for token in tokens { | 100 | for token in tokens { |
| 97 | if let Some(value) = token.strip_prefix("latest=") { | 101 | if let Some(value) = token.strip_prefix("latest=") { |
| 98 | latest = Some(value.to_string()); | 102 | latest = Some(value.to_string()); |
| 99 | } else if let Some(value) = token.strip_prefix("url=") { | 103 | } else if let Some(value) = token.strip_prefix("url=") { |
| 100 | url = Some(value.to_string()); | 104 | url = Some(value.to_string()); |
| 105 | } else if let Some(value) = token.strip_prefix("time=") { | ||
| 106 | time = value.parse().ok(); | ||
| 101 | } | 107 | } |
| 102 | } | 108 | } |
| 103 | 109 | ||
| @@ -105,9 +111,29 @@ pub fn parse_hello(line: &str) -> Option<HelloReply> { | |||
| 105 | version, | 111 | version, |
| 106 | latest: latest?, | 112 | latest: latest?, |
| 107 | url: url?, | 113 | url: url?, |
| 114 | time: time?, | ||
| 108 | }) | 115 | }) |
| 109 | } | 116 | } |
| 110 | 117 | ||
| 118 | /// The reply to `PING`: `PONG <unix_ms>`. | ||
| 119 | pub fn pong_line(time: u64) -> String { | ||
| 120 | format!("PONG {time}") | ||
| 121 | } | ||
| 122 | |||
| 123 | /// Parse a `PONG` reply's server time. | ||
| 124 | pub fn parse_pong(line: &str) -> Option<u64> { | ||
| 125 | line.trim().strip_prefix("PONG ")?.trim().parse().ok() | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Current Unix time in milliseconds. Used for handshake timestamps and crash | ||
| 129 | /// anchors; a system clock before the epoch reads as zero. | ||
| 130 | pub fn unix_time_ms() -> u64 { | ||
| 131 | SystemTime::now() | ||
| 132 | .duration_since(UNIX_EPOCH) | ||
| 133 | .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) | ||
| 134 | .unwrap_or(0) | ||
| 135 | } | ||
| 136 | |||
| 111 | /// Download page advertised by the server. `DOWNLOAD_URL` wins when set. | 137 | /// Download page advertised by the server. `DOWNLOAD_URL` wins when set. |
| 112 | pub fn download_url() -> String { | 138 | pub fn download_url() -> String { |
| 113 | env::var("DOWNLOAD_URL") | 139 | env::var("DOWNLOAD_URL") |
diff --git a/tests/client_core.rs b/tests/client_core.rs index 2f43d47..3a2fb27 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -7,17 +7,17 @@ | |||
| 7 | use std::time::Duration; | 7 | use std::time::Duration; |
| 8 | use zagan_bet::blackjack::BlackjackState; | 8 | use zagan_bet::blackjack::BlackjackState; |
| 9 | use zagan_bet::client::core::{ClientCore, Effect}; | 9 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 10 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | ||
| 11 | use zagan_bet::client::shell::{self, Shell}; | ||
| 12 | use zagan_bet::client::ui::help_lines; | ||
| 13 | use zagan_bet::client::games::response::{ | 10 | use zagan_bet::client::games::response::{ |
| 14 | parse_blackjack_response, parse_crash_response, parse_spin_response, | 11 | parse_blackjack_response, parse_crash_response, parse_spin_response, |
| 15 | }; | 12 | }; |
| 16 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; |
| 14 | use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; | ||
| 15 | use zagan_bet::client::shell::{self, Shell}; | ||
| 17 | use zagan_bet::client::ui::command_names; | 16 | use zagan_bet::client::ui::command_names; |
| 17 | use zagan_bet::client::ui::help_lines; | ||
| 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; | 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; |
| 19 | use zagan_bet::roulette::BetTarget; | ||
| 20 | use zagan_bet::crash::{CrashState, multiplier_at}; | 19 | use zagan_bet::crash::{CrashState, multiplier_at}; |
| 20 | use zagan_bet::roulette::BetTarget; | ||
| 21 | 21 | ||
| 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; |
| 23 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | 23 | const LOSS: &str = "CLOOGDSLCDCOGLS"; |
| @@ -54,7 +54,7 @@ impl Screen { | |||
| 54 | self.region_start = None; | 54 | self.region_start = None; |
| 55 | self.region_len = 0; | 55 | self.region_len = 0; |
| 56 | } | 56 | } |
| 57 | Effect::SendLine(line) => sent.push(line.clone()), | 57 | Effect::SendRequest { payload, .. } => sent.push(payload.clone()), |
| 58 | Effect::Quit | Effect::Disconnect => {} | 58 | Effect::Quit | Effect::Disconnect => {} |
| 59 | } | 59 | } |
| 60 | } | 60 | } |
| @@ -601,7 +601,7 @@ fn ping_prints_pong() { | |||
| 601 | h.line("ping"); | 601 | h.line("ping"); |
| 602 | assert_eq!(h.sent.last().map(String::as_str), Some("PING")); | 602 | assert_eq!(h.sent.last().map(String::as_str), Some("PING")); |
| 603 | 603 | ||
| 604 | h.reply("PONG"); | 604 | h.reply("PONG 1700000000000"); |
| 605 | 605 | ||
| 606 | assert!(h.screen.lines.iter().any(|line| line == "Pong")); | 606 | assert!(h.screen.lines.iter().any(|line| line == "Pong")); |
| 607 | } | 607 | } |
| @@ -968,6 +968,13 @@ fn crash_state( | |||
| 968 | crash_point, | 968 | crash_point, |
| 969 | payout, | 969 | payout, |
| 970 | result: phase.to_string(), | 970 | result: phase.to_string(), |
| 971 | started_at: 1_700_000_000_000, | ||
| 972 | commit: Some("00".repeat(32)), | ||
| 973 | seed: if phase == "running" { | ||
| 974 | None | ||
| 975 | } else { | ||
| 976 | Some("00".repeat(32)) | ||
| 977 | }, | ||
| 971 | } | 978 | } |
| 972 | } | 979 | } |
| 973 | 980 | ||
| @@ -1221,11 +1228,12 @@ fn history_prints_recent_plays() { | |||
| 1221 | let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; | 1228 | let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; |
| 1222 | h.reply(&format!("HISTORY {json}")); | 1229 | h.reply(&format!("HISTORY {json}")); |
| 1223 | 1230 | ||
| 1224 | assert!(h | 1231 | assert!( |
| 1225 | .screen | 1232 | h.screen |
| 1226 | .lines | 1233 | .lines |
| 1227 | .iter() | 1234 | .iter() |
| 1228 | .any(|line| line.starts_with("Recent plays (1)"))); | 1235 | .any(|line| line.starts_with("Recent plays (1)")) |
| 1236 | ); | ||
| 1229 | let row = h | 1237 | let row = h |
| 1230 | .screen | 1238 | .screen |
| 1231 | .lines | 1239 | .lines |
| @@ -1242,11 +1250,12 @@ fn history_without_plays_says_so() { | |||
| 1242 | let mut h = Harness::new(); | 1250 | let mut h = Harness::new(); |
| 1243 | h.line("history"); | 1251 | h.line("history"); |
| 1244 | h.reply("HISTORY []"); | 1252 | h.reply("HISTORY []"); |
| 1245 | assert!(h | 1253 | assert!( |
| 1246 | .screen | 1254 | h.screen |
| 1247 | .lines | 1255 | .lines |
| 1248 | .iter() | 1256 | .iter() |
| 1249 | .any(|line| line == "No plays recorded yet.")); | 1257 | .any(|line| line == "No plays recorded yet.") |
| 1258 | ); | ||
| 1250 | } | 1259 | } |
| 1251 | 1260 | ||
| 1252 | #[test] | 1261 | #[test] |
| @@ -1254,11 +1263,12 @@ fn history_rejects_a_bad_count_without_sending() { | |||
| 1254 | let mut h = Harness::new(); | 1263 | let mut h = Harness::new(); |
| 1255 | h.line("history 0"); | 1264 | h.line("history 0"); |
| 1256 | assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); | 1265 | assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); |
| 1257 | assert!(h | 1266 | assert!( |
| 1258 | .screen | 1267 | h.screen |
| 1259 | .lines | 1268 | .lines |
| 1260 | .iter() | 1269 | .iter() |
| 1261 | .any(|line| line.contains("Usage: history [1-50]"))); | 1270 | .any(|line| line.contains("Usage: history [1-50]")) |
| 1271 | ); | ||
| 1262 | } | 1272 | } |
| 1263 | 1273 | ||
| 1264 | #[test] | 1274 | #[test] |
| @@ -1291,10 +1301,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() { | |||
| 1291 | Some(BetTarget::Straight { number: 2 }) | 1301 | Some(BetTarget::Straight { number: 2 }) |
| 1292 | ); | 1302 | ); |
| 1293 | assert_eq!(view.bets.total(), 10); | 1303 | assert_eq!(view.bets.total(), 10); |
| 1294 | assert_eq!( | 1304 | assert_eq!(view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10); |
| 1295 | view.bets.amount_on(&BetTarget::Straight { number: 2 }), | ||
| 1296 | 10 | ||
| 1297 | ); | ||
| 1298 | } | 1305 | } |
| 1299 | 1306 | ||
| 1300 | #[test] | 1307 | #[test] |
| @@ -1302,7 +1309,9 @@ fn clicking_a_bet_target_does_not_move_the_cursor() { | |||
| 1302 | let mut h = Harness::new(); | 1309 | let mut h = Harness::new(); |
| 1303 | h.enter_roulette(); | 1310 | h.enter_roulette(); |
| 1304 | 1311 | ||
| 1305 | let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); | 1312 | let effects = h |
| 1313 | .core | ||
| 1314 | .command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); | ||
| 1306 | h.apply(effects); | 1315 | h.apply(effects); |
| 1307 | 1316 | ||
| 1308 | let Some(View::Roulette(view)) = h.core.view() else { | 1317 | let Some(View::Roulette(view)) = h.core.view() else { |
| @@ -1387,9 +1396,7 @@ fn the_slots_view_carries_the_result() { | |||
| 1387 | let mut h = Harness::new(); | 1396 | let mut h = Harness::new(); |
| 1388 | h.enter_slots("10"); | 1397 | h.enter_slots("10"); |
| 1389 | h.key(Key::Char(' ')); | 1398 | h.key(Key::Char(' ')); |
| 1390 | h.reply(&format!( | 1399 | h.reply(&format!("GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6")); |
| 1391 | "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6" | ||
| 1392 | )); | ||
| 1393 | 1400 | ||
| 1394 | let Some(View::Slots(view)) = h.core.view() else { | 1401 | let Some(View::Slots(view)) = h.core.view() else { |
| 1395 | panic!("expected a slots view"); | 1402 | panic!("expected a slots view"); |
| @@ -1518,11 +1525,12 @@ fn history_is_a_screen_and_keeps_its_records() { | |||
| 1518 | assert_eq!(view.records[0].summary(), "cashed 1.13x"); | 1525 | assert_eq!(view.records[0].summary(), "cashed 1.13x"); |
| 1519 | 1526 | ||
| 1520 | // The terminal adapter still prints the table. | 1527 | // The terminal adapter still prints the table. |
| 1521 | assert!(h | 1528 | assert!( |
| 1522 | .screen | 1529 | h.screen |
| 1523 | .lines | 1530 | .lines |
| 1524 | .iter() | 1531 | .iter() |
| 1525 | .any(|line| line.starts_with("Recent plays (1)"))); | 1532 | .any(|line| line.starts_with("Recent plays (1)")) |
| 1533 | ); | ||
| 1526 | } | 1534 | } |
| 1527 | 1535 | ||
| 1528 | #[test] | 1536 | #[test] |
| @@ -1563,11 +1571,12 @@ fn a_login_command_sends_auth_and_sets_the_user() { | |||
| 1563 | }; | 1571 | }; |
| 1564 | assert_eq!(lobby.user.as_deref(), Some("alice")); | 1572 | assert_eq!(lobby.user.as_deref(), Some("alice")); |
| 1565 | assert!(lobby.login_error.is_none()); | 1573 | assert!(lobby.login_error.is_none()); |
| 1566 | assert!(h | 1574 | assert!( |
| 1567 | .screen | 1575 | h.screen |
| 1568 | .lines | 1576 | .lines |
| 1569 | .iter() | 1577 | .iter() |
| 1570 | .any(|line| line == "Logged in as alice")); | 1578 | .any(|line| line == "Logged in as alice") |
| 1579 | ); | ||
| 1571 | } | 1580 | } |
| 1572 | 1581 | ||
| 1573 | #[test] | 1582 | #[test] |
| @@ -1600,11 +1609,12 @@ fn a_register_command_sends_register_and_reports_the_result() { | |||
| 1600 | assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); | 1609 | assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); |
| 1601 | 1610 | ||
| 1602 | h.reply("REGISTERED bob"); | 1611 | h.reply("REGISTERED bob"); |
| 1603 | assert!(h | 1612 | assert!( |
| 1604 | .screen | 1613 | h.screen |
| 1605 | .lines | 1614 | .lines |
| 1606 | .iter() | 1615 | .iter() |
| 1607 | .any(|line| line == "Account created. Log in to play.")); | 1616 | .any(|line| line == "Account created. Log in to play.") |
| 1617 | ); | ||
| 1608 | } | 1618 | } |
| 1609 | 1619 | ||
| 1610 | #[test] | 1620 | #[test] |
| @@ -1646,3 +1656,56 @@ fn the_crash_multiplier_is_not_rounded_for_the_chart() { | |||
| 1646 | "a two-decimal store would flatten the curve into steps" | 1656 | "a two-decimal store would flatten the curve into steps" |
| 1647 | ); | 1657 | ); |
| 1648 | } | 1658 | } |
| 1659 | |||
| 1660 | #[test] | ||
| 1661 | fn a_reply_is_routed_by_request_id_and_late_replies_are_ignored() { | ||
| 1662 | let mut h = Harness::new(); | ||
| 1663 | let first = h.core.command(Command::Ping); | ||
| 1664 | let first_id = first | ||
| 1665 | .iter() | ||
| 1666 | .find_map(|effect| match effect { | ||
| 1667 | Effect::SendRequest { id, .. } => Some(*id), | ||
| 1668 | _ => None, | ||
| 1669 | }) | ||
| 1670 | .unwrap(); | ||
| 1671 | |||
| 1672 | let second = h.core.command(Command::Info); | ||
| 1673 | let second_id = second | ||
| 1674 | .iter() | ||
| 1675 | .find_map(|effect| match effect { | ||
| 1676 | Effect::SendRequest { id, .. } => Some(*id), | ||
| 1677 | _ => None, | ||
| 1678 | }) | ||
| 1679 | .unwrap(); | ||
| 1680 | assert_ne!(first_id, second_id, "ids must be unique on the connection"); | ||
| 1681 | |||
| 1682 | // The ping reply arrives after INFO was sent. It must not complete INFO. | ||
| 1683 | assert!( | ||
| 1684 | h.core | ||
| 1685 | .handle_reply_id(first_id, "PONG 1700000000000") | ||
| 1686 | .is_empty() | ||
| 1687 | ); | ||
| 1688 | let effects = h.core.handle_reply_id(second_id, "INFO alice 1 1000"); | ||
| 1689 | assert!( | ||
| 1690 | effects.iter().any(|effect| matches!(effect, Effect::Print(lines) if lines.iter().any(|line| line.contains("Balance: 1000")))), | ||
| 1691 | "the matching reply is applied" | ||
| 1692 | ); | ||
| 1693 | } | ||
| 1694 | |||
| 1695 | #[test] | ||
| 1696 | fn a_crash_tick_updates_the_board_for_its_own_session() { | ||
| 1697 | let mut h = Harness::new(); | ||
| 1698 | h.enter_crash(); | ||
| 1699 | h.key(Key::Char(' ')); | ||
| 1700 | let running = crash_state("running", 1.0, None, 0, 990); | ||
| 1701 | h.reply(&crash_line("CRASH_START", &running)); | ||
| 1702 | assert!(h.board()[2].contains("Multiplier: 1.00x")); | ||
| 1703 | |||
| 1704 | h.push("crash.tick session=cr1 t=1700000000100 m=1.5"); | ||
| 1705 | assert!(h.board()[2].contains("Multiplier: 1.50x")); | ||
| 1706 | assert!(h.board()[3].contains("Potential: 15 Kr")); | ||
| 1707 | |||
| 1708 | // A tick for another round cannot move this one. | ||
| 1709 | h.push("crash.tick session=other t=1700000000200 m=9.0"); | ||
| 1710 | assert!(h.board()[2].contains("Multiplier: 1.50x")); | ||
| 1711 | } | ||
diff --git a/tests/crash.rs b/tests/crash.rs index 797874b..513f9c8 100644 --- a/tests/crash.rs +++ b/tests/crash.rs | |||
| @@ -102,3 +102,42 @@ fn crash_points_are_clamped_to_the_liability_cap() { | |||
| 102 | assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); | 102 | assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); |
| 103 | assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); | 103 | assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); |
| 104 | } | 104 | } |
| 105 | |||
| 106 | #[test] | ||
| 107 | fn a_seed_commits_to_its_crash_point_and_can_be_verified() { | ||
| 108 | use zagan_bet::crash::{ | ||
| 109 | SEED_LEN, commit_seed, crash_point_from_seed, seed_from_hex, seed_to_uniform, verify_seed, | ||
| 110 | }; | ||
| 111 | |||
| 112 | let mut seed = [0u8; SEED_LEN]; | ||
| 113 | let value = (0.67_f64 * 18_446_744_073_709_551_616.0) as u64; | ||
| 114 | seed[..8].copy_from_slice(&value.to_be_bytes()); | ||
| 115 | let commit = commit_seed(&seed); | ||
| 116 | let hex = format!( | ||
| 117 | "{}", | ||
| 118 | seed.iter() | ||
| 119 | .map(|byte| format!("{byte:02x}")) | ||
| 120 | .collect::<String>() | ||
| 121 | ); | ||
| 122 | |||
| 123 | assert_eq!(seed_from_hex(&hex).as_deref(), Some(seed.as_slice())); | ||
| 124 | assert!(verify_seed(&hex, &commit)); | ||
| 125 | assert!(!verify_seed(&hex, &"00".repeat(32)), "a wrong commit fails"); | ||
| 126 | assert!(!verify_seed("nope", &commit), "bad hex fails"); | ||
| 127 | |||
| 128 | // The point the client recomputes is the point the server committed to. | ||
| 129 | close(crash_point_from_seed(&seed), 3.0, 1e-9); | ||
| 130 | close(crash_point_from_seed(&seed), 3.0, 1e-9); | ||
| 131 | |||
| 132 | // The first eight bytes are the uniform; the rest are still hashed. | ||
| 133 | close(seed_to_uniform(&seed), 0.67, 1e-9); | ||
| 134 | let mut altered = seed; | ||
| 135 | altered[SEED_LEN - 1] = 1; | ||
| 136 | assert!(!verify_seed( | ||
| 137 | &altered | ||
| 138 | .iter() | ||
| 139 | .map(|byte| format!("{byte:02x}")) | ||
| 140 | .collect::<String>(), | ||
| 141 | &commit | ||
| 142 | )); | ||
| 143 | } | ||
diff --git a/tests/protocol.rs b/tests/protocol.rs index bf8a4fd..d829ba9 100644 --- a/tests/protocol.rs +++ b/tests/protocol.rs | |||
| @@ -1,84 +1,136 @@ | |||
| 1 | use std::io::{Cursor, Error, ErrorKind}; | 1 | use std::io::Cursor; |
| 2 | use zagan_bet::protocol::{ | ||
| 3 | FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message, | ||
| 4 | parse_packet, parse_request, reply_line, request_line, | ||
| 5 | }; | ||
| 2 | 6 | ||
| 3 | use zagan_bet::protocol::{io_failure_message, LineBuffer}; | 7 | fn framed(payload: &str) -> Vec<u8> { |
| 8 | encode_frame(payload) | ||
| 9 | } | ||
| 4 | 10 | ||
| 5 | #[test] | 11 | #[test] |
| 6 | fn keeps_data_after_the_first_newline() { | 12 | fn decodes_every_complete_frame_and_keeps_the_remainder() { |
| 7 | let mut buffer = LineBuffer::new(); | 13 | let mut buffer = FrameBuffer::new(); |
| 8 | buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); | 14 | let mut bytes = framed("RES 1 PONG 1700000000000"); |
| 15 | bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5")); | ||
| 16 | buffer.push(&bytes); | ||
| 9 | 17 | ||
| 10 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | ||
| 11 | assert_eq!( | 18 | assert_eq!( |
| 12 | buffer.next_line().as_deref(), | 19 | buffer.next_frame().unwrap().unwrap(), |
| 13 | Some("GAME_RESULT session 60 10 990 OSS two") | 20 | "RES 1 PONG 1700000000000" |
| 21 | ); | ||
| 22 | assert_eq!( | ||
| 23 | buffer.next_frame().unwrap().unwrap(), | ||
| 24 | "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5" | ||
| 14 | ); | 25 | ); |
| 15 | assert_eq!(buffer.next_line(), None); | 26 | assert!(buffer.next_frame().is_none()); |
| 27 | assert!(buffer.is_empty()); | ||
| 16 | } | 28 | } |
| 17 | 29 | ||
| 18 | #[test] | 30 | #[test] |
| 19 | fn handles_crlf_and_empty_lines() { | 31 | fn waits_for_a_partial_frame() { |
| 20 | let mut buffer = LineBuffer::new(); | 32 | let mut buffer = FrameBuffer::new(); |
| 21 | buffer.push(b"PONG\r\n\r\n"); | 33 | let bytes = framed("RES 7 OK"); |
| 34 | buffer.push(&bytes[..3]); | ||
| 35 | assert!(buffer.next_frame().is_none()); | ||
| 22 | 36 | ||
| 23 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | 37 | buffer.push(&bytes[3..7]); |
| 24 | assert_eq!(buffer.next_line().as_deref(), Some("")); | 38 | assert!(buffer.next_frame().is_none()); |
| 25 | assert_eq!(buffer.next_line(), None); | 39 | |
| 40 | buffer.push(&bytes[7..]); | ||
| 41 | assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK"); | ||
| 26 | } | 42 | } |
| 27 | 43 | ||
| 28 | #[test] | 44 | #[test] |
| 29 | fn waits_for_a_split_line() { | 45 | fn an_oversized_length_is_rejected_without_waiting_for_bytes() { |
| 30 | let mut buffer = LineBuffer::new(); | 46 | let mut buffer = FrameBuffer::new(); |
| 31 | buffer.push(b"PO"); | 47 | buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes())); |
| 32 | assert_eq!(buffer.next_line(), None); | 48 | match buffer.next_frame() { |
| 49 | Some(Err(FrameError::TooLarge(length))) => { | ||
| 50 | assert_eq!(length, MAX_FRAME_LEN + 1); | ||
| 51 | } | ||
| 52 | other => panic!("expected an oversize error, got {other:?}"), | ||
| 53 | } | ||
| 54 | assert!(buffer.is_empty(), "the buffer must not keep the bad frame"); | ||
| 55 | } | ||
| 33 | 56 | ||
| 34 | buffer.push(b"NG\n"); | 57 | #[test] |
| 35 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | 58 | fn invalid_utf8_is_a_protocol_error() { |
| 59 | let mut buffer = FrameBuffer::new(); | ||
| 60 | buffer.push(&1u32.to_be_bytes()); | ||
| 61 | buffer.push(&[0xff]); | ||
| 62 | assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8))); | ||
| 36 | } | 63 | } |
| 37 | 64 | ||
| 38 | #[test] | 65 | #[test] |
| 39 | fn reads_multiple_lines_from_a_reader() { | 66 | fn reads_frames_from_a_blocking_reader() { |
| 40 | let mut buffer = LineBuffer::new(); | 67 | let mut bytes = framed("RES 1 OK"); |
| 41 | let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); | 68 | bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0")); |
| 69 | let mut reader = Cursor::new(bytes); | ||
| 70 | let mut buffer = FrameBuffer::new(); | ||
| 42 | 71 | ||
| 43 | assert_eq!( | 72 | assert_eq!( |
| 44 | buffer.read_line(&mut reader).unwrap().as_deref(), | 73 | buffer.read_frame(&mut reader).unwrap().as_deref(), |
| 45 | Some("one") | 74 | Some("RES 1 OK") |
| 46 | ); | 75 | ); |
| 47 | assert_eq!( | 76 | assert_eq!( |
| 48 | buffer.read_line(&mut reader).unwrap().as_deref(), | 77 | buffer.read_frame(&mut reader).unwrap().as_deref(), |
| 49 | Some("two") | 78 | Some("EVT 1 crash.tick session=cr1 t=1 m=1.0") |
| 50 | ); | 79 | ); |
| 51 | assert_eq!(buffer.read_line(&mut reader).unwrap(), None); | 80 | assert_eq!(buffer.read_frame(&mut reader).unwrap(), None); |
| 52 | } | 81 | } |
| 53 | 82 | ||
| 54 | #[test] | 83 | #[test] |
| 55 | fn returns_a_partial_line_at_eof() { | 84 | fn builds_and_parses_the_envelope() { |
| 56 | let mut buffer = LineBuffer::new(); | ||
| 57 | let mut reader = Cursor::new(b"partial".to_vec()); | ||
| 58 | |||
| 59 | assert_eq!( | 85 | assert_eq!( |
| 60 | buffer.read_line(&mut reader).unwrap().as_deref(), | 86 | request_line(42, None, "PLAY_START crash 50"), |
| 61 | Some("partial") | 87 | "REQ 42 PLAY_START crash 50" |
| 62 | ); | 88 | ); |
| 63 | assert_eq!(buffer.read_line(&mut reader).unwrap(), None); | 89 | assert_eq!( |
| 90 | request_line(42, Some("abc123"), "PLAY_START crash 50"), | ||
| 91 | "REQ 42 idem=abc123 PLAY_START crash 50" | ||
| 92 | ); | ||
| 93 | |||
| 94 | let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap(); | ||
| 95 | assert_eq!(request.id, 42); | ||
| 96 | assert_eq!(request.idem.as_deref(), Some("abc123")); | ||
| 97 | assert_eq!(request.payload, "PLAY_START crash 50"); | ||
| 98 | |||
| 99 | let request = parse_request("REQ 7 PING").unwrap(); | ||
| 100 | assert_eq!(request.id, 7); | ||
| 101 | assert_eq!(request.idem, None); | ||
| 102 | assert_eq!(request.payload, "PING"); | ||
| 103 | |||
| 104 | assert!(parse_request("PLAY_START crash 50").is_err()); | ||
| 105 | assert!(parse_request("REQ nope PING").is_err()); | ||
| 106 | assert!(parse_request("REQ 1 idem= PING").is_err()); | ||
| 64 | } | 107 | } |
| 65 | 108 | ||
| 66 | #[test] | 109 | #[test] |
| 67 | fn timeout_errors_get_a_timeout_message() { | 110 | fn parses_replies_and_events() { |
| 68 | assert_eq!( | ||
| 69 | io_failure_message(&Error::from(ErrorKind::TimedOut)), | ||
| 70 | "ERROR Request timed out" | ||
| 71 | ); | ||
| 72 | assert_eq!( | ||
| 73 | io_failure_message(&Error::from(ErrorKind::WouldBlock)), | ||
| 74 | "ERROR Request timed out" | ||
| 75 | ); | ||
| 76 | assert_eq!( | 111 | assert_eq!( |
| 77 | io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), | 112 | parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(), |
| 78 | "ERROR Connection lost" | 113 | Packet::Reply { |
| 114 | id: 9, | ||
| 115 | body: "ERROR Insufficient balance".to_string() | ||
| 116 | } | ||
| 79 | ); | 117 | ); |
| 80 | assert_eq!( | 118 | assert_eq!( |
| 81 | io_failure_message(&Error::from(ErrorKind::ConnectionReset)), | 119 | parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(), |
| 82 | "ERROR Connection lost" | 120 | Packet::Event { |
| 121 | seq: 3, | ||
| 122 | body: "CRASH_BUSTED {}".to_string() | ||
| 123 | } | ||
| 83 | ); | 124 | ); |
| 125 | assert!(parse_packet("REQ 1 PING").is_err()); | ||
| 126 | assert!(parse_packet("RES nope OK").is_err()); | ||
| 127 | } | ||
| 128 | |||
| 129 | #[test] | ||
| 130 | fn io_failures_are_reported_as_short_messages() { | ||
| 131 | let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow"); | ||
| 132 | assert_eq!(io_failure_message(&timeout), "ERROR Request timed out"); | ||
| 133 | |||
| 134 | let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone"); | ||
| 135 | assert_eq!(io_failure_message(&closed), "ERROR Connection lost"); | ||
| 84 | } | 136 | } |
diff --git a/tests/server_core.rs b/tests/server_core.rs index e2600e4..6c9c835 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs | |||
| @@ -101,11 +101,11 @@ fn crash_state(response: &str) -> CrashState { | |||
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | fn hello(core: &mut ServerCore, state: &mut ConnectionState) { | 103 | fn hello(core: &mut ServerCore, state: &mut ConnectionState) { |
| 104 | let response = core.handle_message( | 104 | let response = core.handle_message(state, &format!("HELLO {}", zagan_bet::version::VERSION)); |
| 105 | state, | 105 | assert!( |
| 106 | &format!("HELLO {}", zagan_bet::version::VERSION), | 106 | response.starts_with("HELLO "), |
| 107 | "handshake failed: {response}" | ||
| 107 | ); | 108 | ); |
| 108 | assert!(response.starts_with("HELLO "), "handshake failed: {response}"); | ||
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 111 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { |
| @@ -258,8 +258,18 @@ fn roulette_settles_split_bets_and_records_details() { | |||
| 258 | 258 | ||
| 259 | let mut bets = Bets::default(); | 259 | let mut bets = Bets::default(); |
| 260 | bets.add(BetTarget::Outside { kind: Outside::Red }, 10); | 260 | bets.add(BetTarget::Outside { kind: Outside::Red }, 10); |
| 261 | bets.add(BetTarget::Outside { kind: Outside::Black }, 10); | 261 | bets.add( |
| 262 | bets.add(BetTarget::Outside { kind: Outside::Even }, 10); | 262 | BetTarget::Outside { |
| 263 | kind: Outside::Black, | ||
| 264 | }, | ||
| 265 | 10, | ||
| 266 | ); | ||
| 267 | bets.add( | ||
| 268 | BetTarget::Outside { | ||
| 269 | kind: Outside::Even, | ||
| 270 | }, | ||
| 271 | 10, | ||
| 272 | ); | ||
| 263 | bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); | 273 | bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); |
| 264 | bets.add(BetTarget::Straight { number: 17 }, 5); | 274 | bets.add(BetTarget::Straight { number: 17 }, 5); |
| 265 | let bets = bets.to_json(); | 275 | let bets = bets.to_json(); |
| @@ -671,10 +681,14 @@ fn crash_poll_pushes_the_bust_exactly_once() { | |||
| 671 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | 681 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); |
| 672 | 682 | ||
| 673 | clock.advance(1.0); | 683 | clock.advance(1.0); |
| 684 | let ticks = core.poll(&state); | ||
| 685 | assert_eq!(ticks.len(), 1, "a running round pushes coalesced ticks"); | ||
| 674 | assert!( | 686 | assert!( |
| 675 | core.poll(&state).is_empty(), | 687 | ticks[0].starts_with("crash.tick session="), |
| 676 | "a running round must not push anything" | 688 | "tick: {}", |
| 689 | ticks[0] | ||
| 677 | ); | 690 | ); |
| 691 | assert!(ticks[0].contains(" m="), "tick carries the multiplier"); | ||
| 678 | 692 | ||
| 679 | clock.advance(15.0); | 693 | clock.advance(15.0); |
| 680 | let pushed = core.poll(&state); | 694 | let pushed = core.poll(&state); |
| @@ -920,9 +934,10 @@ fn history_lists_recent_plays_newest_first() { | |||
| 920 | login(&mut core, &mut state, "alice"); | 934 | login(&mut core, &mut state, "alice"); |
| 921 | 935 | ||
| 922 | for _ in 0..3 { | 936 | for _ in 0..3 { |
| 923 | assert!(core | 937 | assert!( |
| 924 | .handle_message(&mut state, "PLAY_START slots 10") | 938 | core.handle_message(&mut state, "PLAY_START slots 10") |
| 925 | .starts_with("GAME_RESULT")); | 939 | .starts_with("GAME_RESULT") |
| 940 | ); | ||
| 926 | } | 941 | } |
| 927 | 942 | ||
| 928 | let response = core.handle_message(&mut state, "HISTORY 2"); | 943 | let response = core.handle_message(&mut state, "HISTORY 2"); |
| @@ -979,7 +994,10 @@ fn hello_reports_the_version_and_download_url() { | |||
| 979 | assert!(state.greeted()); | 994 | assert!(state.greeted()); |
| 980 | 995 | ||
| 981 | // The gate is open now. | 996 | // The gate is open now. |
| 982 | assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); | 997 | assert!( |
| 998 | core.handle_message(&mut state, "PING").starts_with("PONG "), | ||
| 999 | "PING must carry the server time" | ||
| 1000 | ); | ||
| 983 | } | 1001 | } |
| 984 | 1002 | ||
| 985 | #[test] | 1003 | #[test] |
| @@ -999,7 +1017,10 @@ fn hello_refuses_a_different_major_or_minor() { | |||
| 999 | response.starts_with("ERROR Version mismatch"), | 1017 | response.starts_with("ERROR Version mismatch"), |
| 1000 | "response: {response}" | 1018 | "response: {response}" |
| 1001 | ); | 1019 | ); |
| 1002 | assert!(!state.greeted(), "a refused handshake must not open the gate"); | 1020 | assert!( |
| 1021 | !state.greeted(), | ||
| 1022 | "a refused handshake must not open the gate" | ||
| 1023 | ); | ||
| 1003 | assert_eq!( | 1024 | assert_eq!( |
| 1004 | core.handle_message(&mut state, "PING"), | 1025 | core.handle_message(&mut state, "PING"), |
| 1005 | "ERROR HELLO required" | 1026 | "ERROR HELLO required" |
| @@ -1014,9 +1035,10 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { | |||
| 1014 | let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); | 1035 | let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); |
| 1015 | 1036 | ||
| 1016 | let mut state = ConnectionState::default(); | 1037 | let mut state = ConnectionState::default(); |
| 1017 | assert!(core | 1038 | assert!( |
| 1018 | .handle_message(&mut state, &format!("HELLO {patch}")) | 1039 | core.handle_message(&mut state, &format!("HELLO {patch}")) |
| 1019 | .starts_with("HELLO ")); | 1040 | .starts_with("HELLO ") |
| 1041 | ); | ||
| 1020 | 1042 | ||
| 1021 | let mut other = ConnectionState::default(); | 1043 | let mut other = ConnectionState::default(); |
| 1022 | assert_eq!( | 1044 | assert_eq!( |
| @@ -1028,3 +1050,70 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { | |||
| 1028 | "ERROR Invalid version" | 1050 | "ERROR Invalid version" |
| 1029 | ); | 1051 | ); |
| 1030 | } | 1052 | } |
| 1053 | |||
| 1054 | #[test] | ||
| 1055 | fn crash_start_commits_to_a_seed_and_the_end_reveals_it() { | ||
| 1056 | use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed}; | ||
| 1057 | |||
| 1058 | let (mut core, _path, clock) = setup_crash("crash-commit", CRASH_AT_3); | ||
| 1059 | let mut state = ConnectionState::default(); | ||
| 1060 | register(&mut core, &mut state, "alice"); | ||
| 1061 | login(&mut core, &mut state, "alice"); | ||
| 1062 | |||
| 1063 | let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); | ||
| 1064 | let commit = started.commit.clone().expect("a start reply commits"); | ||
| 1065 | assert_eq!(commit.len(), 64, "sha256 hex"); | ||
| 1066 | assert_eq!(started.seed, None, "the seed stays hidden while running"); | ||
| 1067 | assert_eq!(started.crash_point, None); | ||
| 1068 | assert!(started.started_at > 1_600_000_000_000, "server time anchor"); | ||
| 1069 | |||
| 1070 | clock.advance(5.0); | ||
| 1071 | let cashed = crash_state( | ||
| 1072 | &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), | ||
| 1073 | ); | ||
| 1074 | let seed = cashed.seed.clone().expect("the end reveals the seed"); | ||
| 1075 | assert!(verify_seed(&seed, &commit), "the reveal matches the commit"); | ||
| 1076 | let bytes = seed_from_hex(&seed).expect("32-byte hex seed"); | ||
| 1077 | let point = crash_point_from_seed(&bytes); | ||
| 1078 | assert!( | ||
| 1079 | (point - cashed.crash_point.unwrap()).abs() < 1e-9, | ||
| 1080 | "the client recomputes the same point" | ||
| 1081 | ); | ||
| 1082 | assert_eq!(cashed.commit.as_deref(), Some(commit.as_str())); | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | #[test] | ||
| 1086 | fn a_repeated_request_id_replays_its_reply_without_charging_twice() { | ||
| 1087 | use zagan_bet::protocol::Request; | ||
| 1088 | |||
| 1089 | let (mut core, _path) = setup("idempotent-spin", WIN3); | ||
| 1090 | let mut state = ConnectionState::default(); | ||
| 1091 | register(&mut core, &mut state, "alice"); | ||
| 1092 | login(&mut core, &mut state, "alice"); | ||
| 1093 | |||
| 1094 | let request = Request { | ||
| 1095 | id: 41, | ||
| 1096 | idem: Some("spin-1".to_string()), | ||
| 1097 | payload: "PLAY_START slots 10".to_string(), | ||
| 1098 | }; | ||
| 1099 | let first = core.handle_request(&mut state, &request); | ||
| 1100 | assert!(first.starts_with("GAME_RESULT"), "first: {first}"); | ||
| 1101 | assert_eq!(core.balance("alice"), Some(1000)); | ||
| 1102 | assert_eq!(core.play_count("alice"), 1); | ||
| 1103 | |||
| 1104 | let replay = core.handle_request(&mut state, &request); | ||
| 1105 | assert_eq!(replay, first, "the cached reply is returned verbatim"); | ||
| 1106 | assert_eq!(core.balance("alice"), Some(1000), "no second debit"); | ||
| 1107 | assert_eq!(core.play_count("alice"), 1, "no second audit row"); | ||
| 1108 | |||
| 1109 | let different = Request { | ||
| 1110 | id: 41, | ||
| 1111 | idem: Some("spin-2".to_string()), | ||
| 1112 | payload: "PLAY_START slots 20".to_string(), | ||
| 1113 | }; | ||
| 1114 | assert_eq!( | ||
| 1115 | core.handle_request(&mut state, &different), | ||
| 1116 | "ERROR Request id reused with a different command" | ||
| 1117 | ); | ||
| 1118 | assert_eq!(core.balance("alice"), Some(1000)); | ||
| 1119 | } | ||
diff --git a/tests/version.rs b/tests/version.rs index af244db..31bc2b6 100644 --- a/tests/version.rs +++ b/tests/version.rs | |||
| @@ -1,5 +1,6 @@ | |||
| 1 | use zagan_bet::version::{ | 1 | use zagan_bet::version::{ |
| 2 | compatible, hello_line, is_newer, parse_hello, Version, VERSION, | 2 | VERSION, Version, compatible, hello_line, is_newer, parse_hello, parse_pong, pong_line, |
| 3 | unix_time_ms, | ||
| 3 | }; | 4 | }; |
| 4 | 5 | ||
| 5 | #[test] | 6 | #[test] |
| @@ -24,16 +25,16 @@ fn versions_parse_and_reject_nonsense() { | |||
| 24 | Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), | 25 | Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), |
| 25 | Some((4, 0, 0)) | 26 | Some((4, 0, 0)) |
| 26 | ); | 27 | ); |
| 27 | assert_eq!( | 28 | assert_eq!(Version::parse("4.0.0+build7").map(|v| v.patch), Some(0)); |
| 28 | Version::parse("4.0.0+build7").map(|v| v.patch), | ||
| 29 | Some(0) | ||
| 30 | ); | ||
| 31 | 29 | ||
| 32 | for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { | 30 | for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { |
| 33 | assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); | 31 | assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); |
| 34 | } | 32 | } |
| 35 | 33 | ||
| 36 | assert!(Version::parse(VERSION).is_some(), "the build version must parse"); | 34 | assert!( |
| 35 | Version::parse(VERSION).is_some(), | ||
| 36 | "the build version must parse" | ||
| 37 | ); | ||
| 37 | } | 38 | } |
| 38 | 39 | ||
| 39 | #[test] | 40 | #[test] |
| @@ -57,19 +58,45 @@ fn newer_versions_are_ordered_by_major_minor_patch() { | |||
| 57 | 58 | ||
| 58 | #[test] | 59 | #[test] |
| 59 | fn the_hello_reply_round_trips() { | 60 | fn the_hello_reply_round_trips() { |
| 60 | let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); | 61 | let line = hello_line( |
| 62 | "4.0.0", | ||
| 63 | "4.1.2", | ||
| 64 | "https://zagan.example/", | ||
| 65 | 1_700_000_000_123, | ||
| 66 | ); | ||
| 61 | let reply = parse_hello(&line).expect("parse a generated reply"); | 67 | let reply = parse_hello(&line).expect("parse a generated reply"); |
| 62 | 68 | ||
| 63 | assert_eq!(reply.version, "4.0.0"); | 69 | assert_eq!(reply.version, "4.0.0"); |
| 64 | assert_eq!(reply.latest, "4.1.2"); | 70 | assert_eq!(reply.latest, "4.1.2"); |
| 65 | assert_eq!(reply.url, "https://zagan.example/"); | 71 | assert_eq!(reply.url, "https://zagan.example/"); |
| 72 | assert_eq!(reply.time, 1_700_000_000_123); | ||
| 66 | 73 | ||
| 67 | assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); | 74 | assert!( |
| 68 | assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); | 75 | parse_hello("HELLO 4.0.0 url=https://x/ time=1").is_none(), |
| 76 | "latest is required" | ||
| 77 | ); | ||
| 78 | assert!( | ||
| 79 | parse_hello("HELLO 4.0.0 latest=4.0.0 time=1").is_none(), | ||
| 80 | "url is required" | ||
| 81 | ); | ||
| 82 | assert!( | ||
| 83 | parse_hello("HELLO 4.0.0 latest=4.0.0 url=https://x/").is_none(), | ||
| 84 | "time is required" | ||
| 85 | ); | ||
| 69 | assert!(parse_hello("PONG").is_none()); | 86 | assert!(parse_hello("PONG").is_none()); |
| 70 | assert!(parse_hello("HELLO").is_none()); | 87 | assert!(parse_hello("HELLO").is_none()); |
| 71 | } | 88 | } |
| 72 | 89 | ||
| 90 | #[test] | ||
| 91 | fn pong_carries_the_server_time() { | ||
| 92 | let line = pong_line(1_700_000_000_456); | ||
| 93 | assert_eq!(line, "PONG 1700000000456"); | ||
| 94 | assert_eq!(parse_pong(&line), Some(1_700_000_000_456)); | ||
| 95 | assert!(parse_pong("PONG").is_none()); | ||
| 96 | assert!(parse_pong("RES 1 PONG 1700000000456").is_none()); | ||
| 97 | assert!(unix_time_ms() > 1_600_000_000_000); | ||
| 98 | } | ||
| 99 | |||
| 73 | /// The GUI is a client, so it must ship in lockstep with the TUI and server. | 100 | /// The GUI is a client, so it must ship in lockstep with the TUI and server. |
| 74 | /// Keep `gui/Cargo.toml` at the same version as the root package. | 101 | /// Keep `gui/Cargo.toml` at the same version as the root package. |
| 75 | #[test] | 102 | #[test] |
| @@ -83,6 +110,18 @@ fn the_gui_member_uses_the_same_version() { | |||
| 83 | assert_eq!(gui_version, VERSION); | 110 | assert_eq!(gui_version, VERSION); |
| 84 | } | 111 | } |
| 85 | 112 | ||
| 113 | /// The gateway is a frontend too; keep it in lockstep with the core. | ||
| 114 | #[test] | ||
| 115 | fn the_webd_member_uses_the_same_version() { | ||
| 116 | let manifest = include_str!("../webd/Cargo.toml"); | ||
| 117 | let version = manifest | ||
| 118 | .lines() | ||
| 119 | .find_map(|line| line.trim().strip_prefix("version = ")) | ||
| 120 | .expect("webd/Cargo.toml declares a version") | ||
| 121 | .trim_matches('"'); | ||
| 122 | assert_eq!(version, VERSION); | ||
| 123 | } | ||
| 124 | |||
| 86 | /// egui's dependency tree must never enter the TUI or server build graph. | 125 | /// egui's dependency tree must never enter the TUI or server build graph. |
| 87 | /// The GUI is a workspace member that depends on this package, never the | 126 | /// The GUI is a workspace member that depends on this package, never the |
| 88 | /// other way round. | 127 | /// other way round. |
diff --git a/tests/web_types.rs b/tests/web_types.rs new file mode 100644 index 0000000..21af474 --- /dev/null +++ b/tests/web_types.rs | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | //! The browser and gateway exchange serde JSON for `Command`/`View`. These | ||
| 2 | //! tests pin the wire shape the TypeScript app renders. | ||
| 3 | |||
| 4 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | ||
| 5 | use zagan_bet::client::view::{ | ||
| 6 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView, | ||
| 7 | SlotsPhase, SlotsView, View, | ||
| 8 | }; | ||
| 9 | use zagan_bet::roulette::{BetTarget, Bets, Outside}; | ||
| 10 | |||
| 11 | #[test] | ||
| 12 | fn browser_commands_deserialize() { | ||
| 13 | let login: Command = | ||
| 14 | serde_json::from_str(r#"{"Login":{"user":"alice","credential":"abc"}}"#).unwrap(); | ||
| 15 | assert_eq!( | ||
| 16 | login, | ||
| 17 | Command::Login { | ||
| 18 | user: "alice".to_string(), | ||
| 19 | credential: "abc".to_string() | ||
| 20 | } | ||
| 21 | ); | ||
| 22 | |||
| 23 | let play: Command = serde_json::from_str(r#"{"Play":{"game":"crash","bet":25}}"#).unwrap(); | ||
| 24 | assert_eq!( | ||
| 25 | play, | ||
| 26 | Command::Play { | ||
| 27 | game: "crash".to_string(), | ||
| 28 | bet: Some(25) | ||
| 29 | } | ||
| 30 | ); | ||
| 31 | |||
| 32 | let bet: Command = | ||
| 33 | serde_json::from_str(r#"{"PlaceBetAt":{"bet":"outside","kind":"red"}}"#).unwrap(); | ||
| 34 | assert_eq!( | ||
| 35 | bet, | ||
| 36 | Command::PlaceBetAt(BetTarget::Outside { kind: Outside::Red }) | ||
| 37 | ); | ||
| 38 | |||
| 39 | let split: Command = | ||
| 40 | serde_json::from_str(r#"{"PlaceBetAt":{"bet":"split","a":1,"b":2}}"#).unwrap(); | ||
| 41 | assert_eq!(split, Command::PlaceBetAt(BetTarget::Split { a: 1, b: 2 })); | ||
| 42 | |||
| 43 | assert_eq!( | ||
| 44 | serde_json::from_str::<Command>(r#""Spin""#).unwrap(), | ||
| 45 | Command::Spin | ||
| 46 | ); | ||
| 47 | assert_eq!( | ||
| 48 | serde_json::from_str::<Command>(r#""ShowLobby""#).unwrap(), | ||
| 49 | Command::ShowLobby | ||
| 50 | ); | ||
| 51 | assert_eq!( | ||
| 52 | serde_json::from_str::<Command>(r#"{"SetBet":50}"#).unwrap(), | ||
| 53 | Command::SetBet(50) | ||
| 54 | ); | ||
| 55 | assert_eq!( | ||
| 56 | serde_json::from_str::<Command>(r#"{"MoveCursor":"Left"}"#).unwrap(), | ||
| 57 | Command::MoveCursor(Direction::Left) | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | |||
| 61 | #[test] | ||
| 62 | fn views_serialize_with_a_screen_tag() { | ||
| 63 | let lobby = View::Lobby(LobbyView { | ||
| 64 | connected: true, | ||
| 65 | user: Some("alice".to_string()), | ||
| 66 | account: Some("1".to_string()), | ||
| 67 | balance: Some(1000), | ||
| 68 | games: vec![("slots".to_string(), "Slots".to_string())], | ||
| 69 | login_error: None, | ||
| 70 | }); | ||
| 71 | let json = serde_json::to_value(&lobby).unwrap(); | ||
| 72 | assert_eq!(json["screen"], "lobby"); | ||
| 73 | assert_eq!(json["user"], "alice"); | ||
| 74 | |||
| 75 | let slots = View::Slots(SlotsView { | ||
| 76 | bet: 10, | ||
| 77 | balance: Some(990), | ||
| 78 | reels: "CLOGD".chars().collect(), | ||
| 79 | win_cells: vec![0], | ||
| 80 | win_lines: vec![1], | ||
| 81 | win_type: "win".to_string(), | ||
| 82 | winnings: Some(20), | ||
| 83 | phase: SlotsPhase::Result, | ||
| 84 | error: None, | ||
| 85 | }); | ||
| 86 | let json = serde_json::to_value(&slots).unwrap(); | ||
| 87 | assert_eq!(json["screen"], "slots"); | ||
| 88 | assert_eq!(json["phase"], "result"); | ||
| 89 | assert_eq!(json["reels"][0], "C"); | ||
| 90 | |||
| 91 | let roulette = View::Roulette(RouletteView { | ||
| 92 | bet_amount: 10, | ||
| 93 | balance: Some(1000), | ||
| 94 | chip: 25, | ||
| 95 | bets: Bets::default(), | ||
| 96 | cursor: Cursor::default(), | ||
| 97 | phase: RoulettePhase::Placing, | ||
| 98 | number: None, | ||
| 99 | color: None, | ||
| 100 | winnings: None, | ||
| 101 | error: None, | ||
| 102 | }); | ||
| 103 | let json = serde_json::to_value(&roulette).unwrap(); | ||
| 104 | assert_eq!(json["screen"], "roulette"); | ||
| 105 | assert_eq!(json["phase"], "placing"); | ||
| 106 | |||
| 107 | let blackjack = View::Blackjack(BlackjackView { | ||
| 108 | status: "ready".to_string(), | ||
| 109 | balance: Some(1000), | ||
| 110 | bet: 10, | ||
| 111 | hand: None, | ||
| 112 | error: None, | ||
| 113 | }); | ||
| 114 | assert_eq!( | ||
| 115 | serde_json::to_value(&blackjack).unwrap()["screen"], | ||
| 116 | "blackjack" | ||
| 117 | ); | ||
| 118 | |||
| 119 | let crash = View::Crash(CrashView { | ||
| 120 | status: "running".to_string(), | ||
| 121 | balance: Some(990), | ||
| 122 | bet: 10, | ||
| 123 | multiplier: 1.5, | ||
| 124 | potential: 15, | ||
| 125 | crash_point: None, | ||
| 126 | payout: 0, | ||
| 127 | started_at: 1_700_000_000_000, | ||
| 128 | commit: Some("ab".repeat(32)), | ||
| 129 | seed: None, | ||
| 130 | error: None, | ||
| 131 | }); | ||
| 132 | let json = serde_json::to_value(&crash).unwrap(); | ||
| 133 | assert_eq!(json["screen"], "crash"); | ||
| 134 | assert_eq!(json["commit"], "ab".repeat(32)); | ||
| 135 | |||
| 136 | let history = View::History(HistoryView { records: vec![] }); | ||
| 137 | assert_eq!(serde_json::to_value(&history).unwrap()["screen"], "history"); | ||
| 138 | } | ||
diff --git a/web/README.md b/web/README.md index 81346fd..3e81468 100644 --- a/web/README.md +++ b/web/README.md | |||
| @@ -13,6 +13,7 @@ web/ | |||
| 13 | releases.php shared scanner and hash cache | 13 | releases.php shared scanner and hash cache |
| 14 | install.sh stable Linux installer | 14 | install.sh stable Linux installer |
| 15 | install.ps1 stable Windows installer | 15 | install.ps1 stable Windows installer |
| 16 | play/ browser app, served at /play/ | ||
| 16 | bin/gui/ apps, served at /bin/gui/... | 17 | bin/gui/ apps, served at /bin/gui/... |
| 17 | bin/client/ terminal clients | 18 | bin/client/ terminal clients |
| 18 | bin/server/ servers | 19 | bin/server/ servers |
| @@ -20,6 +21,26 @@ web/ | |||
| 20 | 21 | ||
| 21 | Serve `web/` as the document root. Binaries in `bin/` are gitignored. | 22 | Serve `web/` as the document root. Binaries in `bin/` are gitignored. |
| 22 | 23 | ||
| 24 | ## Browser app | ||
| 25 | |||
| 26 | `play/` is a static vanilla TypeScript app. It talks to the WebSocket gateway | ||
| 27 | in `webd/`, not to the game server: the browser sends `Command` JSON and renders | ||
| 28 | `View` JSON, and the gateway owns the upstream TLS connection and the pinned | ||
| 29 | server key. nginx serves `/play/` and proxies `/ws` to `127.0.0.1:8890`. | ||
| 30 | |||
| 31 | Build it before publishing: | ||
| 32 | |||
| 33 | ```sh | ||
| 34 | cd web/play | ||
| 35 | npm ci | ||
| 36 | npm run build # writes app.js | ||
| 37 | cd ../.. | ||
| 38 | ``` | ||
| 39 | |||
| 40 | Commit the generated `app.js`; the site then needs no Node at deploy time. | ||
| 41 | `npm run typecheck` checks the sources. The gateway has its own deployment | ||
| 42 | notes in `webd/README.md`; it is not part of the player download. | ||
| 43 | |||
| 23 | ## Naming | 44 | ## Naming |
| 24 | 45 | ||
| 25 | The page only lists files that match the release pattern; anything else in | 46 | The page only lists files that match the release pattern; anything else in |
| @@ -132,9 +153,9 @@ Then open <http://127.0.0.1:8899/>. | |||
| 132 | `deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a | 153 | `deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a |
| 133 | half-written binary, then fetches the live page and every artifact it lists — | 154 | half-written binary, then fetches the live page and every artifact it lists — |
| 134 | GUI apps, installers, terminal clients and servers — and compares the | 155 | GUI apps, installers, terminal clients and servers — and compares the |
| 135 | downloaded bytes with the local SHA-256. `--verify-only` skips the | 156 | downloaded bytes with the local SHA-256. It also fetches `/play/`, `app.js` and |
| 136 | upload and only checks the live site. Old versions are left on the server; | 157 | `style.css`. `--verify-only` skips the upload and only checks the live site. |
| 137 | prune them by hand. | 158 | Old versions are left on the server; prune them by hand. |
| 138 | 159 | ||
| 139 | A local directory works as a target, which is how the verification itself is | 160 | A local directory works as a target, which is how the verification itself is |
| 140 | tested: | 161 | 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 | |||
| 58 | <body> | 58 | <body> |
| 59 | 59 | ||
| 60 | <h1>żagań.bet</h1> | 60 | <h1>żagań.bet</h1> |
| 61 | <p><a href="<?= e($base) ?>/play/"><strong>Play in your browser</strong></a> — no install, just an account. Play-money only.</p> | ||
| 61 | <p>Official desktop app, terminal client and server builds.</p> | 62 | <p>Official desktop app, terminal client and server builds.</p> |
| 62 | <?php if ($newest > 0): ?> | 63 | <?php if ($newest > 0): ?> |
| 63 | <p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> | 64 | <p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> |
diff --git a/web/nginx.conf.example b/web/nginx.conf.example index 9fc942f..d440ab5 100644 --- a/web/nginx.conf.example +++ b/web/nginx.conf.example | |||
| @@ -37,6 +37,31 @@ server { | |||
| 37 | add_header Cache-Control "public, max-age=31536000, immutable"; | 37 | add_header Cache-Control "public, max-age=31536000, immutable"; |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | # The browser app: static files, no framework runtime. | ||
| 41 | location = /play { | ||
| 42 | return 301 /play/; | ||
| 43 | } | ||
| 44 | |||
| 45 | location /play/ { | ||
| 46 | try_files $uri $uri/ /play/index.html; | ||
| 47 | add_header Cache-Control "no-cache"; | ||
| 48 | add_header X-Frame-Options "DENY" always; | ||
| 49 | add_header X-Content-Type-Options "nosniff" always; | ||
| 50 | } | ||
| 51 | |||
| 52 | # WebSocket gateway. Keep the upgrade headers and a long read timeout: | ||
| 53 | # a crash round can idle for a while between pushes. | ||
| 54 | location = /ws { | ||
| 55 | proxy_pass http://127.0.0.1:8890; | ||
| 56 | proxy_http_version 1.1; | ||
| 57 | proxy_set_header Upgrade $http_upgrade; | ||
| 58 | proxy_set_header Connection "upgrade"; | ||
| 59 | proxy_set_header Host $host; | ||
| 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| 61 | proxy_read_timeout 3600s; | ||
| 62 | proxy_send_timeout 3600s; | ||
| 63 | } | ||
| 64 | |||
| 40 | # The page rescans bin/ on every request, so don't cache it. | 65 | # The page rescans bin/ on every request, so don't cache it. |
| 41 | location = /index.php { | 66 | location = /index.php { |
| 42 | include fastcgi_params; | 67 | include fastcgi_params; |
diff --git a/web/play/.gitignore b/web/play/.gitignore new file mode 100644 index 0000000..63738f6 --- /dev/null +++ b/web/play/.gitignore | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | node_modules/ | ||
| 2 | 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 @@ | |||
| 1 | # `web/play` — browser frontend | ||
| 2 | |||
| 3 | Vanilla TypeScript + esbuild. The browser sends `Command` JSON and renders | ||
| 4 | `View` JSON; it contains no game rules, no payout math and no wire protocol. | ||
| 5 | The gateway in `webd/` owns the upstream TLS connection. | ||
| 6 | |||
| 7 | ## Build | ||
| 8 | |||
| 9 | ```sh | ||
| 10 | npm ci | ||
| 11 | npm run build # writes app.js | ||
| 12 | npm run typecheck | ||
| 13 | npm test # headless DOM smoke test (jsdom) | ||
| 14 | ``` | ||
| 15 | |||
| 16 | Commit the generated `app.js` so the site deploys without Node. | ||
| 17 | |||
| 18 | ## Local development | ||
| 19 | |||
| 20 | Serve the repository's `web/` directory (for `/play/`) and put the gateway | ||
| 21 | behind the same origin at `/ws`. The simplest local setup is nginx with the | ||
| 22 | `web/nginx.conf.example` block pointed at a staging root and | ||
| 23 | `ZAGAN_WEB_ORIGIN=http://127.0.0.1:8899`. For the app alone: | ||
| 24 | |||
| 25 | ```sh | ||
| 26 | python3 -m http.server 8900 -d web/play | ||
| 27 | ``` | ||
| 28 | |||
| 29 | It will load, but the WebSocket will only connect through nginx or another | ||
| 30 | proxy that routes `/ws` to `webd`. | ||
| 31 | |||
| 32 | ## Screens | ||
| 33 | |||
| 34 | - Auth: login/register. The password is hashed with Web Crypto (`SHA-256`) | ||
| 35 | before it leaves the page; the gateway and browser never store it. | ||
| 36 | - Lobby: four games, per-game bet, history. | ||
| 37 | - Slots: five reels, win cells and paylines. | ||
| 38 | - Roulette: canvas felt with straight, dozen, column and outside chips; | ||
| 39 | left click adds, right click removes. | ||
| 40 | - Blackjack: dealer/player cards, hit/stand/double/forfeit. | ||
| 41 | - Crash: curve, live multiplier, cash-out, commit/seed verification. | ||
| 42 | - History: settled plays with net result. | ||
| 43 | |||
| 44 | ## Theme | ||
| 45 | |||
| 46 | `style.css` mirrors the palette in `gui/src/theme.rs`: near-black ground, | ||
| 47 | cyan focus, green for wins, gold for chips, felt green only for roulette. | ||
| 48 | Do not introduce a second palette or hard-code colours in the render modules. | ||
| 49 | |||
| 50 | ## Security | ||
| 51 | |||
| 52 | - The session cookie is HttpOnly and SameSite=Strict; JavaScript never reads | ||
| 53 | it and never stores a credential. | ||
| 54 | - Notices are inserted with `textContent`, not HTML. | ||
| 55 | - The app is play-money only. Do not add deposits, withdrawals, KYC or | ||
| 56 | real-money claims without a product decision and an ADR. | ||
diff --git a/web/play/app.js b/web/play/app.js new file mode 100644 index 0000000..3320693 --- /dev/null +++ b/web/play/app.js | |||
| @@ -0,0 +1 @@ | |||
| "use strict";(()=>{function k(e){let n=document.getElementById(e);if(!n)throw new Error(`missing #${e}`);return n}function o(e,n,t){let s=document.createElement(e);return n&&(s.className=n),t!==void 0&&(s.textContent=t),s}function H(e){e.replaceChildren()}function Oe(e){let n=e<0,t=String(Math.abs(Math.trunc(e))),s="";for(let a=0;a<t.length;a+=1)a>0&&(t.length-a)%3===0&&(s+=" "),s+=t[a];return n?`-${s}`:s}function L(e){return e==null||Number.isNaN(e)?"— Kr":`${Oe(e)} Kr`}function me(e){return`${e<0?"-":"+"}${L(Math.abs(e))}`}function J(e,n="info"){let t=document.getElementById("toasts");if(!t||!e)return;let s=o("div",`toast ${n}`,e);for(t.append(s),window.setTimeout(()=>{s.style.opacity="0",s.style.transition="opacity 220ms ease",window.setTimeout(()=>s.remove(),240)},4200);t.children.length>4;)t.firstElementChild?.remove()}function he(e){let n=e.toLowerCase();return e.startsWith("ERROR")||n.includes("failed")||n.includes("lost")||n.includes("invalid")||n.includes("forfeit")?"bad":e.startsWith("Logged in")||e.startsWith("Account created")||n.includes("won")||n.includes("cashed")?"good":"info"}async function fe(e){let n=new TextEncoder().encode(e),t=await crypto.subtle.digest("SHA-256",n);return[...new Uint8Array(t)].map(s=>s.toString(16).padStart(2,"0")).join("")}function O(e,n=10){return e.length<=n*2?e:`${e.slice(0,n)}…${e.slice(-n)}`}function be(e){let n=Date.parse(e.includes("T")?e:e.replace(" ","T")+"Z");return Number.isNaN(n)?e:new Date(n).toLocaleString()}function ge(e){if(e==="?"||!e)return{rank:"?",suit:"",red:!1};let n=e.slice(-1);return{rank:e.slice(0,-1),suit:n==="C"?"♣":n==="D"?"♦":n==="H"?"♥":n==="S"?"♠":n,red:n==="D"||n==="H"}}var j=class{constructor(n,t){this.url=n;this.handlers=t;this.socket=null;this.stopped=!1;this.replaced=!1;this.attempts=0}connect(){if(this.stopped)return;this.clearTimer();let n;try{n=new WebSocket(this.url)}catch{this.scheduleReconnect()||this.handlers.onClosed(!1);return}this.socket=n,n.addEventListener("open",()=>{this.attempts=0,this.handlers.onOpen()}),n.addEventListener("message",t=>{let s;try{s=JSON.parse(String(t.data))}catch{return}switch(s.type){case"hello":this.handlers.onHello(s);break;case"view":this.handlers.onView(s.view);break;case"notice":this.handlers.onNotice(String(s.text??""));break;case"error":this.handlers.onError(String(s.text??""));break;case"replaced":this.replaced=!0,this.handlers.onReplaced();break;case"pong":this.handlers.onPong();break}}),n.addEventListener("close",()=>{if(this.socket===n&&(this.socket=null),this.replaced||this.stopped){this.handlers.onClosed(!1);return}let s=this.scheduleReconnect();this.handlers.onClosed(s)}),n.addEventListener("error",()=>{})}send(n){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"command",command:n}))}ping(){this.socket?.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify({type:"ping"}))}close(){this.stopped=!0,this.clearTimer(),this.socket?.close()}scheduleReconnect(){if(this.stopped||this.replaced||this.attempts>=6)return!1;this.attempts+=1;let n=Math.min(250*2**(this.attempts-1),4e3);return this.timer=window.setTimeout(()=>this.connect(),n),!0}clearTimer(){this.timer!==void 0&&(window.clearTimeout(this.timer),this.timer=void 0)}};function ze(e){if(e==="?"){let s=o("div","card back");return s.append(o("span","rank","?"),o("span","suit","")),s}let n=ge(e),t=o("div","card");return n.red&&t.classList.add("red"),t.append(o("span","rank",n.rank),o("span","suit",n.suit)),t}function ye(e,n,t){let s=o("div","hand");s.append(o("div","label",e));let a=o("div","cards");for(let d of n)a.append(ze(d));let r=o("div","total");return r.append(document.createTextNode("Total "),o("strong",void 0,String(t))),s.append(a,r),s}function Ve(e,n){let t=String(n?.result??""),s=Number(n?.winnings??0);switch(t){case"blackjack":return`Blackjack — won ${L(s)}`;case"win":return`You win — ${L(s)}`;case"push":return`Push — ${L(s)} returned`;case"bust":return"Bust — the bet is lost";case"lose":return"Dealer wins — the bet is lost";case"forfeit":return"Hand forfeited — the bet is lost";case"playing":return e.status==="player"?"Your move.":"Dealing…";default:return"Ready to deal."}}function ke(e,n,t,s){H(e),H(n);let a=t.hand,r=a?.phase==="player"||t.status==="player",d=a?.phase==="done"||t.status==="done",i=!a||d;if(e.append(o("h2",void 0,"Blackjack")),e.append(o("p","note","Six decks. Dealer stands on all 17. Blackjack pays 3:2.")),a)e.append(ye("Dealer",a.dealer??[],Number(a.dealer_total??0))),e.append(ye("You",a.player??[],Number(a.player_total??0)));else{let h=o("div","outcome");h.textContent="Press DEAL to start a hand.",e.append(h)}if(d||a&&!r){let h=o("div","outcome");h.textContent=Ve(t,a),(String(a?.result)==="win"||String(a?.result)==="blackjack"||String(a?.result)==="push")&&h.classList.add("win-text"),e.append(h)}t.error&&e.append(o("p","error-text",String(t.error))),n.append(o("div","label","Blackjack"));let m=Number(a?.bet??t.bet??0),p=t.balance??t.hand?.balance;if(n.append(o("p","note","Hit, stand or double. Forfeiting loses the reserved bet.")),i){let h=o("div","row"),v=o("button","btn small","−10"),y=o("strong","mono",L(m)),w=o("button","btn small","+10");v.addEventListener("click",()=>s({AdjustBet:-10})),w.addEventListener("click",()=>s({AdjustBet:10})),h.append(v,y,w),n.append(o("div","label","Bet"),h)}else{let h=o("div","stat");h.append(o("span","muted","Bet"),o("strong",void 0,L(m))),n.append(h)}if(p!==void 0){let h=o("div","stat");h.append(o("span","muted","Balance"),o("strong",void 0,L(Number(p)))),n.append(h)}let u=o("div","actions");if(r){let h=o("div","row wrap");for(let y of a?.actions??[]){let w=y==="hit"?"HIT":y==="stand"?"STAND":"DOUBLE",E=y==="stand"?o("button","btn primary",w):o("button","btn",w);E.addEventListener("click",()=>{s(y==="hit"?"Hit":y==="stand"?"Stand":"Double")}),h.append(E)}u.append(h);let v=o("button","btn danger wide","Forfeit hand");v.addEventListener("click",()=>{window.confirm("Forfeit this hand? The reserved bet is lost.")&&s("Forfeit")}),u.append(v)}else if(i){let h=o("button","btn primary wide big","DEAL");h.addEventListener("click",()=>s("Deal")),u.append(h)}let g=o("button","btn ghost wide",r?"Leave (forfeits)":"Leave table");g.addEventListener("click",()=>{r&&!window.confirm("Leaving forfeits this hand. Continue?")||s("Leave")}),u.append(g),n.append(u)}var q=Math.LN2/5,R=null,z=null,ve=null,P=null,W,Le=0,Y="";function _e(e,n,t,s){H(e),H(n),P=t,ve=s,e.append(o("h2",void 0,"Crash")),e.append(o("p","note","The multiplier rises from 1.00×. Cash out before it crashes."));let a=o("div","crash-wrap"),r=o("div","crash-number","1.00×"),d=o("div","crash-meta"),i=o("canvas","crash-canvas"),m=o("div","verify","Waiting for a round commitment.");a.append(r,d,i,m),e.append(a),n.append(o("div","label","Crash")),n.append(o("p","note","The server commits to a seed before the round. The seed is revealed when it ends."));let p=o("div","row"),u=o("button","btn small","−10"),g=o("strong","mono","—"),h=o("button","btn small","+10");u.addEventListener("click",()=>P?.({AdjustBet:-10})),h.addEventListener("click",()=>P?.({AdjustBet:10})),p.append(u,g,h),n.append(o("div","label","Bet"),p);let v=o("strong","mono","—"),y=o("strong","mono","—");for(let[M,N]of[["Potential",v],["Balance",y]]){let b=o("div","stat");b.append(o("span","muted",M),N),n.append(b)}let w=o("p","note","Ready."),E=o("p","note",""),S=o("p","error-text");n.append(w,E,S);let l=o("div","actions"),c=o("button","btn primary wide big","START"),f=o("button","btn ghost wide","Leave table");return c.addEventListener("click",()=>{z?.status==="running"?P?.("CashOut"):P?.("Start")}),f.addEventListener("click",()=>{z?.status==="running"&&!window.confirm("Forfeit this round? The bet is lost.")||P?.("Leave")}),l.append(c,f),n.append(l),{board:e,side:n,number:r,meta:d,verify:m,canvas:i,bet:g,potential:v,balance:y,status:w,lastCrash:E,error:S,primary:c,leave:f,minus:u,plus:h}}function we(e,n,t,s,a){(!R||R.board!==e)&&(R=_e(e,n,s,a),Y="");let r=R;P=s,ve=a,z=t,t.status!==Y&&((t.status==="starting"||t.status==="running")&&Y!=="running"&&(r.verify.className="verify",r.verify.textContent="Round committed; seed hidden until the round ends."),Y=String(t.status));let d=t.status==="running",i=t.status==="starting",m=d||i;r.bet.textContent=L(Number(t.bet??0)),r.potential.textContent=L(Number(t.potential??t.bet??0)),r.balance.textContent=t.balance===null||t.balance===void 0?"—":L(Number(t.balance)),r.error.textContent=t.error?String(t.error):"",r.minus.disabled=m,r.plus.disabled=m,r.primary.textContent=d?"CASH OUT":i?"STARTING…":"START",r.primary.disabled=i,r.primary.className="btn primary wide big",r.leave.textContent=d?"Forfeit round":"Leave table",d?(r.status.textContent="Rising — cash out before the crash.",r.status.className="note win-text"):i?(r.status.textContent="Starting the round…",r.status.className="note"):t.status==="cashed"?(r.status.textContent=`Cashed out at ${Number(t.multiplier??0).toFixed(2)}× for ${L(Number(t.payout??0))}.`,r.status.className="note win-text"):t.status==="crashed"?(r.status.textContent=`Crashed at ${Number(t.crash_point??t.multiplier??0).toFixed(2)}×. The bet is lost.`,r.status.className="note error-text"):t.status==="forfeit"?(r.status.textContent="Round forfeited.",r.status.className="note error-text"):(r.status.textContent="Ready. Start a round when you want.",r.status.className="note"),t.crash_point!==null&&t.crash_point!==void 0&&t.status==="crashed"&&(r.lastCrash.textContent=`Last crash: ${Number(t.crash_point).toFixed(2)}×`);let p=t.commit?String(t.commit):"";if(r.meta.textContent=p?`commit ${O(p,12)}`:"no commitment yet",t.seed&&p){let u=++Le,g=String(t.seed),h=Number(t.crash_point??0);Xe(g,p,h).then(([v,y])=>{if(u!==Le||!R)return;let w=v&&y;R.verify.className=`verify ${w?"ok":"bad"}`,R.verify.textContent=w?`Seed ${O(g,12)} · sha256 matches commit · crash point recomputed`:`VERIFICATION FAILED · seed ${O(g,12)} · commit ${O(p,12)}`})}else!m&&!p&&(r.verify.className="verify",r.verify.textContent="Waiting for a round commitment.");if(W!==void 0&&(cancelAnimationFrame(W),W=void 0),d){let u=()=>{if(!R||z?.status!=="running"){W=void 0;return}xe(R,z,a),W=requestAnimationFrame(u)};W=requestAnimationFrame(u)}else xe(r,t,a)}function je(e,n){if(e.status==="running"){let t=Math.max(0,(n.now()-Number(e.started_at??0))/1e3),s=Math.min(1e3,Math.exp(q*t));return Math.max(s,Number(e.multiplier??1))}return e.status==="cashed"?Number(e.multiplier??1):e.status==="crashed"?Number(e.crash_point??e.multiplier??1):1}function xe(e,n,t){let s=je(n,t);e.number.textContent=`${s.toFixed(2)}×`,e.number.className=`crash-number ${n.status==="running"?"running":n.status==="cashed"?"cashed":n.status==="crashed"?"crashed":""}`,e.potential.textContent=L(Math.floor(Number(n.bet??0)*s)),Ye(e.canvas,n,s,t)}function Ye(e,n,t,s){let a=e.clientWidth||600,r=e.clientHeight||360,d=window.devicePixelRatio||1;(e.width!==Math.round(a*d)||e.height!==Math.round(r*d))&&(e.width=Math.round(a*d),e.height=Math.round(r*d));let i=e.getContext("2d");if(!i)return;i.setTransform(d,0,0,d,0,0),i.clearRect(0,0,a,r);let m=54,p=16,u=18,g=34,h=Math.max(1,a-m-p),v=Math.max(1,r-u-g),y=n.status==="running"?Math.max(.25,(s.now()-Number(n.started_at??0))/1e3):t>1?Math.log(t)/q:1,w=Math.max(5,y*1.08),E=Math.max(2,Math.ceil(t*1.15*2)/2),S=b=>m+b/w*h,l=b=>u+v-(b-1)/(E-1)*v;i.strokeStyle="#242e38",i.fillStyle="#8a98a6",i.font="11px ui-monospace, monospace",i.lineWidth=1;for(let b=0;b<=4;b+=1){let T=1+(E-1)*b/4,C=l(T);i.beginPath(),i.moveTo(m,C),i.lineTo(a-p,C),i.stroke(),i.fillText(`${T.toFixed(1)}×`,6,C+4)}for(let b=0;b<=4;b+=1){let T=w*b/4,C=S(T);i.beginPath(),i.moveTo(C,u),i.lineTo(C,u+v),i.strokeStyle="#1b232c",i.stroke(),i.fillStyle="#5b6773",i.fillText(`${T.toFixed(0)}s`,C-10,r-12)}let c=n.status==="crashed"?"#e05a5a":n.status==="cashed"?"#4ec27b":"#e0b64a";i.beginPath();let f=180;for(let b=0;b<=f;b+=1){let T=y*b/f,C=Math.min(E,Math.exp(q*T)),ue=S(T),pe=l(C);b===0?i.moveTo(ue,pe):i.lineTo(ue,pe)}i.strokeStyle=c,i.lineWidth=2.4,i.stroke();let M=S(y),N=l(Math.min(E,t));i.beginPath(),i.arc(M,N,4.5,0,Math.PI*2),i.fillStyle=c,i.fill(),i.fillStyle=c,i.font="600 12px ui-monospace, monospace",i.fillText(`${t.toFixed(2)}×`,Math.min(M+8,a-70),Math.max(u+12,N-10))}async function Xe(e,n,t){let s=new Uint8Array(e.length/2);for(let u=0;u<s.length;u+=1)s[u]=Number.parseInt(e.slice(u*2,u*2+2),16);let a=await crypto.subtle.digest("SHA-256",s),d=[...new Uint8Array(a)].map(u=>u.toString(16).padStart(2,"0")).join("")===n.toLowerCase(),i=0;for(let u=0;u<8;u+=1)i=i*256+s[u];i/=2**64;let m=Math.max(1,Math.min(1e3,(1-.01)/(1-i))),p=Math.abs(m-t)<1e-6;return[d,p]}function Ge(e){let n=e.details??{};switch(e.game){case"slots":return String(n.win_type??"spin");case"roulette":return`${n.number??"?"} ${n.color??""}`.trim();case"blackjack":return String(n.result??"");case"crash":{let t=String(n.result??""),s=n.crash_point;return s==null?t:`${t} @ ${Number(s).toFixed(2)}×`}default:return""}}function Te(e,n){H(e);let t=n.records??[];if(t.length===0){let m=o("div","board");m.append(o("h2",void 0,"No plays yet"),o("p","muted","Your settled plays will appear here.")),e.append(m);return}let s=o("div","table-wrap"),a=o("table"),r=o("thead"),d=o("tr");for(let m of["Time","Game","Bet","Result","Net","Balance"])d.append(o("th",void 0,m));r.append(d);let i=o("tbody");for(let m of t){let p=o("tr");p.append(o("td","mono",be(String(m.at??"")))),p.append(o("td",void 0,String(m.game??""))),p.append(o("td","num",L(Number(m.bet??0)))),p.append(o("td","muted",Ge(m)));let u=Number(m.winnings??0)-Number(m.bet??0),g=o("td",`num ${u>0?"pos":u<0?"neg":""}`,u===0?"0 Kr":me(u));p.append(g),p.append(o("td","num",L(Number(m.balance_after??0)))),i.append(p)}a.append(r,i),s.append(a),e.append(s)}var Ke="#b0313a",Ue="#151b21",Ee="#106b45",Ze="#0d5c39",Je="#09472c",qe="#1f7d52",Qe="#ecf7f0",et="#e0b64a";function tt(e){return e===0?"green":[1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36].includes(e)?"red":"black"}function nt(e,n){let s=Math.max(38,e*.055),a=Math.max(34,e*.05),r=Math.max(30,n*.07),d=6,i=e-28-s-a,m=i/12,p=(n-28-r*2-d*2)/3,u=14+s,g=14,h=g+p*3+d,v=h+r+d,y=[];for(let l=0;l<12;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);y.push({x:u+l*m,y:g+c*p,w:m,h:p,target:{bet:"straight",number:f},label:String(f),kind:"number",number:f})}y.push({x:14,y:g,w:s,h:p*3,target:{bet:"straight",number:0},label:"0",kind:"zero",number:0});for(let l=0;l<3;l+=1){let c=2-l;y.push({x:u+i,y:g+l*p,w:a,h:p,target:{bet:"column",index:c},label:"2:1",kind:"column"})}for(let l=0;l<3;l+=1)y.push({x:u+i/3*l,y:h,w:i/3,h:r,target:{bet:"dozen",index:l},label:["1st 12","2nd 12","3rd 12"][l],kind:"dozen"});[{kind:"low",label:"1–18"},{kind:"even",label:"EVEN"},{kind:"red",label:"RED"},{kind:"black",label:"BLACK"},{kind:"odd",label:"ODD"},{kind:"high",label:"19–36"}].forEach((l,c)=>{y.push({x:u+i/6*c,y:v,w:i/6,h:r,target:{bet:"outside",kind:l.kind},label:l.label,kind:"outside"})});let E=[],S=l=>{let c=y.find(f=>f.kind==="number"&&f.number===l);return c?{x:c.x+c.w/2,y:c.y+c.h/2}:null};for(let l=0;l<11;l+=1)for(let c=0;c<2;c+=1){let f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+(c+1)*p,radius:12,rank:0,target:{bet:"corner",numbers:[f,f-1,f+3,f+2]}})}for(let l=0;l<12;l+=1)for(let c=0;c<2;c+=1){let f=l*3+(3-c),M=S(f);M&&E.push({x:M.x,y:g+(c+1)*p,radius:10,rank:1,target:{bet:"split",a:f,b:f-1}})}for(let l=0;l<11;l+=1)for(let c=0;c<3;c+=1){let f=l*3+(3-c);E.push({x:u+(l+1)*m,y:g+c*p+p/2,radius:10,rank:1,target:{bet:"split",a:f,b:f+3}})}for(let l=0;l<3;l+=1){let c=3-l;E.push({x:u,y:g+l*p+p/2,radius:10,rank:1,target:{bet:"split",a:0,b:c}})}return{width:e,height:n,cellW:m,cellH:p,zeroX:14,zeroW:s,gridX:u,gridY:g,gridW:i,columnX:u+i,dozensY:h,outsideY:v,outsideH:r,zones:y,hotspots:E}}function Me(e,n,t,s,a,r){let d=Math.min(r,s/2,a/2);e.beginPath(),e.moveTo(n+d,t),e.arcTo(n+s,t,n+s,t+a,d),e.arcTo(n+s,t+a,n,t+a,d),e.arcTo(n,t+a,n,t,d),e.arcTo(n,t,n+s,t,d),e.closePath()}function Ce(e,n){let t=e.clientWidth||800,s=e.clientHeight||540,a=window.devicePixelRatio||1;(e.width!==Math.round(t*a)||e.height!==Math.round(s*a))&&(e.width=Math.round(t*a),e.height=Math.round(s*a));let r=e.getContext("2d"),d=nt(t,s);if(!r)return d;r.setTransform(a,0,0,a,0,0),r.clearRect(0,0,t,s),r.fillStyle=Je,Me(r,0,0,t,s,10),r.fill();for(let i of d.zones){let m=Ze;i.kind==="number"?m=i.number===0?Ee:tt(i.number)==="red"?Ke:Ue:i.kind==="zero"&&(m=Ee),r.fillStyle=m,r.strokeStyle=qe,r.lineWidth=1,Me(r,i.x+1,i.y+1,i.w-2,i.h-2,3),r.fill(),r.stroke(),r.fillStyle=Qe,r.textAlign="center",r.textBaseline="middle",i.kind==="number"||i.kind==="zero"?r.font=`700 ${Math.min(24,d.cellH*.3)}px ui-monospace, monospace`:r.font=`600 ${Math.min(12,d.outsideH*.4)}px ui-monospace, monospace`,r.fillText(i.label,i.x+i.w/2,i.y+i.h/2)}return st(r,d,n),d}function Se(e){return JSON.stringify(e)}function ot(e,n){let t=Se(n),s=e.zones.find(a=>Se(a.target)===t);if(s)return{x:s.x+s.w/2,y:s.y+s.h/2};if(n?.bet==="split"||n?.bet==="corner"){let a=n.bet==="split"?[n.a,n.b]:n.numbers,r=e.zones.filter(d=>(d.kind==="number"||d.kind==="zero")&&a.includes(d.number));if(r.length>0)return{x:r.reduce((d,i)=>d+i.x+i.w/2,0)/r.length,y:r.reduce((d,i)=>d+i.y+i.h/2,0)/r.length}}return null}function st(e,n,t){let s=t.bets?.chips??[];for(let a of s){let r=ot(n,a.target);if(!r)continue;let d=Math.max(12,Math.min(18,n.cellW*.26));e.beginPath(),e.arc(r.x,r.y,d,0,Math.PI*2),e.fillStyle=et,e.fill(),e.strokeStyle="#7a5a12",e.lineWidth=2,e.stroke(),e.fillStyle="#221a05",e.textAlign="center",e.textBaseline="middle",e.font=`700 ${d>15?11:10}px ui-monospace, monospace`,e.fillText(String(a.amount),r.x,r.y+.5)}}function Ne(e,n,t,s){H(e),H(n);let a=t.phase==="spinning";e.append(o("h2",void 0,"Roulette")),e.append(o("p","note","European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back."));let r=o("div","roulette-wrap"),d=o("canvas","roulette-canvas");if(r.append(d),t.phase==="result"&&t.number!==null&&t.number!==void 0){let l=o("div",`wheel-result ${t.color??""}`);l.append(o("div","label","Result")),l.append(o("div","num",String(t.number)));let c=Number(t.winnings??0);l.append(o("div","note",c>0?`Won ${L(c)}`:"No win")),r.append(l)}e.append(r);let m=Ce(d,t),p=(l,c)=>{let f=d.getBoundingClientRect(),M=l-f.left,N=c-f.top,b=null;for(let T of m.hotspots){let C=Math.hypot(M-T.x,N-T.y);C>T.radius||(!b||T.rank<b.rank||T.rank===b.rank&&C<Math.hypot(M-b.x,N-b.y))&&(b=T)}if(b)return{x:b.x-b.radius,y:b.y-b.radius,w:b.radius*2,h:b.radius*2,target:b.target,label:"",kind:"number"};for(let T=m.zones.length-1;T>=0;T-=1){let C=m.zones[T];if(M>=C.x&&M<=C.x+C.w&&N>=C.y&&N<=C.y+C.h)return C}return null};d.addEventListener("click",l=>{if(a)return;let c=p(l.clientX,l.clientY);c&&s({PlaceBetAt:c.target})}),d.addEventListener("contextmenu",l=>{if(l.preventDefault(),a)return;let c=p(l.clientX,l.clientY);c&&s({RemoveBetAt:c.target})}),d.addEventListener("mousemove",l=>{let c=p(l.clientX,l.clientY);d.style.cursor=c&&!a?"pointer":"default"}),new ResizeObserver(()=>{m=Ce(d,t)}).observe(d),n.append(o("div","label","Roulette")),n.append(o("p","note","Chips sit on the felt. The total is reserved when you spin.")),n.append(o("div","label","Chip"));let u=o("div","chip-row");for(let l of[10,25,50,100,500]){let c=o("button",`chip-btn ${Number(t.chip)===l?"active":""}`,String(l));c.disabled=a,c.addEventListener("click",()=>s({SetChip:l})),u.append(c)}n.append(u);let g=(t.bets?.chips??[]).reduce((l,c)=>l+Number(c.amount??0),0),h=o("div","stat");if(h.append(o("span","muted","On the felt"),o("strong","mono",L(g))),n.append(h),t.balance!==null&&t.balance!==void 0){let l=o("div","stat");l.append(o("span","muted","Balance"),o("strong","mono",L(Number(t.balance)))),n.append(l)}t.error&&n.append(o("p","error-text",String(t.error)));let v=o("p","note");if(a)v.textContent="Spinning…";else if(t.phase==="result"){let l=Number(t.winnings??0);v.textContent=`${t.number} ${t.color} · ${l>0?`won ${L(l)}`:"no win"}`,v.className=l>0?"note win-text":"note"}else v.textContent=g>0?"Ready to spin.":"Place at least one chip.";n.append(v);let y=o("div","actions"),w=o("button","btn primary wide big",a?"SPINNING…":"SPIN WHEEL");w.disabled=a||g===0,w.addEventListener("click",()=>s("Spin"));let E=o("button","btn wide","Clear bets");E.disabled=a||g===0,E.addEventListener("click",()=>s("ClearBets"));let S=o("button","btn ghost wide","Leave table");S.disabled=a,S.addEventListener("click",()=>{a||s("Leave")}),y.append(w,E,S),n.append(y)}var Q=5,ee=3,D="CLOGDS",rt=["top","middle","bottom","V","inverted V"],oe=0,I,te=null,ne=null;function at(){oe=Date.now()+900}function it(e){return e.phase==="spinning"||Date.now()<oe}function se(e,n,t,s){te=t,ne=e,I!==void 0&&(window.clearInterval(I),I=void 0);let a=it(t);H(e),H(n);let r=o("h2",void 0,"Slots"),d=o("p","note","Match three or more symbols from the left. Five paylines.");e.append(r,d);let i=o("div","reels"),m=[],p=String(t.reels??"");for(let c=0;c<Q;c+=1){let f=o("div","reel");for(let M=0;M<ee;M+=1){let N=c*ee+M,b=a?D[(N*3+Math.floor(Date.now()/70))%D.length]:p.length===Q*ee?p[N]:p.length===Q?p[c]:D[(N*5+c)%D.length],T=o("div","tile",b);T.classList.add(`sym-${b}`),a&&T.classList.add("spinning"),!a&&(t.win_cells??[]).includes(N)&&T.classList.add("win"),m.push(T),f.append(T)}i.append(f)}e.append(i);let u=o("div","win-lines");if(!a&&t.phase==="result"){let c=Number(t.winnings??0);if(c>0){let f=(t.win_lines??[]).map(M=>`${M} (${rt[M-1]??"line"})`).join(" · ");u.textContent=`${t.win_type==="mega"?"MEGA WIN":t.win_type==="big"?"BIG WIN":"WIN"} ${L(c)}${f?` · ${f}`:""}`,u.classList.add("win-text")}else u.textContent="No win — spin again."}else a?u.textContent="Spinning…":u.textContent="Pick a bet and spin.";if(e.append(u),a){let c=0;I=window.setInterval(()=>{c+=1;for(let f=0;f<m.length;f+=1){let M=D[(f*3+c)%D.length];m[f].textContent=M,m[f].className=`tile spinning sym-${M}`}Date.now()>=oe&&te&&ne&&(window.clearInterval(I),I=void 0,se(ne,n,te,s))},70)}n.append(o("div","label","Slots")),n.append(o("p","note","5 reels × 3 rows, 5 paylines."));let g=o("div","row"),h=o("button","btn small","−10"),v=o("strong","mono",L(Number(t.bet??0))),y=o("button","btn small","+10");h.disabled=a,y.disabled=a,h.addEventListener("click",()=>s({AdjustBet:-10})),y.addEventListener("click",()=>s({AdjustBet:10})),g.append(h,v,y),n.append(o("div","label","Bet"),g),t.error&&n.append(o("p","error-text",String(t.error)));let w=o("p","note");if(a)w.textContent="Spinning…";else if(t.phase==="result"){let c=Number(t.winnings??0);w.textContent=c>0?`Won ${L(c)}`:"No win this spin.",c>0&&w.classList.add("win-text")}else w.textContent="Ready when you are.";n.append(w);let E=o("div","actions"),S=o("button","btn primary wide big",a?"SPINNING…":"SPIN");S.disabled=a,S.addEventListener("click",()=>{at(),s("Spin")});let l=o("button","btn ghost wide","Leave machine");l.disabled=a,l.addEventListener("click",()=>s("Leave")),E.append(S,l),n.append(E)}var lt=k("auth-screen"),ct=k("lobby-screen"),dt=k("game-screen"),ut=k("history-screen"),He=k("session-bar"),pt=k("account-name"),mt=k("account-balance"),re=k("game-grid"),X=k("game-board"),G=k("game-side"),ht=k("game-title"),$e=k("game-badge"),ft=k("history-body"),bt=k("auth-form"),Ae=k("auth-user"),U=k("auth-pass"),F=k("auth-error"),A=k("auth-submit"),Pe=k("tab-login"),We=k("tab-register"),K=k("reconnect-overlay"),Be=k("reconnect-message"),Re=k("replaced-overlay"),x=null,$=null,ce=null,_="login",ae=null,De=0,ie=0,le=new Map([["slots",10],["roulette",10],["blackjack",10],["crash",10]]),Ie={slots:"Slots",roulette:"Roulette",blackjack:"Blackjack",crash:"Crash"},gt={slots:"Five reels, five paylines. Match symbols from the left.",roulette:"European single-zero wheel with inside and outside bets.",blackjack:"Six-deck shoe. Blackjack pays 3:2.",crash:"A rising multiplier. Cash out before it crashes."},yt=location.pathname.includes("/play/")?location.pathname.slice(0,location.pathname.indexOf("/play/")):"",kt=`${location.protocol==="https:"?"wss":"ws"}://${location.host}${yt}/ws`,Z=new j(kt,{onHello(e){De=e.time-Date.now(),$=e.user??null,$e.textContent=`v${e.version}`},onView(e){x=e,typeof e.balance=="number"&&(ce=e.balance),e.screen==="lobby"&&"user"in e&&($=e.user??null,$?U.value="":ce=null),ie=0,K.hidden=!0,Fe(),$&&x.balance===null&&ae!==$&&(ae=$,Z.send("Info")),$||(ae=null)},onNotice(e){e.startsWith("Connected to:")||e.startsWith("Fingerprint:")||e.startsWith("User:")||e.startsWith("Account:")||e.startsWith("Balance:")||(J(e,he(e)),e.startsWith("Account created")&&(de("login"),U.value="",Ae.focus()),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account")},onError(e){J(e,"bad"),A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"},onReplaced(){Re.hidden=!1},onPong(){},onOpen(){K.hidden=!0},onClosed(e){if(Re.hidden!==!1){if(!e){Be.textContent="The connection could not be restored. Reload the page to try again.",K.hidden=!1;return}ie+=1,Be.textContent=ie>1?"Still trying to reach the game server…":"Trying to reach the game server…",K.hidden=!1}}});function B(e){Z.send(e)}function Lt(){return Date.now()+De}function de(e){_=e,Pe.classList.toggle("active",e==="login"),We.classList.toggle("active",e==="register"),A.textContent=e==="login"?"Log in":"Create account",U.autocomplete=e==="login"?"current-password":"new-password",F.textContent=""}function V(e){lt.hidden=e!=="auth",ct.hidden=e!=="lobby",dt.hidden=e!=="game",ut.hidden=e!=="history"}function xt(e){mt.textContent=L(e??null)}function Fe(){if(!x)return;let e=$;if(e?(He.hidden=!1,pt.textContent=String(e)):He.hidden=!0,xt(ce),!e){V("auth"),F.textContent=x.login_error?String(x.login_error):"",x.screen==="lobby"&&x.connected===!1&&(F.textContent="The game server is not reachable yet. Retrying…");return}switch(x.screen){case"lobby":V("lobby"),wt(x);break;case"history":V("history"),Te(ft,x);break;case"slots":case"roulette":case"blackjack":case"crash":V("game"),ht.textContent=Ie[x.screen]??x.screen,$e.textContent=vt(x),x.screen==="slots"?se(X,G,x,B):x.screen==="roulette"?Ne(X,G,x,B):x.screen==="blackjack"?ke(X,G,x,B):we(X,G,x,B,{now:Lt});break;default:V("auth")}}function vt(e){return e.screen==="crash"||e.screen==="blackjack"?String(e.status??""):e.screen==="slots"||e.screen==="roulette"?String(e.phase??""):""}function wt(e){re.replaceChildren();let n=e.games??[];for(let[t,s]of n){let a=o("article",`game-card ${t}`);a.append(o("h3",void 0,s||Ie[t]||t)),a.append(o("p",void 0,gt[t]??"Play a round."));let r=o("div","row wrap"),d=o("label","field");d.style.flex="1",d.append(o("span","label","Bet"));let i=o("input","input bet-input");i.type="number",i.min="1",i.step="10",i.value=String(le.get(t)??10),i.addEventListener("change",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));i.value=String(p),le.set(t,p)}),d.append(i);let m=o("button","btn primary",t==="roulette"?"Open table":"Play");m.addEventListener("click",()=>{let p=Math.max(1,Math.floor(Number(i.value)||1));le.set(t,p),B({Play:{game:t,bet:p}})}),r.append(d,m),a.append(r),re.append(a)}n.length===0&&re.append(o("p","muted","The server did not advertise any games."))}Pe.addEventListener("click",()=>de("login"));We.addEventListener("click",()=>de("register"));bt.addEventListener("submit",async e=>{e.preventDefault();let n=Ae.value.trim(),t=U.value;if(!n||!t){F.textContent="Enter a username and a password.";return}A.disabled=!0,A.textContent="Working…",F.textContent="";try{let s=await fe(t);B(_==="login"?{Login:{user:n,credential:s}}:{Register:{user:n,credential:s}})}catch{F.textContent="This browser cannot hash the password (needs a secure context).",A.disabled=!1,A.textContent=_==="login"?"Log in":"Create account"}});k("logout").addEventListener("click",()=>B("Logout"));k("show-history").addEventListener("click",()=>B({History:{limit:50}}));k("refresh-lobby").addEventListener("click",()=>B("Info"));k("history-back").addEventListener("click",()=>B("ShowLobby"));k("leave-game").addEventListener("click",()=>{!x||(x.screen==="blackjack"&&x.status==="player"||x.screen==="crash"&&x.status==="running")&&!window.confirm("Leaving now forfeits the active bet. Continue?")||B("Leave")});k("reload-replaced").addEventListener("click",()=>location.reload());k("reload-reconnect").addEventListener("click",()=>location.reload());window.addEventListener("resize",()=>{window.clearTimeout(window.__resizeTimer),window.__resizeTimer=window.setTimeout(()=>{x&&Fe()},140)});window.setInterval(()=>{$&&Z.ping()},25e3);Z.connect();})(); | |||
diff --git a/web/play/build.mjs b/web/play/build.mjs new file mode 100644 index 0000000..40af074 --- /dev/null +++ b/web/play/build.mjs | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | import { build, context } from "esbuild"; | ||
| 2 | |||
| 3 | const watch = process.argv.includes("--watch"); | ||
| 4 | |||
| 5 | const options = { | ||
| 6 | entryPoints: ["src/main.ts"], | ||
| 7 | bundle: true, | ||
| 8 | outfile: "app.js", | ||
| 9 | format: "iife", | ||
| 10 | target: ["es2020"], | ||
| 11 | minify: !watch, | ||
| 12 | sourcemap: watch ? "inline" : false, | ||
| 13 | charset: "utf8", | ||
| 14 | logLevel: "info", | ||
| 15 | }; | ||
| 16 | |||
| 17 | if (watch) { | ||
| 18 | const ctx = await context(options); | ||
| 19 | await ctx.watch(); | ||
| 20 | } else { | ||
| 21 | await build(options); | ||
| 22 | } | ||
diff --git a/web/play/index.html b/web/play/index.html new file mode 100644 index 0000000..e9d4b79 --- /dev/null +++ b/web/play/index.html | |||
| @@ -0,0 +1,112 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>Play — żagań.bet</title> | ||
| 7 | <meta name="description" content="Play slots, roulette, blackjack and crash with play credits. No real money."> | ||
| 8 | <meta name="color-scheme" content="dark"> | ||
| 9 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; base-uri 'none'; form-action 'self'"> | ||
| 10 | <link rel="stylesheet" href="style.css"> | ||
| 11 | </head> | ||
| 12 | <body> | ||
| 13 | <div class="shell"> | ||
| 14 | <header class="topbar"> | ||
| 15 | <a class="brand" href="/">żagań<span>.bet</span></a> | ||
| 16 | <span class="tagline">Play credits · no real money</span> | ||
| 17 | <span class="spacer"></span> | ||
| 18 | <div id="session-bar" class="session" hidden> | ||
| 19 | <span class="who">Signed in as <strong id="account-name"></strong></span> | ||
| 20 | <span id="account-balance" class="balance">— Kr</span> | ||
| 21 | <button id="logout" class="btn ghost small" type="button">Log out</button> | ||
| 22 | </div> | ||
| 23 | </header> | ||
| 24 | |||
| 25 | <main> | ||
| 26 | <section id="auth-screen" hidden> | ||
| 27 | <div class="auth-wrap"> | ||
| 28 | <div class="auth-card"> | ||
| 29 | <h1>Play żagań.bet</h1> | ||
| 30 | <p class="muted" style="margin: 6px 0 20px">Play-money casino. Your balance is play credit; nothing here is real money.</p> | ||
| 31 | <div class="auth-tabs" role="tablist"> | ||
| 32 | <button id="tab-login" class="auth-tab active" type="button" role="tab">Log in</button> | ||
| 33 | <button id="tab-register" class="auth-tab" type="button" role="tab">Register</button> | ||
| 34 | </div> | ||
| 35 | <form id="auth-form"> | ||
| 36 | <label class="field"> | ||
| 37 | <span class="label">Username</span> | ||
| 38 | <input id="auth-user" class="input" type="text" autocomplete="username" maxlength="32" required> | ||
| 39 | </label> | ||
| 40 | <label class="field"> | ||
| 41 | <span class="label">Password</span> | ||
| 42 | <input id="auth-pass" class="input" type="password" autocomplete="current-password" required> | ||
| 43 | </label> | ||
| 44 | <p id="auth-error" class="error-text" style="margin: 0; min-height: 18px"></p> | ||
| 45 | <button id="auth-submit" class="btn primary wide big" type="submit">Log in</button> | ||
| 46 | </form> | ||
| 47 | <p class="auth-foot">Registration gives you 1 000 Kr of play credit. No deposits, no withdrawals, no personal data beyond a username.</p> | ||
| 48 | </div> | ||
| 49 | </div> | ||
| 50 | </section> | ||
| 51 | |||
| 52 | <section id="lobby-screen" hidden> | ||
| 53 | <div class="lobby-head"> | ||
| 54 | <div> | ||
| 55 | <h1>Lobby</h1> | ||
| 56 | <p class="muted" style="margin: 4px 0 0">Choose a game. Bets are play credits.</p> | ||
| 57 | </div> | ||
| 58 | <span class="spacer"></span> | ||
| 59 | <button id="show-history" class="btn ghost" type="button">Play history</button> | ||
| 60 | <button id="refresh-lobby" class="btn ghost" type="button">Refresh</button> | ||
| 61 | </div> | ||
| 62 | <div id="game-grid" class="game-grid"></div> | ||
| 63 | </section> | ||
| 64 | |||
| 65 | <section id="game-screen" hidden> | ||
| 66 | <div class="game-head"> | ||
| 67 | <button id="leave-game" class="btn ghost" type="button">← Lobby</button> | ||
| 68 | <h1 id="game-title">Game</h1> | ||
| 69 | <span class="spacer"></span> | ||
| 70 | <span id="game-badge" class="label"></span> | ||
| 71 | </div> | ||
| 72 | <div class="game-body"> | ||
| 73 | <div id="game-board" class="board"></div> | ||
| 74 | <aside id="game-side" class="sidebar"></aside> | ||
| 75 | </div> | ||
| 76 | </section> | ||
| 77 | |||
| 78 | <section id="history-screen" hidden> | ||
| 79 | <div class="lobby-head"> | ||
| 80 | <div> | ||
| 81 | <h1>Play history</h1> | ||
| 82 | <p class="muted" style="margin: 4px 0 0">The last plays on your account.</p> | ||
| 83 | </div> | ||
| 84 | <span class="spacer"></span> | ||
| 85 | <button id="history-back" class="btn ghost" type="button">← Lobby</button> | ||
| 86 | </div> | ||
| 87 | <div id="history-body"></div> | ||
| 88 | </section> | ||
| 89 | </main> | ||
| 90 | </div> | ||
| 91 | |||
| 92 | <div id="toasts" aria-live="polite"></div> | ||
| 93 | |||
| 94 | <div id="replaced-overlay" class="overlay" hidden> | ||
| 95 | <div class="modal"> | ||
| 96 | <h2>Opened in another tab</h2> | ||
| 97 | <p>This game was opened somewhere else. Only one tab can play a session at a time.</p> | ||
| 98 | <button id="reload-replaced" class="btn primary" type="button">Reload this tab</button> | ||
| 99 | </div> | ||
| 100 | </div> | ||
| 101 | |||
| 102 | <div id="reconnect-overlay" class="overlay" hidden> | ||
| 103 | <div class="modal"> | ||
| 104 | <h2>Connection lost</h2> | ||
| 105 | <p id="reconnect-message">Trying to reach the game server…</p> | ||
| 106 | <button id="reload-reconnect" class="btn ghost" type="button">Reload</button> | ||
| 107 | </div> | ||
| 108 | </div> | ||
| 109 | |||
| 110 | <script src="app.js" defer></script> | ||
| 111 | </body> | ||
| 112 | </html> | ||
diff --git a/web/play/package-lock.json b/web/play/package-lock.json new file mode 100644 index 0000000..831527f --- /dev/null +++ b/web/play/package-lock.json | |||
| @@ -0,0 +1,1031 @@ | |||
| 1 | { | ||
| 2 | "name": "zagan-bet-play", | ||
| 3 | "version": "5.0.0", | ||
| 4 | "lockfileVersion": 3, | ||
| 5 | "requires": true, | ||
| 6 | "packages": { | ||
| 7 | "": { | ||
| 8 | "name": "zagan-bet-play", | ||
| 9 | "version": "5.0.0", | ||
| 10 | "devDependencies": { | ||
| 11 | "esbuild": "^0.28.0", | ||
| 12 | "jsdom": "^30.1.3", | ||
| 13 | "typescript": "^5.9.0" | ||
| 14 | } | ||
| 15 | }, | ||
| 16 | "node_modules/@asamuzakjp/css-color": { | ||
| 17 | "version": "7.1.9", | ||
| 18 | "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", | ||
| 19 | "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", | ||
| 20 | "dev": true, | ||
| 21 | "license": "MIT", | ||
| 22 | "dependencies": { | ||
| 23 | "@csstools/css-calc": "^3.4.3", | ||
| 24 | "@csstools/css-color-parser": "^4.2.6", | ||
| 25 | "@csstools/css-parser-algorithms": "^4.0.2", | ||
| 26 | "@csstools/css-tokenizer": "^4.0.2", | ||
| 27 | "lru-cache": "^11.5.3" | ||
| 28 | }, | ||
| 29 | "engines": { | ||
| 30 | "node": "^22.22.2 || ^24.15.0 || >=26.0.0" | ||
| 31 | } | ||
| 32 | }, | ||
| 33 | "node_modules/@asamuzakjp/dom-selector": { | ||
| 34 | "version": "10.0.5", | ||
| 35 | "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", | ||
| 36 | "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", | ||
| 37 | "dev": true, | ||
| 38 | "license": "MIT", | ||
| 39 | "dependencies": { | ||
| 40 | "bidi-js": "^1.1.0", | ||
| 41 | "css-tree": "^3.2.1", | ||
| 42 | "is-potential-custom-element-name": "^1.0.1", | ||
| 43 | "lru-cache": "^11.5.3" | ||
| 44 | }, | ||
| 45 | "engines": { | ||
| 46 | "node": "^22.22.2 || ^24.15.0 || >=26.0.0" | ||
| 47 | } | ||
| 48 | }, | ||
| 49 | "node_modules/@bramus/specificity": { | ||
| 50 | "version": "2.4.3", | ||
| 51 | "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", | ||
| 52 | "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", | ||
| 53 | "dev": true, | ||
| 54 | "license": "MIT", | ||
| 55 | "dependencies": { | ||
| 56 | "css-tree": "^3.2.1" | ||
| 57 | }, | ||
| 58 | "bin": { | ||
| 59 | "specificity": "bin/cli.js" | ||
| 60 | } | ||
| 61 | }, | ||
| 62 | "node_modules/@csstools/color-helpers": { | ||
| 63 | "version": "6.1.2", | ||
| 64 | "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", | ||
| 65 | "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", | ||
| 66 | "dev": true, | ||
| 67 | "funding": [ | ||
| 68 | { | ||
| 69 | "type": "github", | ||
| 70 | "url": "https://github.com/sponsors/csstools" | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | "type": "opencollective", | ||
| 74 | "url": "https://opencollective.com/csstools" | ||
| 75 | } | ||
| 76 | ], | ||
| 77 | "license": "MIT-0", | ||
| 78 | "engines": { | ||
| 79 | "node": ">=20.19.0" | ||
| 80 | } | ||
| 81 | }, | ||
| 82 | "node_modules/@csstools/css-calc": { | ||
| 83 | "version": "3.4.3", | ||
| 84 | "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", | ||
| 85 | "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", | ||
| 86 | "dev": true, | ||
| 87 | "funding": [ | ||
| 88 | { | ||
| 89 | "type": "github", | ||
| 90 | "url": "https://github.com/sponsors/csstools" | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | "type": "opencollective", | ||
| 94 | "url": "https://opencollective.com/csstools" | ||
| 95 | } | ||
| 96 | ], | ||
| 97 | "license": "MIT", | ||
| 98 | "engines": { | ||
| 99 | "node": ">=20.19.0" | ||
| 100 | }, | ||
| 101 | "peerDependencies": { | ||
| 102 | "@csstools/css-parser-algorithms": "^4.0.2", | ||
| 103 | "@csstools/css-tokenizer": "^4.0.2" | ||
| 104 | } | ||
| 105 | }, | ||
| 106 | "node_modules/@csstools/css-color-parser": { | ||
| 107 | "version": "4.2.7", | ||
| 108 | "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", | ||
| 109 | "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", | ||
| 110 | "dev": true, | ||
| 111 | "funding": [ | ||
| 112 | { | ||
| 113 | "type": "github", | ||
| 114 | "url": "https://github.com/sponsors/csstools" | ||
| 115 | }, | ||
| 116 | { | ||
| 117 | "type": "opencollective", | ||
| 118 | "url": "https://opencollective.com/csstools" | ||
| 119 | } | ||
| 120 | ], | ||
| 121 | "license": "MIT", | ||
| 122 | "dependencies": { | ||
| 123 | "@csstools/color-helpers": "^6.1.2", | ||
| 124 | "@csstools/css-calc": "^3.4.3" | ||
| 125 | }, | ||
| 126 | "engines": { | ||
| 127 | "node": ">=20.19.0" | ||
| 128 | }, | ||
| 129 | "peerDependencies": { | ||
| 130 | "@csstools/css-parser-algorithms": "^4.0.2", | ||
| 131 | "@csstools/css-tokenizer": "^4.0.2" | ||
| 132 | } | ||
| 133 | }, | ||
| 134 | "node_modules/@csstools/css-parser-algorithms": { | ||
| 135 | "version": "4.0.2", | ||
| 136 | "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", | ||
| 137 | "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", | ||
| 138 | "dev": true, | ||
| 139 | "funding": [ | ||
| 140 | { | ||
| 141 | "type": "github", | ||
| 142 | "url": "https://github.com/sponsors/csstools" | ||
| 143 | }, | ||
| 144 | { | ||
| 145 | "type": "opencollective", | ||
| 146 | "url": "https://opencollective.com/csstools" | ||
| 147 | } | ||
| 148 | ], | ||
| 149 | "license": "MIT", | ||
| 150 | "engines": { | ||
| 151 | "node": ">=20.19.0" | ||
| 152 | }, | ||
| 153 | "peerDependencies": { | ||
| 154 | "@csstools/css-tokenizer": "^4.0.2" | ||
| 155 | } | ||
| 156 | }, | ||
| 157 | "node_modules/@csstools/css-syntax-patches-for-csstree": { | ||
| 158 | "version": "1.1.16", | ||
| 159 | "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", | ||
| 160 | "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", | ||
| 161 | "dev": true, | ||
| 162 | "funding": [ | ||
| 163 | { | ||
| 164 | "type": "github", | ||
| 165 | "url": "https://github.com/sponsors/csstools" | ||
| 166 | }, | ||
| 167 | { | ||
| 168 | "type": "opencollective", | ||
| 169 | "url": "https://opencollective.com/csstools" | ||
| 170 | } | ||
| 171 | ], | ||
| 172 | "license": "MIT-0", | ||
| 173 | "peerDependencies": { | ||
| 174 | "css-tree": "^3.2.1" | ||
| 175 | }, | ||
| 176 | "peerDependenciesMeta": { | ||
| 177 | "css-tree": { | ||
| 178 | "optional": true | ||
| 179 | } | ||
| 180 | } | ||
| 181 | }, | ||
| 182 | "node_modules/@csstools/css-tokenizer": { | ||
| 183 | "version": "4.0.2", | ||
| 184 | "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", | ||
| 185 | "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", | ||
| 186 | "dev": true, | ||
| 187 | "funding": [ | ||
| 188 | { | ||
| 189 | "type": "github", | ||
| 190 | "url": "https://github.com/sponsors/csstools" | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | "type": "opencollective", | ||
| 194 | "url": "https://opencollective.com/csstools" | ||
| 195 | } | ||
| 196 | ], | ||
| 197 | "license": "MIT", | ||
| 198 | "engines": { | ||
| 199 | "node": ">=20.19.0" | ||
| 200 | } | ||
| 201 | }, | ||
| 202 | "node_modules/@esbuild/aix-ppc64": { | ||
| 203 | "version": "0.28.2", | ||
| 204 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", | ||
| 205 | "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", | ||
| 206 | "cpu": [ | ||
| 207 | "ppc64" | ||
| 208 | ], | ||
| 209 | "dev": true, | ||
| 210 | "license": "MIT", | ||
| 211 | "optional": true, | ||
| 212 | "os": [ | ||
| 213 | "aix" | ||
| 214 | ], | ||
| 215 | "engines": { | ||
| 216 | "node": ">=18" | ||
| 217 | } | ||
| 218 | }, | ||
| 219 | "node_modules/@esbuild/android-arm": { | ||
| 220 | "version": "0.28.2", | ||
| 221 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", | ||
| 222 | "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", | ||
| 223 | "cpu": [ | ||
| 224 | "arm" | ||
| 225 | ], | ||
| 226 | "dev": true, | ||
| 227 | "license": "MIT", | ||
| 228 | "optional": true, | ||
| 229 | "os": [ | ||
| 230 | "android" | ||
| 231 | ], | ||
| 232 | "engines": { | ||
| 233 | "node": ">=18" | ||
| 234 | } | ||
| 235 | }, | ||
| 236 | "node_modules/@esbuild/android-arm64": { | ||
| 237 | "version": "0.28.2", | ||
| 238 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", | ||
| 239 | "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", | ||
| 240 | "cpu": [ | ||
| 241 | "arm64" | ||
| 242 | ], | ||
| 243 | "dev": true, | ||
| 244 | "license": "MIT", | ||
| 245 | "optional": true, | ||
| 246 | "os": [ | ||
| 247 | "android" | ||
| 248 | ], | ||
| 249 | "engines": { | ||
| 250 | "node": ">=18" | ||
| 251 | } | ||
| 252 | }, | ||
| 253 | "node_modules/@esbuild/android-x64": { | ||
| 254 | "version": "0.28.2", | ||
| 255 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", | ||
| 256 | "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", | ||
| 257 | "cpu": [ | ||
| 258 | "x64" | ||
| 259 | ], | ||
| 260 | "dev": true, | ||
| 261 | "license": "MIT", | ||
| 262 | "optional": true, | ||
| 263 | "os": [ | ||
| 264 | "android" | ||
| 265 | ], | ||
| 266 | "engines": { | ||
| 267 | "node": ">=18" | ||
| 268 | } | ||
| 269 | }, | ||
| 270 | "node_modules/@esbuild/darwin-arm64": { | ||
| 271 | "version": "0.28.2", | ||
| 272 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", | ||
| 273 | "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", | ||
| 274 | "cpu": [ | ||
| 275 | "arm64" | ||
| 276 | ], | ||
| 277 | "dev": true, | ||
| 278 | "license": "MIT", | ||
| 279 | "optional": true, | ||
| 280 | "os": [ | ||
| 281 | "darwin" | ||
| 282 | ], | ||
| 283 | "engines": { | ||
| 284 | "node": ">=18" | ||
| 285 | } | ||
| 286 | }, | ||
| 287 | "node_modules/@esbuild/darwin-x64": { | ||
| 288 | "version": "0.28.2", | ||
| 289 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", | ||
| 290 | "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", | ||
| 291 | "cpu": [ | ||
| 292 | "x64" | ||
| 293 | ], | ||
| 294 | "dev": true, | ||
| 295 | "license": "MIT", | ||
| 296 | "optional": true, | ||
| 297 | "os": [ | ||
| 298 | "darwin" | ||
| 299 | ], | ||
| 300 | "engines": { | ||
| 301 | "node": ">=18" | ||
| 302 | } | ||
| 303 | }, | ||
| 304 | "node_modules/@esbuild/freebsd-arm64": { | ||
| 305 | "version": "0.28.2", | ||
| 306 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", | ||
| 307 | "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", | ||
| 308 | "cpu": [ | ||
| 309 | "arm64" | ||
| 310 | ], | ||
| 311 | "dev": true, | ||
| 312 | "license": "MIT", | ||
| 313 | "optional": true, | ||
| 314 | "os": [ | ||
| 315 | "freebsd" | ||
| 316 | ], | ||
| 317 | "engines": { | ||
| 318 | "node": ">=18" | ||
| 319 | } | ||
| 320 | }, | ||
| 321 | "node_modules/@esbuild/freebsd-x64": { | ||
| 322 | "version": "0.28.2", | ||
| 323 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", | ||
| 324 | "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", | ||
| 325 | "cpu": [ | ||
| 326 | "x64" | ||
| 327 | ], | ||
| 328 | "dev": true, | ||
| 329 | "license": "MIT", | ||
| 330 | "optional": true, | ||
| 331 | "os": [ | ||
| 332 | "freebsd" | ||
| 333 | ], | ||
| 334 | "engines": { | ||
| 335 | "node": ">=18" | ||
| 336 | } | ||
| 337 | }, | ||
| 338 | "node_modules/@esbuild/linux-arm": { | ||
| 339 | "version": "0.28.2", | ||
| 340 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", | ||
| 341 | "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", | ||
| 342 | "cpu": [ | ||
| 343 | "arm" | ||
| 344 | ], | ||
| 345 | "dev": true, | ||
| 346 | "license": "MIT", | ||
| 347 | "optional": true, | ||
| 348 | "os": [ | ||
| 349 | "linux" | ||
| 350 | ], | ||
| 351 | "engines": { | ||
| 352 | "node": ">=18" | ||
| 353 | } | ||
| 354 | }, | ||
| 355 | "node_modules/@esbuild/linux-arm64": { | ||
| 356 | "version": "0.28.2", | ||
| 357 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", | ||
| 358 | "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", | ||
| 359 | "cpu": [ | ||
| 360 | "arm64" | ||
| 361 | ], | ||
| 362 | "dev": true, | ||
| 363 | "license": "MIT", | ||
| 364 | "optional": true, | ||
| 365 | "os": [ | ||
| 366 | "linux" | ||
| 367 | ], | ||
| 368 | "engines": { | ||
| 369 | "node": ">=18" | ||
| 370 | } | ||
| 371 | }, | ||
| 372 | "node_modules/@esbuild/linux-ia32": { | ||
| 373 | "version": "0.28.2", | ||
| 374 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", | ||
| 375 | "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", | ||
| 376 | "cpu": [ | ||
| 377 | "ia32" | ||
| 378 | ], | ||
| 379 | "dev": true, | ||
| 380 | "license": "MIT", | ||
| 381 | "optional": true, | ||
| 382 | "os": [ | ||
| 383 | "linux" | ||
| 384 | ], | ||
| 385 | "engines": { | ||
| 386 | "node": ">=18" | ||
| 387 | } | ||
| 388 | }, | ||
| 389 | "node_modules/@esbuild/linux-loong64": { | ||
| 390 | "version": "0.28.2", | ||
| 391 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", | ||
| 392 | "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", | ||
| 393 | "cpu": [ | ||
| 394 | "loong64" | ||
| 395 | ], | ||
| 396 | "dev": true, | ||
| 397 | "license": "MIT", | ||
| 398 | "optional": true, | ||
| 399 | "os": [ | ||
| 400 | "linux" | ||
| 401 | ], | ||
| 402 | "engines": { | ||
| 403 | "node": ">=18" | ||
| 404 | } | ||
| 405 | }, | ||
| 406 | "node_modules/@esbuild/linux-mips64el": { | ||
| 407 | "version": "0.28.2", | ||
| 408 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", | ||
| 409 | "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", | ||
| 410 | "cpu": [ | ||
| 411 | "mips64el" | ||
| 412 | ], | ||
| 413 | "dev": true, | ||
| 414 | "license": "MIT", | ||
| 415 | "optional": true, | ||
| 416 | "os": [ | ||
| 417 | "linux" | ||
| 418 | ], | ||
| 419 | "engines": { | ||
| 420 | "node": ">=18" | ||
| 421 | } | ||
| 422 | }, | ||
| 423 | "node_modules/@esbuild/linux-ppc64": { | ||
| 424 | "version": "0.28.2", | ||
| 425 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", | ||
| 426 | "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", | ||
| 427 | "cpu": [ | ||
| 428 | "ppc64" | ||
| 429 | ], | ||
| 430 | "dev": true, | ||
| 431 | "license": "MIT", | ||
| 432 | "optional": true, | ||
| 433 | "os": [ | ||
| 434 | "linux" | ||
| 435 | ], | ||
| 436 | "engines": { | ||
| 437 | "node": ">=18" | ||
| 438 | } | ||
| 439 | }, | ||
| 440 | "node_modules/@esbuild/linux-riscv64": { | ||
| 441 | "version": "0.28.2", | ||
| 442 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", | ||
| 443 | "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", | ||
| 444 | "cpu": [ | ||
| 445 | "riscv64" | ||
| 446 | ], | ||
| 447 | "dev": true, | ||
| 448 | "license": "MIT", | ||
| 449 | "optional": true, | ||
| 450 | "os": [ | ||
| 451 | "linux" | ||
| 452 | ], | ||
| 453 | "engines": { | ||
| 454 | "node": ">=18" | ||
| 455 | } | ||
| 456 | }, | ||
| 457 | "node_modules/@esbuild/linux-s390x": { | ||
| 458 | "version": "0.28.2", | ||
| 459 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", | ||
| 460 | "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", | ||
| 461 | "cpu": [ | ||
| 462 | "s390x" | ||
| 463 | ], | ||
| 464 | "dev": true, | ||
| 465 | "license": "MIT", | ||
| 466 | "optional": true, | ||
| 467 | "os": [ | ||
| 468 | "linux" | ||
| 469 | ], | ||
| 470 | "engines": { | ||
| 471 | "node": ">=18" | ||
| 472 | } | ||
| 473 | }, | ||
| 474 | "node_modules/@esbuild/linux-x64": { | ||
| 475 | "version": "0.28.2", | ||
| 476 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", | ||
| 477 | "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", | ||
| 478 | "cpu": [ | ||
| 479 | "x64" | ||
| 480 | ], | ||
| 481 | "dev": true, | ||
| 482 | "license": "MIT", | ||
| 483 | "optional": true, | ||
| 484 | "os": [ | ||
| 485 | "linux" | ||
| 486 | ], | ||
| 487 | "engines": { | ||
| 488 | "node": ">=18" | ||
| 489 | } | ||
| 490 | }, | ||
| 491 | "node_modules/@esbuild/netbsd-arm64": { | ||
| 492 | "version": "0.28.2", | ||
| 493 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", | ||
| 494 | "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", | ||
| 495 | "cpu": [ | ||
| 496 | "arm64" | ||
| 497 | ], | ||
| 498 | "dev": true, | ||
| 499 | "license": "MIT", | ||
| 500 | "optional": true, | ||
| 501 | "os": [ | ||
| 502 | "netbsd" | ||
| 503 | ], | ||
| 504 | "engines": { | ||
| 505 | "node": ">=18" | ||
| 506 | } | ||
| 507 | }, | ||
| 508 | "node_modules/@esbuild/netbsd-x64": { | ||
| 509 | "version": "0.28.2", | ||
| 510 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", | ||
| 511 | "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", | ||
| 512 | "cpu": [ | ||
| 513 | "x64" | ||
| 514 | ], | ||
| 515 | "dev": true, | ||
| 516 | "license": "MIT", | ||
| 517 | "optional": true, | ||
| 518 | "os": [ | ||
| 519 | "netbsd" | ||
| 520 | ], | ||
| 521 | "engines": { | ||
| 522 | "node": ">=18" | ||
| 523 | } | ||
| 524 | }, | ||
| 525 | "node_modules/@esbuild/openbsd-arm64": { | ||
| 526 | "version": "0.28.2", | ||
| 527 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", | ||
| 528 | "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", | ||
| 529 | "cpu": [ | ||
| 530 | "arm64" | ||
| 531 | ], | ||
| 532 | "dev": true, | ||
| 533 | "license": "MIT", | ||
| 534 | "optional": true, | ||
| 535 | "os": [ | ||
| 536 | "openbsd" | ||
| 537 | ], | ||
| 538 | "engines": { | ||
| 539 | "node": ">=18" | ||
| 540 | } | ||
| 541 | }, | ||
| 542 | "node_modules/@esbuild/openbsd-x64": { | ||
| 543 | "version": "0.28.2", | ||
| 544 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", | ||
| 545 | "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", | ||
| 546 | "cpu": [ | ||
| 547 | "x64" | ||
| 548 | ], | ||
| 549 | "dev": true, | ||
| 550 | "license": "MIT", | ||
| 551 | "optional": true, | ||
| 552 | "os": [ | ||
| 553 | "openbsd" | ||
| 554 | ], | ||
| 555 | "engines": { | ||
| 556 | "node": ">=18" | ||
| 557 | } | ||
| 558 | }, | ||
| 559 | "node_modules/@esbuild/openharmony-arm64": { | ||
| 560 | "version": "0.28.2", | ||
| 561 | "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", | ||
| 562 | "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", | ||
| 563 | "cpu": [ | ||
| 564 | "arm64" | ||
| 565 | ], | ||
| 566 | "dev": true, | ||
| 567 | "license": "MIT", | ||
| 568 | "optional": true, | ||
| 569 | "os": [ | ||
| 570 | "openharmony" | ||
| 571 | ], | ||
| 572 | "engines": { | ||
| 573 | "node": ">=18" | ||
| 574 | } | ||
| 575 | }, | ||
| 576 | "node_modules/@esbuild/sunos-x64": { | ||
| 577 | "version": "0.28.2", | ||
| 578 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", | ||
| 579 | "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", | ||
| 580 | "cpu": [ | ||
| 581 | "x64" | ||
| 582 | ], | ||
| 583 | "dev": true, | ||
| 584 | "license": "MIT", | ||
| 585 | "optional": true, | ||
| 586 | "os": [ | ||
| 587 | "sunos" | ||
| 588 | ], | ||
| 589 | "engines": { | ||
| 590 | "node": ">=18" | ||
| 591 | } | ||
| 592 | }, | ||
| 593 | "node_modules/@esbuild/win32-arm64": { | ||
| 594 | "version": "0.28.2", | ||
| 595 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", | ||
| 596 | "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", | ||
| 597 | "cpu": [ | ||
| 598 | "arm64" | ||
| 599 | ], | ||
| 600 | "dev": true, | ||
| 601 | "license": "MIT", | ||
| 602 | "optional": true, | ||
| 603 | "os": [ | ||
| 604 | "win32" | ||
| 605 | ], | ||
| 606 | "engines": { | ||
| 607 | "node": ">=18" | ||
| 608 | } | ||
| 609 | }, | ||
| 610 | "node_modules/@esbuild/win32-ia32": { | ||
| 611 | "version": "0.28.2", | ||
| 612 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", | ||
| 613 | "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", | ||
| 614 | "cpu": [ | ||
| 615 | "ia32" | ||
| 616 | ], | ||
| 617 | "dev": true, | ||
| 618 | "license": "MIT", | ||
| 619 | "optional": true, | ||
| 620 | "os": [ | ||
| 621 | "win32" | ||
| 622 | ], | ||
| 623 | "engines": { | ||
| 624 | "node": ">=18" | ||
| 625 | } | ||
| 626 | }, | ||
| 627 | "node_modules/@esbuild/win32-x64": { | ||
| 628 | "version": "0.28.2", | ||
| 629 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", | ||
| 630 | "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", | ||
| 631 | "cpu": [ | ||
| 632 | "x64" | ||
| 633 | ], | ||
| 634 | "dev": true, | ||
| 635 | "license": "MIT", | ||
| 636 | "optional": true, | ||
| 637 | "os": [ | ||
| 638 | "win32" | ||
| 639 | ], | ||
| 640 | "engines": { | ||
| 641 | "node": ">=18" | ||
| 642 | } | ||
| 643 | }, | ||
| 644 | "node_modules/@exodus/bytes": { | ||
| 645 | "version": "1.16.0", | ||
| 646 | "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", | ||
| 647 | "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", | ||
| 648 | "dev": true, | ||
| 649 | "license": "MIT", | ||
| 650 | "engines": { | ||
| 651 | "node": "^20.19.0 || ^22.12.0 || >=24.0.0" | ||
| 652 | }, | ||
| 653 | "peerDependencies": { | ||
| 654 | "@noble/hashes": "^1.8.0 || ^2.0.0" | ||
| 655 | }, | ||
| 656 | "peerDependenciesMeta": { | ||
| 657 | "@noble/hashes": { | ||
| 658 | "optional": true | ||
| 659 | } | ||
| 660 | } | ||
| 661 | }, | ||
| 662 | "node_modules/bidi-js": { | ||
| 663 | "version": "1.1.0", | ||
| 664 | "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", | ||
| 665 | "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", | ||
| 666 | "dev": true, | ||
| 667 | "license": "MIT", | ||
| 668 | "dependencies": { | ||
| 669 | "require-from-string": "^2.0.2" | ||
| 670 | } | ||
| 671 | }, | ||
| 672 | "node_modules/css-tree": { | ||
| 673 | "version": "3.2.1", | ||
| 674 | "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", | ||
| 675 | "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", | ||
| 676 | "dev": true, | ||
| 677 | "license": "MIT", | ||
| 678 | "dependencies": { | ||
| 679 | "mdn-data": "2.27.1", | ||
| 680 | "source-map-js": "^1.2.1" | ||
| 681 | }, | ||
| 682 | "engines": { | ||
| 683 | "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" | ||
| 684 | } | ||
| 685 | }, | ||
| 686 | "node_modules/data-urls": { | ||
| 687 | "version": "8.0.0", | ||
| 688 | "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", | ||
| 689 | "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", | ||
| 690 | "dev": true, | ||
| 691 | "license": "MIT", | ||
| 692 | "dependencies": { | ||
| 693 | "whatwg-mimetype": "^5.0.0", | ||
| 694 | "whatwg-url": "^17.1.2" | ||
| 695 | }, | ||
| 696 | "engines": { | ||
| 697 | "node": "^22.14.0 || >=24.0.0" | ||
| 698 | } | ||
| 699 | }, | ||
| 700 | "node_modules/decimal.js": { | ||
| 701 | "version": "10.6.0", | ||
| 702 | "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", | ||
| 703 | "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", | ||
| 704 | "dev": true, | ||
| 705 | "license": "MIT" | ||
| 706 | }, | ||
| 707 | "node_modules/entities": { | ||
| 708 | "version": "8.1.0", | ||
| 709 | "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", | ||
| 710 | "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", | ||
| 711 | "dev": true, | ||
| 712 | "license": "BSD-2-Clause", | ||
| 713 | "engines": { | ||
| 714 | "node": ">=20.19.0" | ||
| 715 | }, | ||
| 716 | "funding": { | ||
| 717 | "url": "https://github.com/fb55/entities?sponsor=1" | ||
| 718 | } | ||
| 719 | }, | ||
| 720 | "node_modules/esbuild": { | ||
| 721 | "version": "0.28.2", | ||
| 722 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", | ||
| 723 | "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", | ||
| 724 | "dev": true, | ||
| 725 | "hasInstallScript": true, | ||
| 726 | "license": "MIT", | ||
| 727 | "bin": { | ||
| 728 | "esbuild": "bin/esbuild" | ||
| 729 | }, | ||
| 730 | "engines": { | ||
| 731 | "node": ">=18" | ||
| 732 | }, | ||
| 733 | "optionalDependencies": { | ||
| 734 | "@esbuild/aix-ppc64": "0.28.2", | ||
| 735 | "@esbuild/android-arm": "0.28.2", | ||
| 736 | "@esbuild/android-arm64": "0.28.2", | ||
| 737 | "@esbuild/android-x64": "0.28.2", | ||
| 738 | "@esbuild/darwin-arm64": "0.28.2", | ||
| 739 | "@esbuild/darwin-x64": "0.28.2", | ||
| 740 | "@esbuild/freebsd-arm64": "0.28.2", | ||
| 741 | "@esbuild/freebsd-x64": "0.28.2", | ||
| 742 | "@esbuild/linux-arm": "0.28.2", | ||
| 743 | "@esbuild/linux-arm64": "0.28.2", | ||
| 744 | "@esbuild/linux-ia32": "0.28.2", | ||
| 745 | "@esbuild/linux-loong64": "0.28.2", | ||
| 746 | "@esbuild/linux-mips64el": "0.28.2", | ||
| 747 | "@esbuild/linux-ppc64": "0.28.2", | ||
| 748 | "@esbuild/linux-riscv64": "0.28.2", | ||
| 749 | "@esbuild/linux-s390x": "0.28.2", | ||
| 750 | "@esbuild/linux-x64": "0.28.2", | ||
| 751 | "@esbuild/netbsd-arm64": "0.28.2", | ||
| 752 | "@esbuild/netbsd-x64": "0.28.2", | ||
| 753 | "@esbuild/openbsd-arm64": "0.28.2", | ||
| 754 | "@esbuild/openbsd-x64": "0.28.2", | ||
| 755 | "@esbuild/openharmony-arm64": "0.28.2", | ||
| 756 | "@esbuild/sunos-x64": "0.28.2", | ||
| 757 | "@esbuild/win32-arm64": "0.28.2", | ||
| 758 | "@esbuild/win32-ia32": "0.28.2", | ||
| 759 | "@esbuild/win32-x64": "0.28.2" | ||
| 760 | } | ||
| 761 | }, | ||
| 762 | "node_modules/html-encoding-sniffer": { | ||
| 763 | "version": "7.0.0", | ||
| 764 | "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", | ||
| 765 | "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", | ||
| 766 | "dev": true, | ||
| 767 | "license": "MIT", | ||
| 768 | "dependencies": { | ||
| 769 | "@exodus/bytes": "^1.15.1" | ||
| 770 | }, | ||
| 771 | "engines": { | ||
| 772 | "node": "^22.13.0 || >=24.0.0" | ||
| 773 | } | ||
| 774 | }, | ||
| 775 | "node_modules/is-potential-custom-element-name": { | ||
| 776 | "version": "1.0.1", | ||
| 777 | "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", | ||
| 778 | "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", | ||
| 779 | "dev": true, | ||
| 780 | "license": "MIT" | ||
| 781 | }, | ||
| 782 | "node_modules/jsdom": { | ||
| 783 | "version": "30.1.3", | ||
| 784 | "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", | ||
| 785 | "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", | ||
| 786 | "dev": true, | ||
| 787 | "license": "MIT", | ||
| 788 | "dependencies": { | ||
| 789 | "@asamuzakjp/css-color": "^7.1.3", | ||
| 790 | "@asamuzakjp/dom-selector": "^10.0.3", | ||
| 791 | "@bramus/specificity": "^2.4.2", | ||
| 792 | "@csstools/css-syntax-patches-for-csstree": "^1.1.15", | ||
| 793 | "@exodus/bytes": "^1.16.0", | ||
| 794 | "css-tree": "^3.2.1", | ||
| 795 | "data-urls": "^8.0.0", | ||
| 796 | "decimal.js": "^10.6.0", | ||
| 797 | "html-encoding-sniffer": "^7.0.0", | ||
| 798 | "is-potential-custom-element-name": "^1.0.1", | ||
| 799 | "lru-cache": "^11.5.3", | ||
| 800 | "parse5": "^8.0.1", | ||
| 801 | "saxes": "^6.0.0", | ||
| 802 | "tough-cookie": "^6.0.2", | ||
| 803 | "undici": "^8.12.1", | ||
| 804 | "w3c-xmlserializer": "^6.0.0", | ||
| 805 | "webidl-conversions": "^8.0.1", | ||
| 806 | "whatwg-mimetype": "^5.0.0", | ||
| 807 | "whatwg-url": "^17.2.0", | ||
| 808 | "xml-name-validator": "^5.0.0" | ||
| 809 | }, | ||
| 810 | "engines": { | ||
| 811 | "node": "^22.22.2 || ^24.15.0 || >=26.0.0" | ||
| 812 | }, | ||
| 813 | "peerDependencies": { | ||
| 814 | "canvas": "^3.2.3" | ||
| 815 | }, | ||
| 816 | "peerDependenciesMeta": { | ||
| 817 | "canvas": { | ||
| 818 | "optional": true | ||
| 819 | } | ||
| 820 | } | ||
| 821 | }, | ||
| 822 | "node_modules/lru-cache": { | ||
| 823 | "version": "11.5.3", | ||
| 824 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", | ||
| 825 | "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", | ||
| 826 | "dev": true, | ||
| 827 | "license": "BlueOak-1.0.0", | ||
| 828 | "engines": { | ||
| 829 | "node": "20 || >=22" | ||
| 830 | } | ||
| 831 | }, | ||
| 832 | "node_modules/mdn-data": { | ||
| 833 | "version": "2.27.1", | ||
| 834 | "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", | ||
| 835 | "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", | ||
| 836 | "dev": true, | ||
| 837 | "license": "CC0-1.0" | ||
| 838 | }, | ||
| 839 | "node_modules/parse5": { | ||
| 840 | "version": "8.0.1", | ||
| 841 | "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", | ||
| 842 | "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", | ||
| 843 | "dev": true, | ||
| 844 | "license": "MIT", | ||
| 845 | "dependencies": { | ||
| 846 | "entities": "^8.0.0" | ||
| 847 | }, | ||
| 848 | "funding": { | ||
| 849 | "url": "https://github.com/inikulin/parse5?sponsor=1" | ||
| 850 | } | ||
| 851 | }, | ||
| 852 | "node_modules/punycode": { | ||
| 853 | "version": "2.3.1", | ||
| 854 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", | ||
| 855 | "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", | ||
| 856 | "dev": true, | ||
| 857 | "license": "MIT", | ||
| 858 | "engines": { | ||
| 859 | "node": ">=6" | ||
| 860 | } | ||
| 861 | }, | ||
| 862 | "node_modules/require-from-string": { | ||
| 863 | "version": "2.0.2", | ||
| 864 | "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", | ||
| 865 | "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", | ||
| 866 | "dev": true, | ||
| 867 | "license": "MIT", | ||
| 868 | "engines": { | ||
| 869 | "node": ">=0.10.0" | ||
| 870 | } | ||
| 871 | }, | ||
| 872 | "node_modules/saxes": { | ||
| 873 | "version": "6.0.0", | ||
| 874 | "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", | ||
| 875 | "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", | ||
| 876 | "dev": true, | ||
| 877 | "license": "ISC", | ||
| 878 | "dependencies": { | ||
| 879 | "xmlchars": "^2.2.0" | ||
| 880 | }, | ||
| 881 | "engines": { | ||
| 882 | "node": ">=v12.22.7" | ||
| 883 | } | ||
| 884 | }, | ||
| 885 | "node_modules/source-map-js": { | ||
| 886 | "version": "1.2.2", | ||
| 887 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", | ||
| 888 | "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", | ||
| 889 | "dev": true, | ||
| 890 | "license": "BSD-3-Clause", | ||
| 891 | "engines": { | ||
| 892 | "node": ">=0.10.0" | ||
| 893 | } | ||
| 894 | }, | ||
| 895 | "node_modules/tldts": { | ||
| 896 | "version": "7.4.19", | ||
| 897 | "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", | ||
| 898 | "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", | ||
| 899 | "dev": true, | ||
| 900 | "license": "MIT", | ||
| 901 | "dependencies": { | ||
| 902 | "tldts-core": "^7.4.19" | ||
| 903 | }, | ||
| 904 | "bin": { | ||
| 905 | "tldts": "bin/cli.js" | ||
| 906 | } | ||
| 907 | }, | ||
| 908 | "node_modules/tldts-core": { | ||
| 909 | "version": "7.4.20", | ||
| 910 | "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", | ||
| 911 | "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", | ||
| 912 | "dev": true, | ||
| 913 | "license": "MIT" | ||
| 914 | }, | ||
| 915 | "node_modules/tough-cookie": { | ||
| 916 | "version": "6.0.2", | ||
| 917 | "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", | ||
| 918 | "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", | ||
| 919 | "dev": true, | ||
| 920 | "license": "BSD-3-Clause", | ||
| 921 | "dependencies": { | ||
| 922 | "tldts": "^7.0.5" | ||
| 923 | }, | ||
| 924 | "engines": { | ||
| 925 | "node": ">=16" | ||
| 926 | } | ||
| 927 | }, | ||
| 928 | "node_modules/tr46": { | ||
| 929 | "version": "7.0.0", | ||
| 930 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", | ||
| 931 | "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", | ||
| 932 | "dev": true, | ||
| 933 | "license": "MIT", | ||
| 934 | "dependencies": { | ||
| 935 | "punycode": "^2.3.1" | ||
| 936 | }, | ||
| 937 | "engines": { | ||
| 938 | "node": "^22.14.0 || >=24.0.0" | ||
| 939 | } | ||
| 940 | }, | ||
| 941 | "node_modules/typescript": { | ||
| 942 | "version": "5.9.3", | ||
| 943 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", | ||
| 944 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | ||
| 945 | "dev": true, | ||
| 946 | "license": "Apache-2.0", | ||
| 947 | "bin": { | ||
| 948 | "tsc": "bin/tsc", | ||
| 949 | "tsserver": "bin/tsserver" | ||
| 950 | }, | ||
| 951 | "engines": { | ||
| 952 | "node": ">=14.17" | ||
| 953 | } | ||
| 954 | }, | ||
| 955 | "node_modules/undici": { | ||
| 956 | "version": "8.12.3", | ||
| 957 | "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", | ||
| 958 | "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", | ||
| 959 | "dev": true, | ||
| 960 | "license": "MIT", | ||
| 961 | "engines": { | ||
| 962 | "node": ">=22.19.0" | ||
| 963 | } | ||
| 964 | }, | ||
| 965 | "node_modules/w3c-xmlserializer": { | ||
| 966 | "version": "6.0.0", | ||
| 967 | "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", | ||
| 968 | "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", | ||
| 969 | "dev": true, | ||
| 970 | "license": "MIT", | ||
| 971 | "dependencies": { | ||
| 972 | "xml-name-validator": "^5.0.0" | ||
| 973 | }, | ||
| 974 | "engines": { | ||
| 975 | "node": "^22.22.2 || ^24.15.0 || >=26.0.0" | ||
| 976 | } | ||
| 977 | }, | ||
| 978 | "node_modules/webidl-conversions": { | ||
| 979 | "version": "8.0.1", | ||
| 980 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", | ||
| 981 | "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", | ||
| 982 | "dev": true, | ||
| 983 | "license": "BSD-2-Clause", | ||
| 984 | "engines": { | ||
| 985 | "node": ">=20" | ||
| 986 | } | ||
| 987 | }, | ||
| 988 | "node_modules/whatwg-mimetype": { | ||
| 989 | "version": "5.0.0", | ||
| 990 | "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", | ||
| 991 | "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", | ||
| 992 | "dev": true, | ||
| 993 | "license": "MIT", | ||
| 994 | "engines": { | ||
| 995 | "node": ">=20" | ||
| 996 | } | ||
| 997 | }, | ||
| 998 | "node_modules/whatwg-url": { | ||
| 999 | "version": "17.3.0", | ||
| 1000 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", | ||
| 1001 | "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", | ||
| 1002 | "dev": true, | ||
| 1003 | "license": "MIT", | ||
| 1004 | "dependencies": { | ||
| 1005 | "@exodus/bytes": "^1.16.0", | ||
| 1006 | "tr46": "^7.0.0", | ||
| 1007 | "webidl-conversions": "^8.0.1" | ||
| 1008 | }, | ||
| 1009 | "engines": { | ||
| 1010 | "node": "^22.14.0 || >=24.0.0" | ||
| 1011 | } | ||
| 1012 | }, | ||
| 1013 | "node_modules/xml-name-validator": { | ||
| 1014 | "version": "5.0.0", | ||
| 1015 | "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", | ||
| 1016 | "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", | ||
| 1017 | "dev": true, | ||
| 1018 | "license": "Apache-2.0", | ||
| 1019 | "engines": { | ||
| 1020 | "node": ">=18" | ||
| 1021 | } | ||
| 1022 | }, | ||
| 1023 | "node_modules/xmlchars": { | ||
| 1024 | "version": "2.2.0", | ||
| 1025 | "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", | ||
| 1026 | "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", | ||
| 1027 | "dev": true, | ||
| 1028 | "license": "MIT" | ||
| 1029 | } | ||
| 1030 | } | ||
| 1031 | } | ||
diff --git a/web/play/package.json b/web/play/package.json new file mode 100644 index 0000000..3e3cebf --- /dev/null +++ b/web/play/package.json | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | { | ||
| 2 | "name": "zagan-bet-play", | ||
| 3 | "private": true, | ||
| 4 | "version": "5.0.0", | ||
| 5 | "type": "module", | ||
| 6 | "scripts": { | ||
| 7 | "build": "node build.mjs", | ||
| 8 | "watch": "node build.mjs --watch", | ||
| 9 | "typecheck": "tsc --noEmit", | ||
| 10 | "test": "node test/smoke.mjs" | ||
| 11 | }, | ||
| 12 | "devDependencies": { | ||
| 13 | "esbuild": "^0.28.0", | ||
| 14 | "jsdom": "^30.1.3", | ||
| 15 | "typescript": "^5.9.0" | ||
| 16 | }, | ||
| 17 | "engines": { | ||
| 18 | "node": ">=20" | ||
| 19 | } | ||
| 20 | } | ||
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts new file mode 100644 index 0000000..3537f45 --- /dev/null +++ b/web/play/src/blackjack.ts | |||
| @@ -0,0 +1,152 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { cardFace, clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | function cardElement(card: string): HTMLElement { | ||
| 5 | if (card === "?") { | ||
| 6 | const back = node("div", "card back"); | ||
| 7 | back.append(node("span", "rank", "?"), node("span", "suit", "")); | ||
| 8 | return back; | ||
| 9 | } | ||
| 10 | const face = cardFace(card); | ||
| 11 | const element = node("div", "card"); | ||
| 12 | if (face.red) element.classList.add("red"); | ||
| 13 | element.append(node("span", "rank", face.rank), node("span", "suit", face.suit)); | ||
| 14 | return element; | ||
| 15 | } | ||
| 16 | |||
| 17 | function handBlock(label: string, cards: string[], total: number): HTMLElement { | ||
| 18 | const block = node("div", "hand"); | ||
| 19 | block.append(node("div", "label", label)); | ||
| 20 | const row = node("div", "cards"); | ||
| 21 | for (const card of cards) row.append(cardElement(card)); | ||
| 22 | const sum = node("div", "total"); | ||
| 23 | sum.append(document.createTextNode("Total "), node("strong", undefined, String(total))); | ||
| 24 | block.append(row, sum); | ||
| 25 | return block; | ||
| 26 | } | ||
| 27 | |||
| 28 | function outcome(view: View, hand: any): string { | ||
| 29 | const result = String(hand?.result ?? ""); | ||
| 30 | const winnings = Number(hand?.winnings ?? 0); | ||
| 31 | switch (result) { | ||
| 32 | case "blackjack": | ||
| 33 | return `Blackjack — won ${money(winnings)}`; | ||
| 34 | case "win": | ||
| 35 | return `You win — ${money(winnings)}`; | ||
| 36 | case "push": | ||
| 37 | return `Push — ${money(winnings)} returned`; | ||
| 38 | case "bust": | ||
| 39 | return "Bust — the bet is lost"; | ||
| 40 | case "lose": | ||
| 41 | return "Dealer wins — the bet is lost"; | ||
| 42 | case "forfeit": | ||
| 43 | return "Hand forfeited — the bet is lost"; | ||
| 44 | case "playing": | ||
| 45 | return view.status === "player" ? "Your move." : "Dealing…"; | ||
| 46 | default: | ||
| 47 | return "Ready to deal."; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | export function renderBlackjack( | ||
| 52 | board: HTMLElement, | ||
| 53 | side: HTMLElement, | ||
| 54 | view: View, | ||
| 55 | send: (command: unknown) => void, | ||
| 56 | ): void { | ||
| 57 | clear(board); | ||
| 58 | clear(side); | ||
| 59 | |||
| 60 | const hand = view.hand; | ||
| 61 | const playerPhase = hand?.phase === "player" || view.status === "player"; | ||
| 62 | const done = hand?.phase === "done" || view.status === "done"; | ||
| 63 | const canDeal = !hand || done; | ||
| 64 | |||
| 65 | board.append(node("h2", undefined, "Blackjack")); | ||
| 66 | board.append( | ||
| 67 | node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."), | ||
| 68 | ); | ||
| 69 | |||
| 70 | if (hand) { | ||
| 71 | board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); | ||
| 72 | board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); | ||
| 73 | } else { | ||
| 74 | const empty = node("div", "outcome"); | ||
| 75 | empty.textContent = "Press DEAL to start a hand."; | ||
| 76 | board.append(empty); | ||
| 77 | } | ||
| 78 | |||
| 79 | if (done || (hand && !playerPhase)) { | ||
| 80 | const result = node("div", "outcome"); | ||
| 81 | result.textContent = outcome(view, hand); | ||
| 82 | if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { | ||
| 83 | result.classList.add("win-text"); | ||
| 84 | } | ||
| 85 | board.append(result); | ||
| 86 | } | ||
| 87 | |||
| 88 | if (view.error) board.append(node("p", "error-text", String(view.error))); | ||
| 89 | |||
| 90 | // Sidebar | ||
| 91 | side.append(node("div", "label", "Blackjack")); | ||
| 92 | const bet = Number(hand?.bet ?? view.bet ?? 0); | ||
| 93 | const balance = view.balance ?? view.hand?.balance; | ||
| 94 | side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); | ||
| 95 | |||
| 96 | if (canDeal) { | ||
| 97 | const betRow = node("div", "row"); | ||
| 98 | const minus = node("button", "btn small", "−10"); | ||
| 99 | const amount = node("strong", "mono", money(bet)); | ||
| 100 | const plus = node("button", "btn small", "+10"); | ||
| 101 | minus.addEventListener("click", () => send({ AdjustBet: -10 })); | ||
| 102 | plus.addEventListener("click", () => send({ AdjustBet: 10 })); | ||
| 103 | betRow.append(minus, amount, plus); | ||
| 104 | side.append(node("div", "label", "Bet"), betRow); | ||
| 105 | } else { | ||
| 106 | const stat = node("div", "stat"); | ||
| 107 | stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet))); | ||
| 108 | side.append(stat); | ||
| 109 | } | ||
| 110 | |||
| 111 | if (balance !== undefined) { | ||
| 112 | const stat = node("div", "stat"); | ||
| 113 | stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance)))); | ||
| 114 | side.append(stat); | ||
| 115 | } | ||
| 116 | |||
| 117 | const actions = node("div", "actions"); | ||
| 118 | if (playerPhase) { | ||
| 119 | const actionsRow = node("div", "row wrap"); | ||
| 120 | for (const action of hand?.actions ?? []) { | ||
| 121 | const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE"; | ||
| 122 | const button = | ||
| 123 | action === "stand" | ||
| 124 | ? node("button", "btn primary", label) | ||
| 125 | : node("button", "btn", label); | ||
| 126 | button.addEventListener("click", () => { | ||
| 127 | if (action === "hit") send("Hit"); | ||
| 128 | else if (action === "stand") send("Stand"); | ||
| 129 | else send("Double"); | ||
| 130 | }); | ||
| 131 | actionsRow.append(button); | ||
| 132 | } | ||
| 133 | actions.append(actionsRow); | ||
| 134 | const forfeit = node("button", "btn danger wide", "Forfeit hand"); | ||
| 135 | forfeit.addEventListener("click", () => { | ||
| 136 | if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); | ||
| 137 | }); | ||
| 138 | actions.append(forfeit); | ||
| 139 | } else if (canDeal) { | ||
| 140 | const deal = node("button", "btn primary wide big", "DEAL"); | ||
| 141 | deal.addEventListener("click", () => send("Deal")); | ||
| 142 | actions.append(deal); | ||
| 143 | } | ||
| 144 | |||
| 145 | const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table"); | ||
| 146 | leave.addEventListener("click", () => { | ||
| 147 | if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return; | ||
| 148 | send("Leave"); | ||
| 149 | }); | ||
| 150 | actions.append(leave); | ||
| 151 | side.append(actions); | ||
| 152 | } | ||
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts new file mode 100644 index 0000000..6f3c8dd --- /dev/null +++ b/web/play/src/crash.ts | |||
| @@ -0,0 +1,365 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node, shortHash } from "./dom"; | ||
| 3 | |||
| 4 | const GROWTH = Math.LN2 / 5; | ||
| 5 | |||
| 6 | type Clock = { now: () => number }; | ||
| 7 | |||
| 8 | type Parts = { | ||
| 9 | board: HTMLElement; | ||
| 10 | side: HTMLElement; | ||
| 11 | number: HTMLElement; | ||
| 12 | meta: HTMLElement; | ||
| 13 | verify: HTMLElement; | ||
| 14 | canvas: HTMLCanvasElement; | ||
| 15 | bet: HTMLElement; | ||
| 16 | potential: HTMLElement; | ||
| 17 | balance: HTMLElement; | ||
| 18 | status: HTMLElement; | ||
| 19 | lastCrash: HTMLElement; | ||
| 20 | error: HTMLElement; | ||
| 21 | primary: HTMLButtonElement; | ||
| 22 | leave: HTMLButtonElement; | ||
| 23 | minus: HTMLButtonElement; | ||
| 24 | plus: HTMLButtonElement; | ||
| 25 | }; | ||
| 26 | |||
| 27 | let parts: Parts | null = null; | ||
| 28 | let current: View | null = null; | ||
| 29 | let clock: Clock | null = null; | ||
| 30 | let sendCommand: ((command: unknown) => void) | null = null; | ||
| 31 | let frameHandle: number | undefined; | ||
| 32 | let verifyToken = 0; | ||
| 33 | let lastStatus = ""; | ||
| 34 | |||
| 35 | function build( | ||
| 36 | board: HTMLElement, | ||
| 37 | side: HTMLElement, | ||
| 38 | send: (command: unknown) => void, | ||
| 39 | now: Clock, | ||
| 40 | ): Parts { | ||
| 41 | clear(board); | ||
| 42 | clear(side); | ||
| 43 | sendCommand = send; | ||
| 44 | clock = now; | ||
| 45 | |||
| 46 | board.append(node("h2", undefined, "Crash")); | ||
| 47 | board.append( | ||
| 48 | node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const wrap = node("div", "crash-wrap"); | ||
| 52 | const number = node("div", "crash-number", "1.00×"); | ||
| 53 | const meta = node("div", "crash-meta"); | ||
| 54 | const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; | ||
| 55 | const verify = node("div", "verify", "Waiting for a round commitment."); | ||
| 56 | wrap.append(number, meta, canvas, verify); | ||
| 57 | board.append(wrap); | ||
| 58 | |||
| 59 | side.append(node("div", "label", "Crash")); | ||
| 60 | side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); | ||
| 61 | |||
| 62 | const betRow = node("div", "row"); | ||
| 63 | const minus = node("button", "btn small", "−10") as HTMLButtonElement; | ||
| 64 | const bet = node("strong", "mono", "—"); | ||
| 65 | const plus = node("button", "btn small", "+10") as HTMLButtonElement; | ||
| 66 | minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); | ||
| 67 | plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); | ||
| 68 | betRow.append(minus, bet, plus); | ||
| 69 | side.append(node("div", "label", "Bet"), betRow); | ||
| 70 | |||
| 71 | const potential = node("strong", "mono", "—"); | ||
| 72 | const balance = node("strong", "mono", "—"); | ||
| 73 | for (const [label, value] of [ | ||
| 74 | ["Potential", potential], | ||
| 75 | ["Balance", balance], | ||
| 76 | ] as const) { | ||
| 77 | const row = node("div", "stat"); | ||
| 78 | row.append(node("span", "muted", label), value); | ||
| 79 | side.append(row); | ||
| 80 | } | ||
| 81 | |||
| 82 | const status = node("p", "note", "Ready."); | ||
| 83 | const lastCrash = node("p", "note", ""); | ||
| 84 | const error = node("p", "error-text"); | ||
| 85 | side.append(status, lastCrash, error); | ||
| 86 | |||
| 87 | const actions = node("div", "actions"); | ||
| 88 | const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; | ||
| 89 | const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; | ||
| 90 | primary.addEventListener("click", () => { | ||
| 91 | if (current?.status === "running") sendCommand?.("CashOut"); | ||
| 92 | else sendCommand?.("Start"); | ||
| 93 | }); | ||
| 94 | leave.addEventListener("click", () => { | ||
| 95 | if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return; | ||
| 96 | sendCommand?.("Leave"); | ||
| 97 | }); | ||
| 98 | actions.append(primary, leave); | ||
| 99 | side.append(actions); | ||
| 100 | |||
| 101 | return { | ||
| 102 | board, | ||
| 103 | side, | ||
| 104 | number, | ||
| 105 | meta, | ||
| 106 | verify, | ||
| 107 | canvas, | ||
| 108 | bet, | ||
| 109 | potential, | ||
| 110 | balance, | ||
| 111 | status, | ||
| 112 | lastCrash, | ||
| 113 | error, | ||
| 114 | primary, | ||
| 115 | leave, | ||
| 116 | minus, | ||
| 117 | plus, | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | |||
| 121 | export function renderCrash( | ||
| 122 | board: HTMLElement, | ||
| 123 | side: HTMLElement, | ||
| 124 | view: View, | ||
| 125 | send: (command: unknown) => void, | ||
| 126 | now: Clock, | ||
| 127 | ): void { | ||
| 128 | if (!parts || parts.board !== board) { | ||
| 129 | parts = build(board, side, send, now); | ||
| 130 | lastStatus = ""; | ||
| 131 | } | ||
| 132 | const p = parts; | ||
| 133 | sendCommand = send; | ||
| 134 | clock = now; | ||
| 135 | current = view; | ||
| 136 | |||
| 137 | if (view.status !== lastStatus) { | ||
| 138 | if (view.status === "starting" || view.status === "running") { | ||
| 139 | if (lastStatus !== "running") { | ||
| 140 | p.verify.className = "verify"; | ||
| 141 | p.verify.textContent = "Round committed; seed hidden until the round ends."; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | lastStatus = String(view.status); | ||
| 145 | } | ||
| 146 | |||
| 147 | const running = view.status === "running"; | ||
| 148 | const starting = view.status === "starting"; | ||
| 149 | const live = running || starting; | ||
| 150 | |||
| 151 | p.bet.textContent = money(Number(view.bet ?? 0)); | ||
| 152 | p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0)); | ||
| 153 | p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance)); | ||
| 154 | p.error.textContent = view.error ? String(view.error) : ""; | ||
| 155 | |||
| 156 | p.minus.disabled = live; | ||
| 157 | p.plus.disabled = live; | ||
| 158 | p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START"; | ||
| 159 | p.primary.disabled = starting; | ||
| 160 | p.primary.className = running ? "btn primary wide big" : "btn primary wide big"; | ||
| 161 | p.leave.textContent = running ? "Forfeit round" : "Leave table"; | ||
| 162 | |||
| 163 | if (running) { | ||
| 164 | p.status.textContent = "Rising — cash out before the crash."; | ||
| 165 | p.status.className = "note win-text"; | ||
| 166 | } else if (starting) { | ||
| 167 | p.status.textContent = "Starting the round…"; | ||
| 168 | p.status.className = "note"; | ||
| 169 | } else if (view.status === "cashed") { | ||
| 170 | p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`; | ||
| 171 | p.status.className = "note win-text"; | ||
| 172 | } else if (view.status === "crashed") { | ||
| 173 | p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`; | ||
| 174 | p.status.className = "note error-text"; | ||
| 175 | } else if (view.status === "forfeit") { | ||
| 176 | p.status.textContent = "Round forfeited."; | ||
| 177 | p.status.className = "note error-text"; | ||
| 178 | } else { | ||
| 179 | p.status.textContent = "Ready. Start a round when you want."; | ||
| 180 | p.status.className = "note"; | ||
| 181 | } | ||
| 182 | |||
| 183 | if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") { | ||
| 184 | p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`; | ||
| 185 | } | ||
| 186 | |||
| 187 | const commit = view.commit ? String(view.commit) : ""; | ||
| 188 | p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; | ||
| 189 | |||
| 190 | if (view.seed && commit) { | ||
| 191 | const token = ++verifyToken; | ||
| 192 | const seed = String(view.seed); | ||
| 193 | const crashPoint = Number(view.crash_point ?? 0); | ||
| 194 | void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => { | ||
| 195 | if (token !== verifyToken || !parts) return; | ||
| 196 | const ok = commitOk && pointOk; | ||
| 197 | parts.verify.className = `verify ${ok ? "ok" : "bad"}`; | ||
| 198 | parts.verify.textContent = ok | ||
| 199 | ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed` | ||
| 200 | : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`; | ||
| 201 | }); | ||
| 202 | } else if (!live && !commit) { | ||
| 203 | p.verify.className = "verify"; | ||
| 204 | p.verify.textContent = "Waiting for a round commitment."; | ||
| 205 | } | ||
| 206 | |||
| 207 | if (frameHandle !== undefined) { | ||
| 208 | cancelAnimationFrame(frameHandle); | ||
| 209 | frameHandle = undefined; | ||
| 210 | } | ||
| 211 | if (running) { | ||
| 212 | const loop = () => { | ||
| 213 | if (!parts || current?.status !== "running") { | ||
| 214 | frameHandle = undefined; | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | update(parts, current, now); | ||
| 218 | frameHandle = requestAnimationFrame(loop); | ||
| 219 | }; | ||
| 220 | frameHandle = requestAnimationFrame(loop); | ||
| 221 | } else { | ||
| 222 | update(p, view, now); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | function currentMultiplier(view: View, now: Clock): number { | ||
| 227 | if (view.status === "running") { | ||
| 228 | const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000); | ||
| 229 | const local = Math.min(1000, Math.exp(GROWTH * elapsed)); | ||
| 230 | return Math.max(local, Number(view.multiplier ?? 1)); | ||
| 231 | } | ||
| 232 | if (view.status === "cashed") return Number(view.multiplier ?? 1); | ||
| 233 | if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1); | ||
| 234 | return 1; | ||
| 235 | } | ||
| 236 | |||
| 237 | function update(p: Parts, view: View, now: Clock): void { | ||
| 238 | const multiplier = currentMultiplier(view, now); | ||
| 239 | p.number.textContent = `${multiplier.toFixed(2)}×`; | ||
| 240 | p.number.className = `crash-number ${ | ||
| 241 | view.status === "running" | ||
| 242 | ? "running" | ||
| 243 | : view.status === "cashed" | ||
| 244 | ? "cashed" | ||
| 245 | : view.status === "crashed" | ||
| 246 | ? "crashed" | ||
| 247 | : "" | ||
| 248 | }`; | ||
| 249 | p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier)); | ||
| 250 | drawChart(p.canvas, view, multiplier, now); | ||
| 251 | } | ||
| 252 | |||
| 253 | function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void { | ||
| 254 | const width = canvas.clientWidth || 600; | ||
| 255 | const height = canvas.clientHeight || 360; | ||
| 256 | const dpr = window.devicePixelRatio || 1; | ||
| 257 | if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { | ||
| 258 | canvas.width = Math.round(width * dpr); | ||
| 259 | canvas.height = Math.round(height * dpr); | ||
| 260 | } | ||
| 261 | const ctx = canvas.getContext("2d"); | ||
| 262 | if (!ctx) return; | ||
| 263 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | ||
| 264 | ctx.clearRect(0, 0, width, height); | ||
| 265 | |||
| 266 | const padL = 54; | ||
| 267 | const padR = 16; | ||
| 268 | const padT = 18; | ||
| 269 | const padB = 34; | ||
| 270 | const plotW = Math.max(1, width - padL - padR); | ||
| 271 | const plotH = Math.max(1, height - padT - padB); | ||
| 272 | |||
| 273 | const elapsed = | ||
| 274 | view.status === "running" | ||
| 275 | ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000) | ||
| 276 | : multiplier > 1 | ||
| 277 | ? Math.log(multiplier) / GROWTH | ||
| 278 | : 1; | ||
| 279 | const xMax = Math.max(5, elapsed * 1.08); | ||
| 280 | const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2); | ||
| 281 | const x = (seconds: number) => padL + (seconds / xMax) * plotW; | ||
| 282 | const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH; | ||
| 283 | |||
| 284 | ctx.strokeStyle = "#242e38"; | ||
| 285 | ctx.fillStyle = "#8a98a6"; | ||
| 286 | ctx.font = "11px ui-monospace, monospace"; | ||
| 287 | ctx.lineWidth = 1; | ||
| 288 | for (let step = 0; step <= 4; step += 1) { | ||
| 289 | const value = 1 + ((yMax - 1) * step) / 4; | ||
| 290 | const py = y(value); | ||
| 291 | ctx.beginPath(); | ||
| 292 | ctx.moveTo(padL, py); | ||
| 293 | ctx.lineTo(width - padR, py); | ||
| 294 | ctx.stroke(); | ||
| 295 | ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); | ||
| 296 | } | ||
| 297 | for (let step = 0; step <= 4; step += 1) { | ||
| 298 | const seconds = (xMax * step) / 4; | ||
| 299 | const px = x(seconds); | ||
| 300 | ctx.beginPath(); | ||
| 301 | ctx.moveTo(px, padT); | ||
| 302 | ctx.lineTo(px, padT + plotH); | ||
| 303 | ctx.strokeStyle = "#1b232c"; | ||
| 304 | ctx.stroke(); | ||
| 305 | ctx.fillStyle = "#5b6773"; | ||
| 306 | ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); | ||
| 307 | } | ||
| 308 | |||
| 309 | const line = | ||
| 310 | view.status === "crashed" | ||
| 311 | ? "#e05a5a" | ||
| 312 | : view.status === "cashed" | ||
| 313 | ? "#4ec27b" | ||
| 314 | : "#e0b64a"; | ||
| 315 | |||
| 316 | ctx.beginPath(); | ||
| 317 | const samples = 180; | ||
| 318 | for (let index = 0; index <= samples; index += 1) { | ||
| 319 | const seconds = (elapsed * index) / samples; | ||
| 320 | const value = Math.min(yMax, Math.exp(GROWTH * seconds)); | ||
| 321 | const px = x(seconds); | ||
| 322 | const py = y(value); | ||
| 323 | if (index === 0) ctx.moveTo(px, py); | ||
| 324 | else ctx.lineTo(px, py); | ||
| 325 | } | ||
| 326 | ctx.strokeStyle = line; | ||
| 327 | ctx.lineWidth = 2.4; | ||
| 328 | ctx.stroke(); | ||
| 329 | |||
| 330 | const endX = x(elapsed); | ||
| 331 | const endY = y(Math.min(yMax, multiplier)); | ||
| 332 | ctx.beginPath(); | ||
| 333 | ctx.arc(endX, endY, 4.5, 0, Math.PI * 2); | ||
| 334 | ctx.fillStyle = line; | ||
| 335 | ctx.fill(); | ||
| 336 | |||
| 337 | ctx.fillStyle = line; | ||
| 338 | ctx.font = "600 12px ui-monospace, monospace"; | ||
| 339 | ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10)); | ||
| 340 | } | ||
| 341 | |||
| 342 | async function verifyReveal( | ||
| 343 | seed: string, | ||
| 344 | commit: string, | ||
| 345 | crashPoint: number, | ||
| 346 | ): Promise<[boolean, boolean]> { | ||
| 347 | const bytes = new Uint8Array(seed.length / 2); | ||
| 348 | for (let index = 0; index < bytes.length; index += 1) { | ||
| 349 | bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16); | ||
| 350 | } | ||
| 351 | const digest = await crypto.subtle.digest("SHA-256", bytes); | ||
| 352 | const actual = [...new Uint8Array(digest)] | ||
| 353 | .map((byte) => byte.toString(16).padStart(2, "0")) | ||
| 354 | .join(""); | ||
| 355 | const commitOk = actual === commit.toLowerCase(); | ||
| 356 | |||
| 357 | let uniform = 0; | ||
| 358 | for (let index = 0; index < 8; index += 1) { | ||
| 359 | uniform = uniform * 256 + bytes[index]; | ||
| 360 | } | ||
| 361 | uniform /= 2 ** 64; | ||
| 362 | const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform))); | ||
| 363 | const pointOk = Math.abs(point - crashPoint) < 1e-6; | ||
| 364 | return [commitOk, pointOk]; | ||
| 365 | } | ||
diff --git a/web/play/src/dom.ts b/web/play/src/dom.ts new file mode 100644 index 0000000..d8ed212 --- /dev/null +++ b/web/play/src/dom.ts | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | export function byId<T extends HTMLElement = HTMLElement>(id: string): T { | ||
| 2 | const node = document.getElementById(id); | ||
| 3 | if (!node) throw new Error(`missing #${id}`); | ||
| 4 | return node as T; | ||
| 5 | } | ||
| 6 | |||
| 7 | export function node<K extends keyof HTMLElementTagNameMap>( | ||
| 8 | tag: K, | ||
| 9 | className?: string, | ||
| 10 | text?: string, | ||
| 11 | ): HTMLElementTagNameMap[K] { | ||
| 12 | const element = document.createElement(tag); | ||
| 13 | if (className) element.className = className; | ||
| 14 | if (text !== undefined) element.textContent = text; | ||
| 15 | return element; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function clear(element: HTMLElement): void { | ||
| 19 | element.replaceChildren(); | ||
| 20 | } | ||
| 21 | |||
| 22 | export function groupDigits(amount: number): string { | ||
| 23 | const negative = amount < 0; | ||
| 24 | const digits = String(Math.abs(Math.trunc(amount))); | ||
| 25 | let grouped = ""; | ||
| 26 | for (let index = 0; index < digits.length; index += 1) { | ||
| 27 | if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009"; | ||
| 28 | grouped += digits[index]; | ||
| 29 | } | ||
| 30 | return negative ? `-${grouped}` : grouped; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function money(amount: number | null | undefined): string { | ||
| 34 | if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr"; | ||
| 35 | return `${groupDigits(amount)} Kr`; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function signedMoney(amount: number): string { | ||
| 39 | return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; | ||
| 40 | } | ||
| 41 | |||
| 42 | export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void { | ||
| 43 | const root = document.getElementById("toasts"); | ||
| 44 | if (!root || !text) return; | ||
| 45 | const item = node("div", `toast ${tone}`, text); | ||
| 46 | root.append(item); | ||
| 47 | window.setTimeout(() => { | ||
| 48 | item.style.opacity = "0"; | ||
| 49 | item.style.transition = "opacity 220ms ease"; | ||
| 50 | window.setTimeout(() => item.remove(), 240); | ||
| 51 | }, 4200); | ||
| 52 | while (root.children.length > 4) root.firstElementChild?.remove(); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function toneFor(text: string): "info" | "good" | "bad" { | ||
| 56 | const lowered = text.toLowerCase(); | ||
| 57 | if ( | ||
| 58 | text.startsWith("ERROR") || | ||
| 59 | lowered.includes("failed") || | ||
| 60 | lowered.includes("lost") || | ||
| 61 | lowered.includes("invalid") || | ||
| 62 | lowered.includes("forfeit") | ||
| 63 | ) { | ||
| 64 | return "bad"; | ||
| 65 | } | ||
| 66 | if ( | ||
| 67 | text.startsWith("Logged in") || | ||
| 68 | text.startsWith("Account created") || | ||
| 69 | lowered.includes("won") || | ||
| 70 | lowered.includes("cashed") | ||
| 71 | ) { | ||
| 72 | return "good"; | ||
| 73 | } | ||
| 74 | return "info"; | ||
| 75 | } | ||
| 76 | |||
| 77 | export async function sha256Hex(text: string): Promise<string> { | ||
| 78 | const bytes = new TextEncoder().encode(text); | ||
| 79 | const digest = await crypto.subtle.digest("SHA-256", bytes); | ||
| 80 | return [...new Uint8Array(digest)] | ||
| 81 | .map((byte) => byte.toString(16).padStart(2, "0")) | ||
| 82 | .join(""); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function shortHash(hash: string, keep = 10): string { | ||
| 86 | if (hash.length <= keep * 2) return hash; | ||
| 87 | return `${hash.slice(0, keep)}…${hash.slice(-keep)}`; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function formatTime(raw: string): string { | ||
| 91 | // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC. | ||
| 92 | const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z"); | ||
| 93 | if (Number.isNaN(parsed)) return raw; | ||
| 94 | return new Date(parsed).toLocaleString(); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function cardFace(card: string): { rank: string; suit: string; red: boolean } { | ||
| 98 | if (card === "?" || !card) return { rank: "?", suit: "", red: false }; | ||
| 99 | const suit = card.slice(-1); | ||
| 100 | const rank = card.slice(0, -1); | ||
| 101 | const glyph = | ||
| 102 | suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit; | ||
| 103 | return { rank, suit: glyph, red: suit === "D" || suit === "H" }; | ||
| 104 | } | ||
diff --git a/web/play/src/history.ts b/web/play/src/history.ts new file mode 100644 index 0000000..9e7a564 --- /dev/null +++ b/web/play/src/history.ts | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, formatTime, money, node, signedMoney } from "./dom"; | ||
| 3 | |||
| 4 | function summary(record: any): string { | ||
| 5 | const details = record.details ?? {}; | ||
| 6 | switch (record.game) { | ||
| 7 | case "slots": | ||
| 8 | return String(details.win_type ?? "spin"); | ||
| 9 | case "roulette": | ||
| 10 | return `${details.number ?? "?"} ${details.color ?? ""}`.trim(); | ||
| 11 | case "blackjack": | ||
| 12 | return String(details.result ?? ""); | ||
| 13 | case "crash": { | ||
| 14 | const result = String(details.result ?? ""); | ||
| 15 | const point = details.crash_point; | ||
| 16 | return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`; | ||
| 17 | } | ||
| 18 | default: | ||
| 19 | return ""; | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | export function renderHistory(root: HTMLElement, view: View): void { | ||
| 24 | clear(root); | ||
| 25 | const records: any[] = view.records ?? []; | ||
| 26 | if (records.length === 0) { | ||
| 27 | const empty = node("div", "board"); | ||
| 28 | empty.append( | ||
| 29 | node("h2", undefined, "No plays yet"), | ||
| 30 | node("p", "muted", "Your settled plays will appear here."), | ||
| 31 | ); | ||
| 32 | root.append(empty); | ||
| 33 | return; | ||
| 34 | } | ||
| 35 | |||
| 36 | const wrap = node("div", "table-wrap"); | ||
| 37 | const table = node("table"); | ||
| 38 | const head = node("thead"); | ||
| 39 | const headRow = node("tr"); | ||
| 40 | for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { | ||
| 41 | headRow.append(node("th", undefined, label)); | ||
| 42 | } | ||
| 43 | head.append(headRow); | ||
| 44 | const body = node("tbody"); | ||
| 45 | for (const record of records) { | ||
| 46 | const row = node("tr"); | ||
| 47 | row.append(node("td", "mono", formatTime(String(record.at ?? "")))); | ||
| 48 | row.append(node("td", undefined, String(record.game ?? ""))); | ||
| 49 | row.append(node("td", "num", money(Number(record.bet ?? 0)))); | ||
| 50 | row.append(node("td", "muted", summary(record))); | ||
| 51 | const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); | ||
| 52 | const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net)); | ||
| 53 | row.append(netCell); | ||
| 54 | row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); | ||
| 55 | body.append(row); | ||
| 56 | } | ||
| 57 | table.append(head, body); | ||
| 58 | wrap.append(table); | ||
| 59 | root.append(wrap); | ||
| 60 | } | ||
diff --git a/web/play/src/main.ts b/web/play/src/main.ts new file mode 100644 index 0000000..fd55250 --- /dev/null +++ b/web/play/src/main.ts | |||
| @@ -0,0 +1,323 @@ | |||
| 1 | import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; | ||
| 2 | import { Gateway } from "./net"; | ||
| 3 | import { renderBlackjack } from "./blackjack"; | ||
| 4 | import { renderCrash } from "./crash"; | ||
| 5 | import { renderHistory } from "./history"; | ||
| 6 | import { renderRoulette } from "./roulette"; | ||
| 7 | import { renderSlots } from "./slots"; | ||
| 8 | import type { View } from "./types"; | ||
| 9 | |||
| 10 | const authScreen = byId("auth-screen"); | ||
| 11 | const lobbyScreen = byId("lobby-screen"); | ||
| 12 | const gameScreen = byId("game-screen"); | ||
| 13 | const historyScreen = byId("history-screen"); | ||
| 14 | const sessionBar = byId("session-bar"); | ||
| 15 | const accountName = byId("account-name"); | ||
| 16 | const accountBalance = byId("account-balance"); | ||
| 17 | const gameGrid = byId("game-grid"); | ||
| 18 | const gameBoard = byId("game-board"); | ||
| 19 | const gameSide = byId("game-side"); | ||
| 20 | const gameTitle = byId("game-title"); | ||
| 21 | const gameBadge = byId("game-badge"); | ||
| 22 | const historyBody = byId("history-body"); | ||
| 23 | const authForm = byId<HTMLFormElement>("auth-form"); | ||
| 24 | const authUser = byId<HTMLInputElement>("auth-user"); | ||
| 25 | const authPass = byId<HTMLInputElement>("auth-pass"); | ||
| 26 | const authError = byId("auth-error"); | ||
| 27 | const authSubmit = byId<HTMLButtonElement>("auth-submit"); | ||
| 28 | const tabLogin = byId<HTMLButtonElement>("tab-login"); | ||
| 29 | const tabRegister = byId<HTMLButtonElement>("tab-register"); | ||
| 30 | const reconnectOverlay = byId("reconnect-overlay"); | ||
| 31 | const reconnectMessage = byId("reconnect-message"); | ||
| 32 | const replacedOverlay = byId("replaced-overlay"); | ||
| 33 | |||
| 34 | let view: View | null = null; | ||
| 35 | let currentUser: string | null = null; | ||
| 36 | let lastBalance: number | null = null; | ||
| 37 | let authMode: "login" | "register" = "login"; | ||
| 38 | let infoFor: string | null = null; | ||
| 39 | let serverOffset = 0; | ||
| 40 | let reconnectAttempts = 0; | ||
| 41 | const bets = new Map<string, number>([ | ||
| 42 | ["slots", 10], | ||
| 43 | ["roulette", 10], | ||
| 44 | ["blackjack", 10], | ||
| 45 | ["crash", 10], | ||
| 46 | ]); | ||
| 47 | |||
| 48 | const titles: Record<string, string> = { | ||
| 49 | slots: "Slots", | ||
| 50 | roulette: "Roulette", | ||
| 51 | blackjack: "Blackjack", | ||
| 52 | crash: "Crash", | ||
| 53 | }; | ||
| 54 | |||
| 55 | const descriptions: Record<string, string> = { | ||
| 56 | slots: "Five reels, five paylines. Match symbols from the left.", | ||
| 57 | roulette: "European single-zero wheel with inside and outside bets.", | ||
| 58 | blackjack: "Six-deck shoe. Blackjack pays 3:2.", | ||
| 59 | crash: "A rising multiplier. Cash out before it crashes.", | ||
| 60 | }; | ||
| 61 | |||
| 62 | const basePath = location.pathname.includes("/play/") | ||
| 63 | ? location.pathname.slice(0, location.pathname.indexOf("/play/")) | ||
| 64 | : ""; | ||
| 65 | const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; | ||
| 66 | |||
| 67 | const gateway = new Gateway(wsUrl, { | ||
| 68 | onHello(hello) { | ||
| 69 | serverOffset = hello.time - Date.now(); | ||
| 70 | currentUser = hello.user ?? null; | ||
| 71 | gameBadge.textContent = `v${hello.version}`; | ||
| 72 | }, | ||
| 73 | onView(next) { | ||
| 74 | view = next; | ||
| 75 | // Game and history views carry no account field; the lobby is the source | ||
| 76 | // of truth for who is signed in. A null lobby user is a logout or an | ||
| 77 | // expired session. | ||
| 78 | if (typeof next.balance === "number") lastBalance = next.balance; | ||
| 79 | if (next.screen === "lobby" && "user" in next) { | ||
| 80 | currentUser = (next.user as string | null) ?? null; | ||
| 81 | if (currentUser) authPass.value = ""; | ||
| 82 | else lastBalance = null; | ||
| 83 | } | ||
| 84 | reconnectAttempts = 0; | ||
| 85 | reconnectOverlay.hidden = true; | ||
| 86 | render(); | ||
| 87 | if (currentUser && view.balance === null && infoFor !== currentUser) { | ||
| 88 | infoFor = currentUser; | ||
| 89 | gateway.send("Info"); | ||
| 90 | } | ||
| 91 | if (!currentUser) infoFor = null; | ||
| 92 | }, | ||
| 93 | onNotice(text) { | ||
| 94 | if ( | ||
| 95 | text.startsWith("Connected to:") || | ||
| 96 | text.startsWith("Fingerprint:") || | ||
| 97 | text.startsWith("User:") || | ||
| 98 | text.startsWith("Account:") || | ||
| 99 | text.startsWith("Balance:") | ||
| 100 | ) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | toast(text, toneFor(text)); | ||
| 104 | if (text.startsWith("Account created")) { | ||
| 105 | setAuthMode("login"); | ||
| 106 | authPass.value = ""; | ||
| 107 | authUser.focus(); | ||
| 108 | } | ||
| 109 | authSubmit.disabled = false; | ||
| 110 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 111 | }, | ||
| 112 | onError(text) { | ||
| 113 | toast(text, "bad"); | ||
| 114 | authSubmit.disabled = false; | ||
| 115 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 116 | }, | ||
| 117 | onReplaced() { | ||
| 118 | replacedOverlay.hidden = false; | ||
| 119 | }, | ||
| 120 | onPong() { | ||
| 121 | /* The gateway answered; the socket is alive. */ | ||
| 122 | }, | ||
| 123 | onOpen() { | ||
| 124 | reconnectOverlay.hidden = true; | ||
| 125 | }, | ||
| 126 | onClosed(willRetry) { | ||
| 127 | if (replacedOverlay.hidden === false) return; | ||
| 128 | if (!willRetry) { | ||
| 129 | reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; | ||
| 130 | reconnectOverlay.hidden = false; | ||
| 131 | return; | ||
| 132 | } | ||
| 133 | reconnectAttempts += 1; | ||
| 134 | reconnectMessage.textContent = | ||
| 135 | reconnectAttempts > 1 | ||
| 136 | ? "Still trying to reach the game server…" | ||
| 137 | : "Trying to reach the game server…"; | ||
| 138 | reconnectOverlay.hidden = false; | ||
| 139 | }, | ||
| 140 | }); | ||
| 141 | |||
| 142 | function send(command: unknown): void { | ||
| 143 | gateway.send(command); | ||
| 144 | } | ||
| 145 | |||
| 146 | function serverNow(): number { | ||
| 147 | return Date.now() + serverOffset; | ||
| 148 | } | ||
| 149 | |||
| 150 | function setAuthMode(mode: "login" | "register"): void { | ||
| 151 | authMode = mode; | ||
| 152 | tabLogin.classList.toggle("active", mode === "login"); | ||
| 153 | tabRegister.classList.toggle("active", mode === "register"); | ||
| 154 | authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; | ||
| 155 | authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; | ||
| 156 | authError.textContent = ""; | ||
| 157 | } | ||
| 158 | |||
| 159 | function show(screen: "auth" | "lobby" | "game" | "history"): void { | ||
| 160 | authScreen.hidden = screen !== "auth"; | ||
| 161 | lobbyScreen.hidden = screen !== "lobby"; | ||
| 162 | gameScreen.hidden = screen !== "game"; | ||
| 163 | historyScreen.hidden = screen !== "history"; | ||
| 164 | } | ||
| 165 | |||
| 166 | function setBalance(balance: number | null | undefined): void { | ||
| 167 | accountBalance.textContent = money(balance ?? null); | ||
| 168 | } | ||
| 169 | |||
| 170 | function render(): void { | ||
| 171 | if (!view) return; | ||
| 172 | const user = currentUser; | ||
| 173 | if (user) { | ||
| 174 | sessionBar.hidden = false; | ||
| 175 | accountName.textContent = String(user); | ||
| 176 | } else { | ||
| 177 | sessionBar.hidden = true; | ||
| 178 | } | ||
| 179 | |||
| 180 | setBalance(lastBalance); | ||
| 181 | |||
| 182 | if (!user) { | ||
| 183 | show("auth"); | ||
| 184 | authError.textContent = view.login_error ? String(view.login_error) : ""; | ||
| 185 | if (view.screen === "lobby" && view.connected === false) { | ||
| 186 | authError.textContent = "The game server is not reachable yet. Retrying…"; | ||
| 187 | } | ||
| 188 | return; | ||
| 189 | } | ||
| 190 | |||
| 191 | switch (view.screen) { | ||
| 192 | case "lobby": | ||
| 193 | show("lobby"); | ||
| 194 | renderLobby(view); | ||
| 195 | break; | ||
| 196 | case "history": | ||
| 197 | show("history"); | ||
| 198 | renderHistory(historyBody, view); | ||
| 199 | break; | ||
| 200 | case "slots": | ||
| 201 | case "roulette": | ||
| 202 | case "blackjack": | ||
| 203 | case "crash": | ||
| 204 | show("game"); | ||
| 205 | gameTitle.textContent = titles[view.screen] ?? view.screen; | ||
| 206 | gameBadge.textContent = badge(view); | ||
| 207 | if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); | ||
| 208 | else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); | ||
| 209 | else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); | ||
| 210 | else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); | ||
| 211 | break; | ||
| 212 | default: | ||
| 213 | show("auth"); | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | function badge(view: View): string { | ||
| 218 | if (view.screen === "crash") return String(view.status ?? ""); | ||
| 219 | if (view.screen === "blackjack") return String(view.status ?? ""); | ||
| 220 | if (view.screen === "slots") return String(view.phase ?? ""); | ||
| 221 | if (view.screen === "roulette") return String(view.phase ?? ""); | ||
| 222 | return ""; | ||
| 223 | } | ||
| 224 | |||
| 225 | function renderLobby(lobby: View): void { | ||
| 226 | gameGrid.replaceChildren(); | ||
| 227 | const list: [string, string][] = lobby.games ?? []; | ||
| 228 | for (const [id, name] of list) { | ||
| 229 | const card = node("article", `game-card ${id}`); | ||
| 230 | card.append(node("h3", undefined, name || titles[id] || id)); | ||
| 231 | card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); | ||
| 232 | |||
| 233 | const row = node("div", "row wrap"); | ||
| 234 | const label = node("label", "field"); | ||
| 235 | label.style.flex = "1"; | ||
| 236 | label.append(node("span", "label", "Bet")); | ||
| 237 | const input = node("input", "input bet-input") as HTMLInputElement; | ||
| 238 | input.type = "number"; | ||
| 239 | input.min = "1"; | ||
| 240 | input.step = "10"; | ||
| 241 | input.value = String(bets.get(id) ?? 10); | ||
| 242 | input.addEventListener("change", () => { | ||
| 243 | const value = Math.max(1, Math.floor(Number(input.value) || 1)); | ||
| 244 | input.value = String(value); | ||
| 245 | bets.set(id, value); | ||
| 246 | }); | ||
| 247 | label.append(input); | ||
| 248 | |||
| 249 | const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); | ||
| 250 | play.addEventListener("click", () => { | ||
| 251 | const bet = Math.max(1, Math.floor(Number(input.value) || 1)); | ||
| 252 | bets.set(id, bet); | ||
| 253 | send({ Play: { game: id, bet } }); | ||
| 254 | }); | ||
| 255 | row.append(label, play); | ||
| 256 | card.append(row); | ||
| 257 | gameGrid.append(card); | ||
| 258 | } | ||
| 259 | |||
| 260 | if (list.length === 0) { | ||
| 261 | gameGrid.append(node("p", "muted", "The server did not advertise any games.")); | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | // Auth tabs and form ------------------------------------------------------- | ||
| 266 | tabLogin.addEventListener("click", () => setAuthMode("login")); | ||
| 267 | tabRegister.addEventListener("click", () => setAuthMode("register")); | ||
| 268 | |||
| 269 | authForm.addEventListener("submit", async (event) => { | ||
| 270 | event.preventDefault(); | ||
| 271 | const user = authUser.value.trim(); | ||
| 272 | const password = authPass.value; | ||
| 273 | if (!user || !password) { | ||
| 274 | authError.textContent = "Enter a username and a password."; | ||
| 275 | return; | ||
| 276 | } | ||
| 277 | authSubmit.disabled = true; | ||
| 278 | authSubmit.textContent = "Working…"; | ||
| 279 | authError.textContent = ""; | ||
| 280 | try { | ||
| 281 | const credential = await sha256Hex(password); | ||
| 282 | if (authMode === "login") { | ||
| 283 | send({ Login: { user, credential } }); | ||
| 284 | } else { | ||
| 285 | send({ Register: { user, credential } }); | ||
| 286 | } | ||
| 287 | } catch { | ||
| 288 | authError.textContent = "This browser cannot hash the password (needs a secure context)."; | ||
| 289 | authSubmit.disabled = false; | ||
| 290 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 291 | } | ||
| 292 | }); | ||
| 293 | |||
| 294 | // Chrome and navigation ---------------------------------------------------- | ||
| 295 | byId("logout").addEventListener("click", () => send("Logout")); | ||
| 296 | byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); | ||
| 297 | byId("refresh-lobby").addEventListener("click", () => send("Info")); | ||
| 298 | byId("history-back").addEventListener("click", () => send("ShowLobby")); | ||
| 299 | byId("leave-game").addEventListener("click", () => { | ||
| 300 | if (!view) return; | ||
| 301 | const live = | ||
| 302 | (view.screen === "blackjack" && view.status === "player") || | ||
| 303 | (view.screen === "crash" && view.status === "running"); | ||
| 304 | if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; | ||
| 305 | send("Leave"); | ||
| 306 | }); | ||
| 307 | byId("reload-replaced").addEventListener("click", () => location.reload()); | ||
| 308 | byId("reload-reconnect").addEventListener("click", () => location.reload()); | ||
| 309 | |||
| 310 | window.addEventListener("resize", () => { | ||
| 311 | window.clearTimeout((window as any).__resizeTimer); | ||
| 312 | (window as any).__resizeTimer = window.setTimeout(() => { | ||
| 313 | if (view) render(); | ||
| 314 | }, 140); | ||
| 315 | }); | ||
| 316 | |||
| 317 | // A periodic ping keeps the gateway session visibly alive and refreshes the | ||
| 318 | // server clock estimate without changing game state. | ||
| 319 | window.setInterval(() => { | ||
| 320 | if (currentUser) gateway.ping(); | ||
| 321 | }, 25_000); | ||
| 322 | |||
| 323 | 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 @@ | |||
| 1 | import type { Handlers } from "./types"; | ||
| 2 | |||
| 3 | const MAX_ATTEMPTS = 6; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Browser side of the gateway socket. | ||
| 7 | * | ||
| 8 | * The cookie is HttpOnly and handled by the browser; this class never reads or | ||
| 9 | * stores a credential. A transient close reconnects inside the gateway's grace | ||
| 10 | * window; a deliberate takeover stops for good. | ||
| 11 | */ | ||
| 12 | export class Gateway { | ||
| 13 | private socket: WebSocket | null = null; | ||
| 14 | private stopped = false; | ||
| 15 | private replaced = false; | ||
| 16 | private attempts = 0; | ||
| 17 | private timer: number | undefined; | ||
| 18 | |||
| 19 | constructor( | ||
| 20 | private readonly url: string, | ||
| 21 | private readonly handlers: Handlers, | ||
| 22 | ) {} | ||
| 23 | |||
| 24 | connect(): void { | ||
| 25 | if (this.stopped) return; | ||
| 26 | this.clearTimer(); | ||
| 27 | |||
| 28 | let socket: WebSocket; | ||
| 29 | try { | ||
| 30 | socket = new WebSocket(this.url); | ||
| 31 | } catch { | ||
| 32 | const retrying = this.scheduleReconnect(); | ||
| 33 | if (!retrying) this.handlers.onClosed(false); | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | this.socket = socket; | ||
| 37 | |||
| 38 | socket.addEventListener("open", () => { | ||
| 39 | this.attempts = 0; | ||
| 40 | this.handlers.onOpen(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | socket.addEventListener("message", (event) => { | ||
| 44 | let message: any; | ||
| 45 | try { | ||
| 46 | message = JSON.parse(String(event.data)); | ||
| 47 | } catch { | ||
| 48 | return; | ||
| 49 | } | ||
| 50 | switch (message.type) { | ||
| 51 | case "hello": | ||
| 52 | this.handlers.onHello(message); | ||
| 53 | break; | ||
| 54 | case "view": | ||
| 55 | this.handlers.onView(message.view); | ||
| 56 | break; | ||
| 57 | case "notice": | ||
| 58 | this.handlers.onNotice(String(message.text ?? "")); | ||
| 59 | break; | ||
| 60 | case "error": | ||
| 61 | this.handlers.onError(String(message.text ?? "")); | ||
| 62 | break; | ||
| 63 | case "replaced": | ||
| 64 | this.replaced = true; | ||
| 65 | this.handlers.onReplaced(); | ||
| 66 | break; | ||
| 67 | case "pong": | ||
| 68 | this.handlers.onPong(); | ||
| 69 | break; | ||
| 70 | } | ||
| 71 | }); | ||
| 72 | |||
| 73 | socket.addEventListener("close", () => { | ||
| 74 | const wasThisSocket = this.socket === socket; | ||
| 75 | if (wasThisSocket) this.socket = null; | ||
| 76 | if (this.replaced || this.stopped) { | ||
| 77 | this.handlers.onClosed(false); | ||
| 78 | return; | ||
| 79 | } | ||
| 80 | const retrying = this.scheduleReconnect(); | ||
| 81 | this.handlers.onClosed(retrying); | ||
| 82 | }); | ||
| 83 | |||
| 84 | socket.addEventListener("error", () => { | ||
| 85 | // The close event follows; reconnect is scheduled there. | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | send(command: unknown): void { | ||
| 90 | if (this.socket?.readyState !== WebSocket.OPEN) return; | ||
| 91 | this.socket.send(JSON.stringify({ type: "command", command })); | ||
| 92 | } | ||
| 93 | |||
| 94 | ping(): void { | ||
| 95 | if (this.socket?.readyState === WebSocket.OPEN) { | ||
| 96 | this.socket.send(JSON.stringify({ type: "ping" })); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | close(): void { | ||
| 101 | this.stopped = true; | ||
| 102 | this.clearTimer(); | ||
| 103 | this.socket?.close(); | ||
| 104 | } | ||
| 105 | |||
| 106 | private scheduleReconnect(): boolean { | ||
| 107 | if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false; | ||
| 108 | this.attempts += 1; | ||
| 109 | const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000); | ||
| 110 | this.timer = window.setTimeout(() => this.connect(), delay); | ||
| 111 | return true; | ||
| 112 | } | ||
| 113 | |||
| 114 | private clearTimer(): void { | ||
| 115 | if (this.timer !== undefined) { | ||
| 116 | window.clearTimeout(this.timer); | ||
| 117 | this.timer = undefined; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts new file mode 100644 index 0000000..cbb7c6e --- /dev/null +++ b/web/play/src/roulette.ts | |||
| @@ -0,0 +1,474 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | type Zone = { | ||
| 5 | x: number; | ||
| 6 | y: number; | ||
| 7 | w: number; | ||
| 8 | h: number; | ||
| 9 | target: any; | ||
| 10 | label: string; | ||
| 11 | kind: "number" | "zero" | "dozen" | "column" | "outside"; | ||
| 12 | number?: number; | ||
| 13 | }; | ||
| 14 | |||
| 15 | type Hotspot = { | ||
| 16 | x: number; | ||
| 17 | y: number; | ||
| 18 | radius: number; | ||
| 19 | target: any; | ||
| 20 | rank: number; | ||
| 21 | }; | ||
| 22 | |||
| 23 | type Layout = { | ||
| 24 | width: number; | ||
| 25 | height: number; | ||
| 26 | cellW: number; | ||
| 27 | cellH: number; | ||
| 28 | zeroX: number; | ||
| 29 | zeroW: number; | ||
| 30 | gridX: number; | ||
| 31 | gridY: number; | ||
| 32 | gridW: number; | ||
| 33 | columnX: number; | ||
| 34 | dozensY: number; | ||
| 35 | outsideY: number; | ||
| 36 | outsideH: number; | ||
| 37 | zones: Zone[]; | ||
| 38 | hotspots: Hotspot[]; | ||
| 39 | }; | ||
| 40 | |||
| 41 | const RED = "#b0313a"; | ||
| 42 | const BLACK = "#151b21"; | ||
| 43 | const ZERO = "#106b45"; | ||
| 44 | const FELT = "#0d5c39"; | ||
| 45 | const FELT_DARK = "#09472c"; | ||
| 46 | const FELT_LINE = "#1f7d52"; | ||
| 47 | const TEXT = "#ecf7f0"; | ||
| 48 | const GOLD = "#e0b64a"; | ||
| 49 | |||
| 50 | function colorOf(number: number): string { | ||
| 51 | if (number === 0) return "green"; | ||
| 52 | const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; | ||
| 53 | return reds.includes(number) ? "red" : "black"; | ||
| 54 | } | ||
| 55 | |||
| 56 | function layout(width: number, height: number): Layout { | ||
| 57 | const pad = 14; | ||
| 58 | const zeroW = Math.max(38, width * 0.055); | ||
| 59 | const columnW = Math.max(34, width * 0.05); | ||
| 60 | const outsideH = Math.max(30, height * 0.07); | ||
| 61 | const gap = 6; | ||
| 62 | const gridW = width - pad * 2 - zeroW - columnW; | ||
| 63 | const cellW = gridW / 12; | ||
| 64 | const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; | ||
| 65 | const gridX = pad + zeroW; | ||
| 66 | const gridY = pad; | ||
| 67 | const dozensY = gridY + cellH * 3 + gap; | ||
| 68 | const outsideY = dozensY + outsideH + gap; | ||
| 69 | const zones: Zone[] = []; | ||
| 70 | |||
| 71 | for (let column = 0; column < 12; column += 1) { | ||
| 72 | for (let row = 0; row < 3; row += 1) { | ||
| 73 | const number = column * 3 + (3 - row); | ||
| 74 | zones.push({ | ||
| 75 | x: gridX + column * cellW, | ||
| 76 | y: gridY + row * cellH, | ||
| 77 | w: cellW, | ||
| 78 | h: cellH, | ||
| 79 | target: { bet: "straight", number }, | ||
| 80 | label: String(number), | ||
| 81 | kind: "number", | ||
| 82 | number, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | zones.push({ | ||
| 88 | x: pad, | ||
| 89 | y: gridY, | ||
| 90 | w: zeroW, | ||
| 91 | h: cellH * 3, | ||
| 92 | target: { bet: "straight", number: 0 }, | ||
| 93 | label: "0", | ||
| 94 | kind: "zero", | ||
| 95 | number: 0, | ||
| 96 | }); | ||
| 97 | |||
| 98 | for (let top = 0; top < 3; top += 1) { | ||
| 99 | const index = 2 - top; | ||
| 100 | zones.push({ | ||
| 101 | x: gridX + gridW, | ||
| 102 | y: gridY + top * cellH, | ||
| 103 | w: columnW, | ||
| 104 | h: cellH, | ||
| 105 | target: { bet: "column", index }, | ||
| 106 | label: "2:1", | ||
| 107 | kind: "column", | ||
| 108 | }); | ||
| 109 | } | ||
| 110 | |||
| 111 | for (let index = 0; index < 3; index += 1) { | ||
| 112 | zones.push({ | ||
| 113 | x: gridX + (gridW / 3) * index, | ||
| 114 | y: dozensY, | ||
| 115 | w: gridW / 3, | ||
| 116 | h: outsideH, | ||
| 117 | target: { bet: "dozen", index }, | ||
| 118 | label: ["1st 12", "2nd 12", "3rd 12"][index], | ||
| 119 | kind: "dozen", | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | |||
| 123 | const outside = [ | ||
| 124 | { kind: "low", label: "1–18" }, | ||
| 125 | { kind: "even", label: "EVEN" }, | ||
| 126 | { kind: "red", label: "RED" }, | ||
| 127 | { kind: "black", label: "BLACK" }, | ||
| 128 | { kind: "odd", label: "ODD" }, | ||
| 129 | { kind: "high", label: "19–36" }, | ||
| 130 | ]; | ||
| 131 | outside.forEach((item, index) => { | ||
| 132 | zones.push({ | ||
| 133 | x: gridX + (gridW / 6) * index, | ||
| 134 | y: outsideY, | ||
| 135 | w: gridW / 6, | ||
| 136 | h: outsideH, | ||
| 137 | target: { bet: "outside", kind: item.kind }, | ||
| 138 | label: item.label, | ||
| 139 | kind: "outside", | ||
| 140 | }); | ||
| 141 | }); | ||
| 142 | |||
| 143 | // Split and corner hotspots sit on the lines between cells, like the real | ||
| 144 | // felt (and the desktop fine lattice). Corners rank ahead of splits. | ||
| 145 | const hotspots: Hotspot[] = []; | ||
| 146 | const cellCenter = (number: number) => { | ||
| 147 | const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); | ||
| 148 | return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; | ||
| 149 | }; | ||
| 150 | |||
| 151 | for (let column = 0; column < 11; column += 1) { | ||
| 152 | for (let row = 0; row < 2; row += 1) { | ||
| 153 | const top = column * 3 + (3 - row); | ||
| 154 | hotspots.push({ | ||
| 155 | x: gridX + (column + 1) * cellW, | ||
| 156 | y: gridY + (row + 1) * cellH, | ||
| 157 | radius: 12, | ||
| 158 | rank: 0, | ||
| 159 | target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | for (let column = 0; column < 12; column += 1) { | ||
| 165 | for (let row = 0; row < 2; row += 1) { | ||
| 166 | const top = column * 3 + (3 - row); | ||
| 167 | const center = cellCenter(top); | ||
| 168 | if (!center) continue; | ||
| 169 | hotspots.push({ | ||
| 170 | x: center.x, | ||
| 171 | y: gridY + (row + 1) * cellH, | ||
| 172 | radius: 10, | ||
| 173 | rank: 1, | ||
| 174 | target: { bet: "split", a: top, b: top - 1 }, | ||
| 175 | }); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | for (let column = 0; column < 11; column += 1) { | ||
| 180 | for (let row = 0; row < 3; row += 1) { | ||
| 181 | const left = column * 3 + (3 - row); | ||
| 182 | hotspots.push({ | ||
| 183 | x: gridX + (column + 1) * cellW, | ||
| 184 | y: gridY + row * cellH + cellH / 2, | ||
| 185 | radius: 10, | ||
| 186 | rank: 1, | ||
| 187 | target: { bet: "split", a: left, b: left + 3 }, | ||
| 188 | }); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | for (let row = 0; row < 3; row += 1) { | ||
| 193 | const number = 3 - row; | ||
| 194 | hotspots.push({ | ||
| 195 | x: gridX, | ||
| 196 | y: gridY + row * cellH + cellH / 2, | ||
| 197 | radius: 10, | ||
| 198 | rank: 1, | ||
| 199 | target: { bet: "split", a: 0, b: number }, | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | return { | ||
| 204 | width, | ||
| 205 | height, | ||
| 206 | cellW, | ||
| 207 | cellH, | ||
| 208 | zeroX: pad, | ||
| 209 | zeroW, | ||
| 210 | gridX, | ||
| 211 | gridY, | ||
| 212 | gridW, | ||
| 213 | columnX: gridX + gridW, | ||
| 214 | dozensY, | ||
| 215 | outsideY, | ||
| 216 | outsideH, | ||
| 217 | zones, | ||
| 218 | hotspots, | ||
| 219 | }; | ||
| 220 | } | ||
| 221 | |||
| 222 | function roundRect( | ||
| 223 | ctx: CanvasRenderingContext2D, | ||
| 224 | x: number, | ||
| 225 | y: number, | ||
| 226 | w: number, | ||
| 227 | h: number, | ||
| 228 | radius: number, | ||
| 229 | ): void { | ||
| 230 | const r = Math.min(radius, w / 2, h / 2); | ||
| 231 | ctx.beginPath(); | ||
| 232 | ctx.moveTo(x + r, y); | ||
| 233 | ctx.arcTo(x + w, y, x + w, y + h, r); | ||
| 234 | ctx.arcTo(x + w, y + h, x, y + h, r); | ||
| 235 | ctx.arcTo(x, y + h, x, y, r); | ||
| 236 | ctx.arcTo(x, y, x + w, y, r); | ||
| 237 | ctx.closePath(); | ||
| 238 | } | ||
| 239 | |||
| 240 | function draw(canvas: HTMLCanvasElement, view: View): Layout { | ||
| 241 | const width = canvas.clientWidth || 800; | ||
| 242 | const height = canvas.clientHeight || 540; | ||
| 243 | const dpr = window.devicePixelRatio || 1; | ||
| 244 | if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { | ||
| 245 | canvas.width = Math.round(width * dpr); | ||
| 246 | canvas.height = Math.round(height * dpr); | ||
| 247 | } | ||
| 248 | const ctx = canvas.getContext("2d"); | ||
| 249 | const map = layout(width, height); | ||
| 250 | if (!ctx) return map; | ||
| 251 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | ||
| 252 | ctx.clearRect(0, 0, width, height); | ||
| 253 | ctx.fillStyle = FELT_DARK; | ||
| 254 | roundRect(ctx, 0, 0, width, height, 10); | ||
| 255 | ctx.fill(); | ||
| 256 | |||
| 257 | for (const zone of map.zones) { | ||
| 258 | let fill = FELT; | ||
| 259 | if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; | ||
| 260 | else if (zone.kind === "zero") fill = ZERO; | ||
| 261 | ctx.fillStyle = fill; | ||
| 262 | ctx.strokeStyle = FELT_LINE; | ||
| 263 | ctx.lineWidth = 1; | ||
| 264 | roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); | ||
| 265 | ctx.fill(); | ||
| 266 | ctx.stroke(); | ||
| 267 | |||
| 268 | ctx.fillStyle = TEXT; | ||
| 269 | ctx.textAlign = "center"; | ||
| 270 | ctx.textBaseline = "middle"; | ||
| 271 | if (zone.kind === "number" || zone.kind === "zero") { | ||
| 272 | ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; | ||
| 273 | } else { | ||
| 274 | ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; | ||
| 275 | } | ||
| 276 | ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); | ||
| 277 | } | ||
| 278 | |||
| 279 | drawChips(ctx, map, view); | ||
| 280 | return map; | ||
| 281 | } | ||
| 282 | |||
| 283 | function targetKey(target: any): string { | ||
| 284 | return JSON.stringify(target); | ||
| 285 | } | ||
| 286 | |||
| 287 | function centerFor(layout: Layout, target: any): { x: number; y: number } | null { | ||
| 288 | const key = targetKey(target); | ||
| 289 | const exact = layout.zones.find((zone) => targetKey(zone.target) === key); | ||
| 290 | if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; | ||
| 291 | |||
| 292 | // Split and corner bets are reachable from the desktop's fine lattice. A | ||
| 293 | // browser session can still show them: average the covered number cells. | ||
| 294 | if (target?.bet === "split" || target?.bet === "corner") { | ||
| 295 | const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; | ||
| 296 | const cells = layout.zones.filter( | ||
| 297 | (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), | ||
| 298 | ); | ||
| 299 | if (cells.length > 0) { | ||
| 300 | return { | ||
| 301 | x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, | ||
| 302 | y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, | ||
| 303 | }; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | return null; | ||
| 307 | } | ||
| 308 | |||
| 309 | function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { | ||
| 310 | const chips: any[] = view.bets?.chips ?? []; | ||
| 311 | for (const chip of chips) { | ||
| 312 | const center = centerFor(map, chip.target); | ||
| 313 | if (!center) continue; | ||
| 314 | const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); | ||
| 315 | ctx.beginPath(); | ||
| 316 | ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); | ||
| 317 | ctx.fillStyle = GOLD; | ||
| 318 | ctx.fill(); | ||
| 319 | ctx.strokeStyle = "#7a5a12"; | ||
| 320 | ctx.lineWidth = 2; | ||
| 321 | ctx.stroke(); | ||
| 322 | ctx.fillStyle = "#221a05"; | ||
| 323 | ctx.textAlign = "center"; | ||
| 324 | ctx.textBaseline = "middle"; | ||
| 325 | ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; | ||
| 326 | ctx.fillText(String(chip.amount), center.x, center.y + 0.5); | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | export function renderRoulette( | ||
| 331 | board: HTMLElement, | ||
| 332 | side: HTMLElement, | ||
| 333 | view: View, | ||
| 334 | send: (command: unknown) => void, | ||
| 335 | ): void { | ||
| 336 | clear(board); | ||
| 337 | clear(side); | ||
| 338 | |||
| 339 | const spinning = view.phase === "spinning"; | ||
| 340 | board.append(node("h2", undefined, "Roulette")); | ||
| 341 | board.append( | ||
| 342 | node( | ||
| 343 | "p", | ||
| 344 | "note", | ||
| 345 | "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", | ||
| 346 | ), | ||
| 347 | ); | ||
| 348 | |||
| 349 | const wrap = node("div", "roulette-wrap"); | ||
| 350 | const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; | ||
| 351 | wrap.append(canvas); | ||
| 352 | |||
| 353 | if (view.phase === "result" && view.number !== null && view.number !== undefined) { | ||
| 354 | const result = node("div", `wheel-result ${view.color ?? ""}`); | ||
| 355 | result.append(node("div", "label", "Result")); | ||
| 356 | result.append(node("div", "num", String(view.number))); | ||
| 357 | const winnings = Number(view.winnings ?? 0); | ||
| 358 | result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); | ||
| 359 | wrap.append(result); | ||
| 360 | } | ||
| 361 | board.append(wrap); | ||
| 362 | |||
| 363 | // The canvas must be in the document before its client size is read. | ||
| 364 | const map = draw(canvas, view); | ||
| 365 | let current = map; | ||
| 366 | |||
| 367 | const hit = (clientX: number, clientY: number): Zone | null => { | ||
| 368 | const rect = canvas.getBoundingClientRect(); | ||
| 369 | const x = clientX - rect.left; | ||
| 370 | const y = clientY - rect.top; | ||
| 371 | |||
| 372 | let best: Hotspot | null = null; | ||
| 373 | for (const hotspot of current.hotspots) { | ||
| 374 | const distance = Math.hypot(x - hotspot.x, y - hotspot.y); | ||
| 375 | if (distance > hotspot.radius) continue; | ||
| 376 | if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { | ||
| 377 | best = hotspot; | ||
| 378 | } | ||
| 379 | } | ||
| 380 | if (best) { | ||
| 381 | return { | ||
| 382 | x: best.x - best.radius, | ||
| 383 | y: best.y - best.radius, | ||
| 384 | w: best.radius * 2, | ||
| 385 | h: best.radius * 2, | ||
| 386 | target: best.target, | ||
| 387 | label: "", | ||
| 388 | kind: "number", | ||
| 389 | }; | ||
| 390 | } | ||
| 391 | |||
| 392 | for (let index = current.zones.length - 1; index >= 0; index -= 1) { | ||
| 393 | const zone = current.zones[index]; | ||
| 394 | if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { | ||
| 395 | return zone; | ||
| 396 | } | ||
| 397 | } | ||
| 398 | return null; | ||
| 399 | }; | ||
| 400 | |||
| 401 | canvas.addEventListener("click", (event) => { | ||
| 402 | if (spinning) return; | ||
| 403 | const zone = hit(event.clientX, event.clientY); | ||
| 404 | if (zone) send({ PlaceBetAt: zone.target }); | ||
| 405 | }); | ||
| 406 | canvas.addEventListener("contextmenu", (event) => { | ||
| 407 | event.preventDefault(); | ||
| 408 | if (spinning) return; | ||
| 409 | const zone = hit(event.clientX, event.clientY); | ||
| 410 | if (zone) send({ RemoveBetAt: zone.target }); | ||
| 411 | }); | ||
| 412 | canvas.addEventListener("mousemove", (event) => { | ||
| 413 | const zone = hit(event.clientX, event.clientY); | ||
| 414 | canvas.style.cursor = zone && !spinning ? "pointer" : "default"; | ||
| 415 | }); | ||
| 416 | new ResizeObserver(() => { | ||
| 417 | current = draw(canvas, view); | ||
| 418 | }).observe(canvas); | ||
| 419 | |||
| 420 | // Sidebar | ||
| 421 | side.append(node("div", "label", "Roulette")); | ||
| 422 | side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); | ||
| 423 | |||
| 424 | side.append(node("div", "label", "Chip")); | ||
| 425 | const chipRow = node("div", "chip-row"); | ||
| 426 | for (const value of [10, 25, 50, 100, 500]) { | ||
| 427 | const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); | ||
| 428 | button.disabled = spinning; | ||
| 429 | button.addEventListener("click", () => send({ SetChip: value })); | ||
| 430 | chipRow.append(button); | ||
| 431 | } | ||
| 432 | side.append(chipRow); | ||
| 433 | |||
| 434 | const total = (view.bets?.chips ?? []).reduce( | ||
| 435 | (sum: number, chip: any) => sum + Number(chip.amount ?? 0), | ||
| 436 | 0, | ||
| 437 | ); | ||
| 438 | const totalRow = node("div", "stat"); | ||
| 439 | totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); | ||
| 440 | side.append(totalRow); | ||
| 441 | |||
| 442 | if (view.balance !== null && view.balance !== undefined) { | ||
| 443 | const balanceRow = node("div", "stat"); | ||
| 444 | balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); | ||
| 445 | side.append(balanceRow); | ||
| 446 | } | ||
| 447 | |||
| 448 | if (view.error) side.append(node("p", "error-text", String(view.error))); | ||
| 449 | |||
| 450 | const status = node("p", "note"); | ||
| 451 | if (spinning) status.textContent = "Spinning…"; | ||
| 452 | else if (view.phase === "result") { | ||
| 453 | const winnings = Number(view.winnings ?? 0); | ||
| 454 | status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; | ||
| 455 | status.className = winnings > 0 ? "note win-text" : "note"; | ||
| 456 | } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; | ||
| 457 | side.append(status); | ||
| 458 | |||
| 459 | const actions = node("div", "actions"); | ||
| 460 | const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); | ||
| 461 | spin.disabled = spinning || total === 0; | ||
| 462 | spin.addEventListener("click", () => send("Spin")); | ||
| 463 | const clearBets = node("button", "btn wide", "Clear bets"); | ||
| 464 | clearBets.disabled = spinning || total === 0; | ||
| 465 | clearBets.addEventListener("click", () => send("ClearBets")); | ||
| 466 | const leave = node("button", "btn ghost wide", "Leave table"); | ||
| 467 | leave.disabled = spinning; | ||
| 468 | leave.addEventListener("click", () => { | ||
| 469 | if (spinning) return; | ||
| 470 | send("Leave"); | ||
| 471 | }); | ||
| 472 | actions.append(spin, clearBets, leave); | ||
| 473 | side.append(actions); | ||
| 474 | } | ||
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts new file mode 100644 index 0000000..dba1e25 --- /dev/null +++ b/web/play/src/slots.ts | |||
| @@ -0,0 +1,144 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node } from "./dom"; | ||
| 3 | |||
| 4 | const REELS = 5; | ||
| 5 | const ROWS = 3; | ||
| 6 | const SYMBOLS = "CLOGDS"; | ||
| 7 | const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; | ||
| 8 | |||
| 9 | let spinUntil = 0; | ||
| 10 | let animation: number | undefined; | ||
| 11 | let latest: View | null = null; | ||
| 12 | let latestBoard: HTMLElement | null = null; | ||
| 13 | |||
| 14 | export function requestSlotsSpin(): void { | ||
| 15 | spinUntil = Date.now() + 900; | ||
| 16 | } | ||
| 17 | |||
| 18 | function spinning(view: View): boolean { | ||
| 19 | return view.phase === "spinning" || Date.now() < spinUntil; | ||
| 20 | } | ||
| 21 | |||
| 22 | export function renderSlots( | ||
| 23 | board: HTMLElement, | ||
| 24 | side: HTMLElement, | ||
| 25 | view: View, | ||
| 26 | send: (command: unknown) => void, | ||
| 27 | ): void { | ||
| 28 | latest = view; | ||
| 29 | latestBoard = board; | ||
| 30 | if (animation !== undefined) { | ||
| 31 | window.clearInterval(animation); | ||
| 32 | animation = undefined; | ||
| 33 | } | ||
| 34 | |||
| 35 | const active = spinning(view); | ||
| 36 | clear(board); | ||
| 37 | clear(side); | ||
| 38 | |||
| 39 | const title = node("h2", undefined, "Slots"); | ||
| 40 | const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); | ||
| 41 | board.append(title, note); | ||
| 42 | |||
| 43 | const reels = node("div", "reels"); | ||
| 44 | const tiles: HTMLElement[] = []; | ||
| 45 | const flat = String(view.reels ?? ""); | ||
| 46 | for (let column = 0; column < REELS; column += 1) { | ||
| 47 | const reel = node("div", "reel"); | ||
| 48 | for (let row = 0; row < ROWS; row += 1) { | ||
| 49 | const index = column * ROWS + row; | ||
| 50 | const symbol = active | ||
| 51 | ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length] | ||
| 52 | : flat.length === REELS * ROWS | ||
| 53 | ? flat[index] | ||
| 54 | : flat.length === REELS | ||
| 55 | ? flat[column] | ||
| 56 | : SYMBOLS[(index * 5 + column) % SYMBOLS.length]; | ||
| 57 | const tile = node("div", "tile", symbol); | ||
| 58 | tile.classList.add(`sym-${symbol}`); | ||
| 59 | if (active) tile.classList.add("spinning"); | ||
| 60 | if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win"); | ||
| 61 | tiles.push(tile); | ||
| 62 | reel.append(tile); | ||
| 63 | } | ||
| 64 | reels.append(reel); | ||
| 65 | } | ||
| 66 | board.append(reels); | ||
| 67 | |||
| 68 | const lines = node("div", "win-lines"); | ||
| 69 | if (!active && view.phase === "result") { | ||
| 70 | const winnings = Number(view.winnings ?? 0); | ||
| 71 | if (winnings > 0) { | ||
| 72 | const names = (view.win_lines ?? []) | ||
| 73 | .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) | ||
| 74 | .join(" · "); | ||
| 75 | lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; | ||
| 76 | lines.classList.add("win-text"); | ||
| 77 | } else { | ||
| 78 | lines.textContent = "No win — spin again."; | ||
| 79 | } | ||
| 80 | } else if (active) { | ||
| 81 | lines.textContent = "Spinning…"; | ||
| 82 | } else { | ||
| 83 | lines.textContent = "Pick a bet and spin."; | ||
| 84 | } | ||
| 85 | board.append(lines); | ||
| 86 | |||
| 87 | if (active) { | ||
| 88 | let tick = 0; | ||
| 89 | animation = window.setInterval(() => { | ||
| 90 | tick += 1; | ||
| 91 | for (let index = 0; index < tiles.length; index += 1) { | ||
| 92 | const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length]; | ||
| 93 | tiles[index].textContent = symbol; | ||
| 94 | tiles[index].className = `tile spinning sym-${symbol}`; | ||
| 95 | } | ||
| 96 | if (Date.now() >= spinUntil && latest && latestBoard) { | ||
| 97 | window.clearInterval(animation); | ||
| 98 | animation = undefined; | ||
| 99 | renderSlots(latestBoard, side, latest, send); | ||
| 100 | } | ||
| 101 | }, 70); | ||
| 102 | } | ||
| 103 | |||
| 104 | // Sidebar | ||
| 105 | side.append(node("div", "label", "Slots")); | ||
| 106 | side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); | ||
| 107 | |||
| 108 | const betRow = node("div", "row"); | ||
| 109 | const minus = node("button", "btn small", "−10"); | ||
| 110 | const bet = node("strong", "mono", money(Number(view.bet ?? 0))); | ||
| 111 | const plus = node("button", "btn small", "+10"); | ||
| 112 | minus.disabled = active; | ||
| 113 | plus.disabled = active; | ||
| 114 | minus.addEventListener("click", () => send({ AdjustBet: -10 })); | ||
| 115 | plus.addEventListener("click", () => send({ AdjustBet: 10 })); | ||
| 116 | betRow.append(minus, bet, plus); | ||
| 117 | side.append(node("div", "label", "Bet"), betRow); | ||
| 118 | |||
| 119 | if (view.error) { | ||
| 120 | side.append(node("p", "error-text", String(view.error))); | ||
| 121 | } | ||
| 122 | |||
| 123 | const status = node("p", "note"); | ||
| 124 | if (active) status.textContent = "Spinning…"; | ||
| 125 | else if (view.phase === "result") { | ||
| 126 | const winnings = Number(view.winnings ?? 0); | ||
| 127 | status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; | ||
| 128 | if (winnings > 0) status.classList.add("win-text"); | ||
| 129 | } else status.textContent = "Ready when you are."; | ||
| 130 | side.append(status); | ||
| 131 | |||
| 132 | const actions = node("div", "actions"); | ||
| 133 | const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); | ||
| 134 | spin.disabled = active; | ||
| 135 | spin.addEventListener("click", () => { | ||
| 136 | requestSlotsSpin(); | ||
| 137 | send("Spin"); | ||
| 138 | }); | ||
| 139 | const leave = node("button", "btn ghost wide", "Leave machine"); | ||
| 140 | leave.disabled = active; | ||
| 141 | leave.addEventListener("click", () => send("Leave")); | ||
| 142 | actions.append(spin, leave); | ||
| 143 | side.append(actions); | ||
| 144 | } | ||
diff --git a/web/play/src/types.ts b/web/play/src/types.ts new file mode 100644 index 0000000..e594a0b --- /dev/null +++ b/web/play/src/types.ts | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | export type View = { | ||
| 2 | screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash"; | ||
| 3 | [key: string]: any; | ||
| 4 | }; | ||
| 5 | |||
| 6 | export type Hello = { | ||
| 7 | type: "hello"; | ||
| 8 | version: string; | ||
| 9 | grace_seconds: number; | ||
| 10 | /** Signed-in user restored after a refresh, if any. */ | ||
| 11 | user?: string | null; | ||
| 12 | /** Estimated game-server Unix time in milliseconds. */ | ||
| 13 | time: number; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export type Send = (command: unknown) => void; | ||
| 17 | |||
| 18 | export type Handlers = { | ||
| 19 | onHello: (hello: Hello) => void; | ||
| 20 | onView: (view: View) => void; | ||
| 21 | onNotice: (text: string) => void; | ||
| 22 | onError: (text: string) => void; | ||
| 23 | onReplaced: () => void; | ||
| 24 | onPong: () => void; | ||
| 25 | onOpen: () => void; | ||
| 26 | onClosed: (willRetry: boolean) => void; | ||
| 27 | }; | ||
diff --git a/web/play/style.css b/web/play/style.css new file mode 100644 index 0000000..20842d2 --- /dev/null +++ b/web/play/style.css | |||
| @@ -0,0 +1,407 @@ | |||
| 1 | :root { | ||
| 2 | --bg: #0a0e12; | ||
| 3 | --surface: #11171d; | ||
| 4 | --raised: #171f27; | ||
| 5 | --hover: #1d2731; | ||
| 6 | --line: #242e38; | ||
| 7 | --text: #d9e2ea; | ||
| 8 | --dim: #8a98a6; | ||
| 9 | --faint: #5b6773; | ||
| 10 | --accent: #4fb8c9; | ||
| 11 | --win: #4ec27b; | ||
| 12 | --loss: #e05a5a; | ||
| 13 | --gold: #e0b64a; | ||
| 14 | --felt: #0d5c39; | ||
| 15 | --felt-dark: #09472c; | ||
| 16 | --felt-line: #1f7d52; | ||
| 17 | --felt-text: #ecf7f0; | ||
| 18 | --red: #b0313a; | ||
| 19 | --black: #151b21; | ||
| 20 | --zero: #106b45; | ||
| 21 | --radius-sm: 4px; | ||
| 22 | --radius-md: 8px; | ||
| 23 | --radius-lg: 12px; | ||
| 24 | --sans: "Open Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; | ||
| 25 | --mono: "Hack", ui-monospace, "SFMono-Regular", "Cascadia Code", monospace; | ||
| 26 | } | ||
| 27 | |||
| 28 | * { box-sizing: border-box; } | ||
| 29 | html, body { height: 100%; } | ||
| 30 | body { | ||
| 31 | margin: 0; | ||
| 32 | background: | ||
| 33 | radial-gradient(1100px 600px at 80% -10%, rgba(79, 184, 201, 0.06), transparent 60%), | ||
| 34 | var(--bg); | ||
| 35 | color: var(--text); | ||
| 36 | font-family: var(--sans); | ||
| 37 | font-size: 14px; | ||
| 38 | line-height: 1.5; | ||
| 39 | -webkit-font-smoothing: antialiased; | ||
| 40 | } | ||
| 41 | [hidden] { display: none !important; } | ||
| 42 | |||
| 43 | a { color: var(--accent); text-decoration: none; } | ||
| 44 | a:hover { text-decoration: underline; } | ||
| 45 | button, input { font: inherit; color: inherit; } | ||
| 46 | button { cursor: pointer; } | ||
| 47 | button:disabled { cursor: not-allowed; opacity: 0.45; } | ||
| 48 | |||
| 49 | .shell { min-height: 100%; display: flex; flex-direction: column; } | ||
| 50 | |||
| 51 | .topbar { | ||
| 52 | display: flex; | ||
| 53 | align-items: center; | ||
| 54 | gap: 16px; | ||
| 55 | min-height: 64px; | ||
| 56 | padding: 0 24px; | ||
| 57 | border-bottom: 1px solid var(--line); | ||
| 58 | background: rgba(10, 14, 18, 0.86); | ||
| 59 | backdrop-filter: blur(10px); | ||
| 60 | position: sticky; | ||
| 61 | top: 0; | ||
| 62 | z-index: 20; | ||
| 63 | } | ||
| 64 | .brand { | ||
| 65 | color: var(--text); | ||
| 66 | font-family: var(--mono); | ||
| 67 | font-size: 19px; | ||
| 68 | letter-spacing: 0.04em; | ||
| 69 | white-space: nowrap; | ||
| 70 | } | ||
| 71 | .brand span { color: var(--win); } | ||
| 72 | .brand:hover { text-decoration: none; } | ||
| 73 | .tagline { | ||
| 74 | color: var(--faint); | ||
| 75 | font-family: var(--mono); | ||
| 76 | font-size: 11px; | ||
| 77 | text-transform: uppercase; | ||
| 78 | letter-spacing: 0.12em; | ||
| 79 | } | ||
| 80 | .spacer { flex: 1; } | ||
| 81 | .session { display: flex; align-items: center; gap: 12px; } | ||
| 82 | .session .who { color: var(--dim); font-family: var(--mono); font-size: 12px; } | ||
| 83 | .session .who strong { color: var(--text); font-weight: 600; } | ||
| 84 | .balance { | ||
| 85 | color: var(--win); | ||
| 86 | font-family: var(--mono); | ||
| 87 | font-size: 16px; | ||
| 88 | padding: 4px 10px; | ||
| 89 | border: 1px solid var(--line); | ||
| 90 | border-radius: var(--radius-sm); | ||
| 91 | background: var(--surface); | ||
| 92 | white-space: nowrap; | ||
| 93 | } | ||
| 94 | |||
| 95 | main { width: min(1240px, 100%); margin: 0 auto; padding: 28px 24px 64px; flex: 1; } | ||
| 96 | |||
| 97 | h1, h2, h3 { margin: 0; font-weight: 600; } | ||
| 98 | h1 { font-size: 26px; letter-spacing: -0.01em; } | ||
| 99 | h2 { font-size: 18px; } | ||
| 100 | .muted { color: var(--dim); } | ||
| 101 | .faint { color: var(--faint); } | ||
| 102 | .mono { font-family: var(--mono); } | ||
| 103 | .label { | ||
| 104 | font-family: var(--mono); | ||
| 105 | font-size: 10px; | ||
| 106 | text-transform: uppercase; | ||
| 107 | letter-spacing: 0.16em; | ||
| 108 | color: var(--dim); | ||
| 109 | } | ||
| 110 | .error-text { color: var(--loss); } | ||
| 111 | .win-text { color: var(--win); } | ||
| 112 | |||
| 113 | .btn { | ||
| 114 | border: 1px solid var(--line); | ||
| 115 | background: var(--raised); | ||
| 116 | color: var(--text); | ||
| 117 | padding: 9px 14px; | ||
| 118 | border-radius: var(--radius-md); | ||
| 119 | line-height: 1.2; | ||
| 120 | transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; | ||
| 121 | } | ||
| 122 | .btn:hover:not(:disabled) { background: var(--hover); border-color: #33404d; } | ||
| 123 | .btn:active:not(:disabled) { transform: translateY(1px); } | ||
| 124 | .btn.primary { | ||
| 125 | background: var(--win); | ||
| 126 | border-color: var(--win); | ||
| 127 | color: #06120b; | ||
| 128 | font-weight: 700; | ||
| 129 | } | ||
| 130 | .btn.primary:hover:not(:disabled) { filter: brightness(1.08); } | ||
| 131 | .btn.accent { | ||
| 132 | background: rgba(79, 184, 201, 0.14); | ||
| 133 | border-color: var(--accent); | ||
| 134 | color: var(--accent); | ||
| 135 | font-weight: 600; | ||
| 136 | } | ||
| 137 | .btn.danger { | ||
| 138 | background: rgba(224, 90, 90, 0.12); | ||
| 139 | border-color: rgba(224, 90, 90, 0.55); | ||
| 140 | color: var(--loss); | ||
| 141 | } | ||
| 142 | .btn.ghost { background: transparent; } | ||
| 143 | .btn.small { padding: 5px 9px; font-size: 12px; } | ||
| 144 | .btn.wide { width: 100%; } | ||
| 145 | .btn.big { padding: 13px 18px; font-size: 15px; } | ||
| 146 | |||
| 147 | .input { | ||
| 148 | width: 100%; | ||
| 149 | background: var(--bg); | ||
| 150 | border: 1px solid var(--line); | ||
| 151 | border-radius: var(--radius-md); | ||
| 152 | padding: 10px 12px; | ||
| 153 | outline: none; | ||
| 154 | } | ||
| 155 | .input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79, 184, 201, 0.18); } | ||
| 156 | .field { display: grid; gap: 6px; } | ||
| 157 | .row { display: flex; align-items: center; gap: 10px; } | ||
| 158 | .row.wrap { flex-wrap: wrap; } | ||
| 159 | .hr { height: 1px; background: var(--line); border: 0; margin: 18px 0; } | ||
| 160 | |||
| 161 | /* Auth */ | ||
| 162 | .auth-wrap { display: grid; place-items: center; min-height: 52vh; } | ||
| 163 | .auth-card { | ||
| 164 | width: min(430px, 100%); | ||
| 165 | background: var(--surface); | ||
| 166 | border: 1px solid var(--line); | ||
| 167 | border-radius: var(--radius-lg); | ||
| 168 | padding: 26px; | ||
| 169 | box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); | ||
| 170 | } | ||
| 171 | .auth-tabs { display: flex; gap: 4px; margin-bottom: 20px; background: var(--bg); padding: 4px; border-radius: var(--radius-md); } | ||
| 172 | .auth-tab { flex: 1; border: 0; background: transparent; color: var(--dim); padding: 8px; border-radius: var(--radius-sm); } | ||
| 173 | .auth-tab.active { background: var(--raised); color: var(--text); } | ||
| 174 | .auth-card form { display: grid; gap: 14px; } | ||
| 175 | .auth-foot { margin-top: 16px; font-size: 12px; color: var(--faint); } | ||
| 176 | |||
| 177 | /* Lobby */ | ||
| 178 | .lobby-head { display: flex; align-items: end; gap: 18px; margin-bottom: 22px; flex-wrap: wrap; } | ||
| 179 | .lobby-head .spacer { flex: 1; } | ||
| 180 | .game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } | ||
| 181 | .game-card { | ||
| 182 | background: var(--surface); | ||
| 183 | border: 1px solid var(--line); | ||
| 184 | border-radius: var(--radius-lg); | ||
| 185 | padding: 18px; | ||
| 186 | display: flex; | ||
| 187 | flex-direction: column; | ||
| 188 | gap: 12px; | ||
| 189 | min-height: 178px; | ||
| 190 | position: relative; | ||
| 191 | overflow: hidden; | ||
| 192 | } | ||
| 193 | .game-card::before { | ||
| 194 | content: ""; | ||
| 195 | position: absolute; | ||
| 196 | inset: 0 auto 0 0; | ||
| 197 | width: 3px; | ||
| 198 | background: var(--accent); | ||
| 199 | opacity: 0.7; | ||
| 200 | } | ||
| 201 | .game-card.crash::before { background: var(--gold); } | ||
| 202 | .game-card.roulette::before { background: var(--felt-line); } | ||
| 203 | .game-card.blackjack::before { background: var(--loss); } | ||
| 204 | .game-card h3 { font-size: 17px; } | ||
| 205 | .game-card p { margin: 0; color: var(--dim); font-size: 13px; flex: 1; } | ||
| 206 | .game-card .row { justify-content: space-between; } | ||
| 207 | .bet-input { width: 92px; } | ||
| 208 | |||
| 209 | /* Game shell */ | ||
| 210 | .game-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; } | ||
| 211 | .game-head .spacer { flex: 1; } | ||
| 212 | .game-body { | ||
| 213 | display: grid; | ||
| 214 | grid-template-columns: minmax(0, 1fr) 320px; | ||
| 215 | gap: 16px; | ||
| 216 | align-items: start; | ||
| 217 | } | ||
| 218 | .board { | ||
| 219 | background: var(--surface); | ||
| 220 | border: 1px solid var(--line); | ||
| 221 | border-radius: var(--radius-lg); | ||
| 222 | padding: 18px; | ||
| 223 | min-height: 560px; | ||
| 224 | overflow: hidden; | ||
| 225 | } | ||
| 226 | .sidebar { | ||
| 227 | background: var(--surface); | ||
| 228 | border: 1px solid var(--line); | ||
| 229 | border-radius: var(--radius-lg); | ||
| 230 | padding: 18px; | ||
| 231 | display: flex; | ||
| 232 | flex-direction: column; | ||
| 233 | gap: 14px; | ||
| 234 | position: sticky; | ||
| 235 | top: 84px; | ||
| 236 | } | ||
| 237 | .sidebar .actions { margin-top: auto; display: grid; gap: 8px; } | ||
| 238 | .sidebar .stat { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; } | ||
| 239 | .sidebar .stat strong { font-family: var(--mono); font-weight: 600; } | ||
| 240 | .note { color: var(--dim); font-size: 12px; } | ||
| 241 | .chip-row { display: flex; flex-wrap: wrap; gap: 6px; } | ||
| 242 | .chip-btn { | ||
| 243 | font-family: var(--mono); | ||
| 244 | font-size: 12px; | ||
| 245 | border-radius: 999px; | ||
| 246 | border: 1px solid var(--line); | ||
| 247 | background: var(--raised); | ||
| 248 | padding: 5px 10px; | ||
| 249 | } | ||
| 250 | .chip-btn.active { border-color: var(--gold); color: var(--gold); background: rgba(224, 182, 74, 0.1); } | ||
| 251 | |||
| 252 | /* Slots */ | ||
| 253 | .reels { | ||
| 254 | display: grid; | ||
| 255 | grid-template-columns: repeat(5, minmax(60px, 1fr)); | ||
| 256 | gap: 10px; | ||
| 257 | max-width: 760px; | ||
| 258 | margin: 24px auto 0; | ||
| 259 | } | ||
| 260 | .reel { display: grid; gap: 8px; } | ||
| 261 | .tile { | ||
| 262 | aspect-ratio: 1 / 0.92; | ||
| 263 | display: grid; | ||
| 264 | place-items: center; | ||
| 265 | border-radius: var(--radius-md); | ||
| 266 | border: 1px solid var(--line); | ||
| 267 | background: var(--bg); | ||
| 268 | font-family: var(--mono); | ||
| 269 | font-size: clamp(26px, 5vw, 54px); | ||
| 270 | font-weight: 700; | ||
| 271 | color: var(--dim); | ||
| 272 | transition: color 90ms ease, border-color 90ms ease, background 90ms ease; | ||
| 273 | } | ||
| 274 | .tile.sym-C { color: #c8d1da; } | ||
| 275 | .tile.sym-L { color: #7cc4d4; } | ||
| 276 | .tile.sym-O { color: #d8c27a; } | ||
| 277 | .tile.sym-G { color: #83d09a; } | ||
| 278 | .tile.sym-D { color: #d48d8d; } | ||
| 279 | .tile.sym-S { color: #e0b64a; } | ||
| 280 | .tile.win { border-color: var(--win); background: rgba(78, 194, 123, 0.12); color: var(--win); } | ||
| 281 | .tile.spinning { color: var(--faint); } | ||
| 282 | .win-lines { text-align: center; margin-top: 18px; color: var(--dim); font-size: 12px; min-height: 18px; } | ||
| 283 | |||
| 284 | /* Cards */ | ||
| 285 | .hand { margin: 16px 0; } | ||
| 286 | .hand .label { margin-bottom: 8px; } | ||
| 287 | .cards { display: flex; gap: 8px; flex-wrap: wrap; min-height: 116px; } | ||
| 288 | .card { | ||
| 289 | width: 76px; | ||
| 290 | height: 108px; | ||
| 291 | border-radius: 9px; | ||
| 292 | background: #f2f5f7; | ||
| 293 | color: #121820; | ||
| 294 | border: 1px solid #cfd8df; | ||
| 295 | padding: 8px; | ||
| 296 | display: flex; | ||
| 297 | flex-direction: column; | ||
| 298 | justify-content: space-between; | ||
| 299 | font-family: var(--mono); | ||
| 300 | font-weight: 700; | ||
| 301 | box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); | ||
| 302 | } | ||
| 303 | .card.red { color: #a51f2d; } | ||
| 304 | .card.back { background: repeating-linear-gradient(135deg, #1d2731, #1d2731 6px, #26333f 6px, #26333f 12px); border-color: #33404d; } | ||
| 305 | .card .rank { font-size: 22px; line-height: 1; } | ||
| 306 | .card .suit { font-size: 22px; align-self: flex-end; } | ||
| 307 | .card.back .rank, .card.back .suit { color: var(--faint); } | ||
| 308 | .total { color: var(--dim); font-family: var(--mono); font-size: 12px; } | ||
| 309 | .total strong { color: var(--text); font-size: 15px; } | ||
| 310 | .outcome { margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); } | ||
| 311 | |||
| 312 | /* Crash */ | ||
| 313 | .crash-wrap { position: relative; } | ||
| 314 | .crash-number { | ||
| 315 | font-family: var(--mono); | ||
| 316 | font-size: clamp(44px, 9vw, 86px); | ||
| 317 | line-height: 1; | ||
| 318 | letter-spacing: -0.04em; | ||
| 319 | } | ||
| 320 | .crash-number.running { color: var(--gold); } | ||
| 321 | .crash-number.crashed { color: var(--loss); } | ||
| 322 | .crash-number.cashed { color: var(--win); } | ||
| 323 | .crash-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; color: var(--faint); font-size: 11px; font-family: var(--mono); } | ||
| 324 | .verify { margin-top: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--bg); font-size: 11px; color: var(--dim); word-break: break-all; } | ||
| 325 | .verify.ok { border-color: rgba(78, 194, 123, 0.4); color: var(--win); } | ||
| 326 | .verify.bad { border-color: rgba(224, 90, 90, 0.5); color: var(--loss); } | ||
| 327 | |||
| 328 | /* Roulette */ | ||
| 329 | .roulette-wrap { position: relative; } | ||
| 330 | canvas { display: block; width: 100%; } | ||
| 331 | .roulette-canvas { height: 540px; } | ||
| 332 | .crash-canvas { height: 360px; } | ||
| 333 | .wheel-result { | ||
| 334 | position: absolute; | ||
| 335 | top: 16px; | ||
| 336 | right: 16px; | ||
| 337 | padding: 8px 12px; | ||
| 338 | border-radius: var(--radius-md); | ||
| 339 | border: 1px solid var(--line); | ||
| 340 | background: rgba(10, 14, 18, 0.86); | ||
| 341 | font-family: var(--mono); | ||
| 342 | } | ||
| 343 | .wheel-result .num { font-size: 26px; font-weight: 700; } | ||
| 344 | .wheel-result.red .num { color: #f06a74; } | ||
| 345 | .wheel-result.black .num { color: #dfe8ef; } | ||
| 346 | .wheel-result.green .num { color: #66d49a; } | ||
| 347 | |||
| 348 | /* History */ | ||
| 349 | .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-lg); } | ||
| 350 | table { width: 100%; border-collapse: collapse; background: var(--surface); } | ||
| 351 | th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: 13px; white-space: nowrap; } | ||
| 352 | th { color: var(--dim); font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; } | ||
| 353 | tr:last-child td { border-bottom: 0; } | ||
| 354 | td.num { font-family: var(--mono); } | ||
| 355 | td.pos { color: var(--win); } | ||
| 356 | td.neg { color: var(--loss); } | ||
| 357 | |||
| 358 | /* Toasts and overlays */ | ||
| 359 | #toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; width: min(380px, calc(100vw - 36px)); } | ||
| 360 | .toast { | ||
| 361 | background: var(--surface); | ||
| 362 | border: 1px solid var(--line); | ||
| 363 | border-left: 3px solid var(--accent); | ||
| 364 | border-radius: var(--radius-md); | ||
| 365 | padding: 10px 12px; | ||
| 366 | font-size: 13px; | ||
| 367 | box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); | ||
| 368 | animation: toast-in 160ms ease-out; | ||
| 369 | } | ||
| 370 | .toast.good { border-left-color: var(--win); } | ||
| 371 | .toast.bad { border-left-color: var(--loss); } | ||
| 372 | @keyframes toast-in { from { opacity: 0; transform: translateY(6px); } } | ||
| 373 | .overlay { | ||
| 374 | position: fixed; | ||
| 375 | inset: 0; | ||
| 376 | background: rgba(5, 8, 11, 0.78); | ||
| 377 | display: grid; | ||
| 378 | place-items: center; | ||
| 379 | z-index: 60; | ||
| 380 | padding: 24px; | ||
| 381 | } | ||
| 382 | .modal { | ||
| 383 | width: min(440px, 100%); | ||
| 384 | background: var(--surface); | ||
| 385 | border: 1px solid var(--line); | ||
| 386 | border-radius: var(--radius-lg); | ||
| 387 | padding: 24px; | ||
| 388 | text-align: center; | ||
| 389 | } | ||
| 390 | .modal p { color: var(--dim); } | ||
| 391 | |||
| 392 | @media (max-width: 900px) { | ||
| 393 | .game-body { grid-template-columns: 1fr; } | ||
| 394 | .sidebar { position: static; } | ||
| 395 | .tagline { display: none; } | ||
| 396 | } | ||
| 397 | @media (max-width: 620px) { | ||
| 398 | main { padding: 18px 14px 54px; } | ||
| 399 | .topbar { padding: 0 14px; gap: 10px; } | ||
| 400 | .session .who { display: none; } | ||
| 401 | .balance { font-size: 14px; } | ||
| 402 | .reels { gap: 6px; } | ||
| 403 | .tile { font-size: clamp(18px, 7vw, 34px); } | ||
| 404 | .card { width: 58px; height: 84px; } | ||
| 405 | .crash-canvas { height: 260px; } | ||
| 406 | .roulette-canvas { height: 400px; } | ||
| 407 | } | ||
diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs new file mode 100644 index 0000000..c1274bc --- /dev/null +++ b/web/play/test/smoke.mjs | |||
| @@ -0,0 +1,294 @@ | |||
| 1 | import fs from "node:fs"; | ||
| 2 | import { webcrypto } from "node:crypto"; | ||
| 3 | import { JSDOM, VirtualConsole } from "jsdom"; | ||
| 4 | |||
| 5 | const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8") | ||
| 6 | .replace('<script src="app.js" defer></script>', ""); | ||
| 7 | const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8"); | ||
| 8 | |||
| 9 | const failures = []; | ||
| 10 | const virtualConsole = new VirtualConsole(); | ||
| 11 | virtualConsole.on("jsdomError", (error) => failures.push(String(error))); | ||
| 12 | |||
| 13 | const dom = new JSDOM(html, { | ||
| 14 | url: "http://127.0.0.1:8899/play/", | ||
| 15 | runScripts: "outside-only", | ||
| 16 | pretendToBeVisual: true, | ||
| 17 | virtualConsole, | ||
| 18 | }); | ||
| 19 | const { window } = dom; | ||
| 20 | |||
| 21 | Object.defineProperty(window, "crypto", { value: webcrypto }); | ||
| 22 | window.devicePixelRatio = 1; | ||
| 23 | window.confirm = () => true; | ||
| 24 | |||
| 25 | class MockWebSocket { | ||
| 26 | static OPEN = 1; | ||
| 27 | static instances = []; | ||
| 28 | readyState = MockWebSocket.OPEN; | ||
| 29 | sent = []; | ||
| 30 | listeners = new Map(); | ||
| 31 | |||
| 32 | constructor(url) { | ||
| 33 | this.url = url; | ||
| 34 | MockWebSocket.instances.push(this); | ||
| 35 | queueMicrotask(() => this.emit("open", {})); | ||
| 36 | } | ||
| 37 | |||
| 38 | addEventListener(type, listener) { | ||
| 39 | const list = this.listeners.get(type) ?? []; | ||
| 40 | list.push(listener); | ||
| 41 | this.listeners.set(type, list); | ||
| 42 | } | ||
| 43 | |||
| 44 | send(raw) { | ||
| 45 | this.sent.push(JSON.parse(raw)); | ||
| 46 | } | ||
| 47 | |||
| 48 | close() { | ||
| 49 | this.emit("close", {}); | ||
| 50 | } | ||
| 51 | |||
| 52 | emit(type, event) { | ||
| 53 | for (const listener of this.listeners.get(type) ?? []) listener(event); | ||
| 54 | } | ||
| 55 | |||
| 56 | message(message) { | ||
| 57 | this.emit("message", { data: JSON.stringify(message) }); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | window.WebSocket = MockWebSocket; | ||
| 61 | window.ResizeObserver = class { | ||
| 62 | observe() {} | ||
| 63 | disconnect() {} | ||
| 64 | }; | ||
| 65 | window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0); | ||
| 66 | window.cancelAnimationFrame = (handle) => window.clearTimeout(handle); | ||
| 67 | |||
| 68 | const context = new Proxy( | ||
| 69 | {}, | ||
| 70 | { | ||
| 71 | get(target, key) { | ||
| 72 | if (key === "measureText") return () => ({ width: 10 }); | ||
| 73 | if (key in target) return target[key]; | ||
| 74 | return () => undefined; | ||
| 75 | }, | ||
| 76 | set(target, key, value) { | ||
| 77 | target[key] = value; | ||
| 78 | return true; | ||
| 79 | }, | ||
| 80 | }, | ||
| 81 | ); | ||
| 82 | window.HTMLCanvasElement.prototype.getContext = function getContext() { | ||
| 83 | return context; | ||
| 84 | }; | ||
| 85 | |||
| 86 | window.eval(bundle); | ||
| 87 | |||
| 88 | const document = window.document; | ||
| 89 | const byId = (id) => document.getElementById(id); | ||
| 90 | const assert = (condition, message) => { | ||
| 91 | if (!condition) failures.push(message); | ||
| 92 | }; | ||
| 93 | |||
| 94 | const socket = MockWebSocket.instances[0]; | ||
| 95 | assert(socket, "the app opened a WebSocket"); | ||
| 96 | assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`); | ||
| 97 | |||
| 98 | const hello = { | ||
| 99 | type: "hello", | ||
| 100 | version: "5.0.0", | ||
| 101 | grace_seconds: 60, | ||
| 102 | time: Date.now(), | ||
| 103 | }; | ||
| 104 | const lobby = { | ||
| 105 | type: "view", | ||
| 106 | view: { | ||
| 107 | screen: "lobby", | ||
| 108 | connected: true, | ||
| 109 | user: null, | ||
| 110 | account: null, | ||
| 111 | balance: null, | ||
| 112 | games: [ | ||
| 113 | ["blackjack", "Blackjack"], | ||
| 114 | ["crash", "Crash"], | ||
| 115 | ["roulette", "Roulette"], | ||
| 116 | ["slots", "Slots"], | ||
| 117 | ], | ||
| 118 | login_error: null, | ||
| 119 | }, | ||
| 120 | }; | ||
| 121 | socket.message(hello); | ||
| 122 | socket.message(lobby); | ||
| 123 | |||
| 124 | assert(byId("auth-screen").hidden === false, "auth is shown before login"); | ||
| 125 | assert(byId("session-bar").hidden === true, "no session bar before login"); | ||
| 126 | |||
| 127 | byId("auth-user").value = "alice"; | ||
| 128 | byId("auth-pass").value = "secret"; | ||
| 129 | byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); | ||
| 130 | |||
| 131 | await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| 132 | const login = socket.sent.find((message) => message.command?.Login); | ||
| 133 | assert(login, "the login form sends a Login command"); | ||
| 134 | assert(login.command.Login.user === "alice", "the username is sent"); | ||
| 135 | assert( | ||
| 136 | /^[0-9a-f]{64}$/.test(login.command.Login.credential), | ||
| 137 | "the password is sent as a SHA-256 hex digest", | ||
| 138 | ); | ||
| 139 | |||
| 140 | socket.message({ | ||
| 141 | type: "view", | ||
| 142 | view: { | ||
| 143 | screen: "lobby", | ||
| 144 | connected: true, | ||
| 145 | user: "alice", | ||
| 146 | account: "1", | ||
| 147 | balance: null, | ||
| 148 | games: lobby.view.games, | ||
| 149 | login_error: null, | ||
| 150 | }, | ||
| 151 | }); | ||
| 152 | assert( | ||
| 153 | socket.sent.some((message) => message.command === "Info"), | ||
| 154 | "the app asks for the balance after login", | ||
| 155 | ); | ||
| 156 | socket.message({ | ||
| 157 | type: "view", | ||
| 158 | view: { | ||
| 159 | screen: "lobby", | ||
| 160 | connected: true, | ||
| 161 | user: "alice", | ||
| 162 | account: "1", | ||
| 163 | balance: 1000, | ||
| 164 | games: lobby.view.games, | ||
| 165 | login_error: null, | ||
| 166 | }, | ||
| 167 | }); | ||
| 168 | assert(byId("auth-screen").hidden === true, "auth is hidden after login"); | ||
| 169 | assert(byId("lobby-screen").hidden === false, "the lobby is shown after login"); | ||
| 170 | assert(byId("account-name").textContent === "alice", "the account name is shown"); | ||
| 171 | assert(byId("account-balance").textContent.includes("1"), "the balance is shown"); | ||
| 172 | assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render"); | ||
| 173 | socket.message({ | ||
| 174 | type: "view", | ||
| 175 | view: { | ||
| 176 | screen: "slots", | ||
| 177 | bet: 10, | ||
| 178 | balance: 1000, | ||
| 179 | reels: "CLOGDSCLOGDSLLO".split(""), | ||
| 180 | win_cells: [0, 1, 2], | ||
| 181 | win_lines: [1], | ||
| 182 | win_type: "win", | ||
| 183 | winnings: 20, | ||
| 184 | phase: "result", | ||
| 185 | error: null, | ||
| 186 | }, | ||
| 187 | }); | ||
| 188 | assert(byId("game-screen").hidden === false, "the game screen is shown"); | ||
| 189 | assert(document.querySelectorAll(".reel").length === 5, "five reels render"); | ||
| 190 | assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render"); | ||
| 191 | assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action"); | ||
| 192 | |||
| 193 | socket.message({ | ||
| 194 | type: "view", | ||
| 195 | view: { | ||
| 196 | screen: "roulette", | ||
| 197 | bet_amount: 10, | ||
| 198 | balance: 1000, | ||
| 199 | chip: 25, | ||
| 200 | bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] }, | ||
| 201 | cursor: { Lattice: { x: 1, y: 5 } }, | ||
| 202 | phase: "result", | ||
| 203 | number: 5, | ||
| 204 | color: "red", | ||
| 205 | winnings: 900, | ||
| 206 | error: null, | ||
| 207 | }, | ||
| 208 | }); | ||
| 209 | assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); | ||
| 210 | assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders"); | ||
| 211 | assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders"); | ||
| 212 | |||
| 213 | socket.message({ | ||
| 214 | type: "view", | ||
| 215 | view: { | ||
| 216 | screen: "blackjack", | ||
| 217 | status: "player", | ||
| 218 | balance: 990, | ||
| 219 | bet: 10, | ||
| 220 | hand: { | ||
| 221 | session_id: "bj1", | ||
| 222 | phase: "player", | ||
| 223 | player: ["10S", "6H"], | ||
| 224 | dealer: ["9D", "?"], | ||
| 225 | player_total: 16, | ||
| 226 | dealer_total: 9, | ||
| 227 | bet: 10, | ||
| 228 | base_bet: 10, | ||
| 229 | balance: 990, | ||
| 230 | actions: ["hit", "stand", "double"], | ||
| 231 | result: "playing", | ||
| 232 | winnings: 0, | ||
| 233 | }, | ||
| 234 | error: null, | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render"); | ||
| 238 | assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render"); | ||
| 239 | assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase"); | ||
| 240 | assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders"); | ||
| 241 | |||
| 242 | socket.message({ | ||
| 243 | type: "view", | ||
| 244 | view: { | ||
| 245 | screen: "crash", | ||
| 246 | status: "running", | ||
| 247 | balance: 990, | ||
| 248 | bet: 10, | ||
| 249 | multiplier: 1.2, | ||
| 250 | potential: 12, | ||
| 251 | crash_point: null, | ||
| 252 | payout: 0, | ||
| 253 | started_at: Date.now(), | ||
| 254 | commit: "ab".repeat(32), | ||
| 255 | seed: null, | ||
| 256 | error: null, | ||
| 257 | }, | ||
| 258 | }); | ||
| 259 | assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); | ||
| 260 | assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); | ||
| 261 | assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); | ||
| 262 | assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment"); | ||
| 263 | |||
| 264 | socket.message({ | ||
| 265 | type: "view", | ||
| 266 | view: { | ||
| 267 | screen: "history", | ||
| 268 | records: [ | ||
| 269 | { | ||
| 270 | game: "slots", | ||
| 271 | bet: 10, | ||
| 272 | winnings: 20, | ||
| 273 | balance_after: 1010, | ||
| 274 | at: "2026-11-08 03:00:00", | ||
| 275 | details: { win_type: "win" }, | ||
| 276 | }, | ||
| 277 | ], | ||
| 278 | }, | ||
| 279 | }); | ||
| 280 | assert(byId("history-screen").hidden === false, "the history screen renders"); | ||
| 281 | assert(document.querySelector("table tbody tr"), "history rows render"); | ||
| 282 | |||
| 283 | socket.message({ type: "replaced" }); | ||
| 284 | assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab"); | ||
| 285 | |||
| 286 | await new Promise((resolve) => setTimeout(resolve, 30)); | ||
| 287 | dom.window.close(); | ||
| 288 | |||
| 289 | if (failures.length > 0) { | ||
| 290 | console.error("web smoke test failed:"); | ||
| 291 | for (const failure of failures) console.error(` - ${failure}`); | ||
| 292 | process.exit(1); | ||
| 293 | } | ||
| 294 | 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 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2020", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "noEmit": true, | ||
| 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||
| 9 | "skipLibCheck": true, | ||
| 10 | "forceConsistentCasingInFileNames": true | ||
| 11 | }, | ||
| 12 | "include": ["src"] | ||
| 13 | } | ||
diff --git a/webd/Cargo.toml b/webd/Cargo.toml new file mode 100644 index 0000000..8698404 --- /dev/null +++ b/webd/Cargo.toml | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | [package] | ||
| 2 | name = "zagan-bet-webd" | ||
| 3 | version = "5.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | license = "GPL-3.0-or-later" | ||
| 6 | |||
| 7 | [dependencies] | ||
| 8 | zagan-bet = { path = "..", default-features = false } | ||
| 9 | tungstenite = "0.21" | ||
| 10 | serde = { version = "1.0", features = ["derive"] } | ||
| 11 | serde_json = "1.0" | ||
| 12 | rand = "0.8" | ||
| 13 | 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 @@ | |||
| 1 | # `webd` — browser gateway | ||
| 2 | |||
| 3 | The browser cannot open the game's TCP/TLS stream, and the rules must not be | ||
| 4 | reimplemented in JavaScript. `webd` is a server-side frontend: one blocking | ||
| 5 | thread per browser session, one `ClientCore`, and one ordinary client TLS | ||
| 6 | connection to the game server. nginx terminates TLS and proxies `/ws` here. | ||
| 7 | |||
| 8 | It is a workspace member like `gui/`. Its dependencies never enter the TUI, | ||
| 9 | GUI or server build graph. | ||
| 10 | |||
| 11 | ## Build | ||
| 12 | |||
| 13 | ```sh | ||
| 14 | cargo build -p zagan-bet-webd --release | ||
| 15 | ``` | ||
| 16 | |||
| 17 | ## Configuration | ||
| 18 | |||
| 19 | | Variable | Default | Meaning | | ||
| 20 | | --- | --- | --- | | ||
| 21 | | `ZAGAN_WEB_BIND` | `127.0.0.1:8890` | Loopback address nginx proxies to | | ||
| 22 | | `ZAGAN_WEB_ORIGIN` | `https://zagan.2137697.xyz` | Exact `Origin` allowed | | ||
| 23 | | `ZAGAN_UPSTREAM` | `127.0.0.1:11525` | Game server `host:port` | | ||
| 24 | | `ZAGAN_UPSTREAM_FINGERPRINT` | **required** | SPKI SHA-256, 64 lowercase hex | | ||
| 25 | | `ZAGAN_GRACE_SECONDS` | `60` | Session survives this long without a socket | | ||
| 26 | | `ZAGAN_WEB_COOKIE_SECURE` | on for `https://` origin | `Secure` on the cookie | | ||
| 27 | |||
| 28 | The fingerprint is the same one the desktop client shows: | ||
| 29 | |||
| 30 | ```sh | ||
| 31 | openssl x509 -in cert.pem -pubkey -noout \ | ||
| 32 | | openssl pkey -pubin -outform DER \ | ||
| 33 | | sha256sum | ||
| 34 | ``` | ||
| 35 | |||
| 36 | There is no trust-on-first-use and no unsafe mode. A mismatch refuses to | ||
| 37 | connect and logs both fingerprints. Run the gateway on the same host as the | ||
| 38 | game server (or pin the remote key and accept the network hop). | ||
| 39 | |||
| 40 | ## systemd | ||
| 41 | |||
| 42 | Install `zagan-bet-webd` under `/usr/local/bin/`, then: | ||
| 43 | |||
| 44 | ```ini | ||
| 45 | # /etc/systemd/system/zagan-bet-webd.service | ||
| 46 | [Unit] | ||
| 47 | Description=zagan-bet browser gateway | ||
| 48 | After=network-online.target | ||
| 49 | Wants=network-online.target | ||
| 50 | |||
| 51 | [Service] | ||
| 52 | Type=simple | ||
| 53 | User=zagan | ||
| 54 | Group=zagan | ||
| 55 | EnvironmentFile=/etc/zagan-bet-webd.env | ||
| 56 | ExecStart=/usr/local/bin/zagan-bet-webd | ||
| 57 | Restart=on-failure | ||
| 58 | RestartSec=2 | ||
| 59 | NoNewPrivileges=true | ||
| 60 | PrivateTmp=true | ||
| 61 | ProtectSystem=strict | ||
| 62 | ProtectHome=true | ||
| 63 | |||
| 64 | [Install] | ||
| 65 | WantedBy=multi-user.target | ||
| 66 | ``` | ||
| 67 | |||
| 68 | ```sh | ||
| 69 | sudo install -m 0755 target/release/zagan-bet-webd /usr/local/bin/ | ||
| 70 | sudo install -d -m 0755 /etc | ||
| 71 | sudo tee /etc/zagan-bet-webd.env >/dev/null <<'ENV' | ||
| 72 | ZAGAN_UPSTREAM=127.0.0.1:11525 | ||
| 73 | ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> | ||
| 74 | ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz | ||
| 75 | ZAGAN_WEB_BIND=127.0.0.1:8890 | ||
| 76 | ENV | ||
| 77 | sudo systemctl daemon-reload | ||
| 78 | sudo systemctl enable --now zagan-bet-webd | ||
| 79 | ``` | ||
| 80 | |||
| 81 | nginx needs: | ||
| 82 | |||
| 83 | ```nginx | ||
| 84 | location = /ws { | ||
| 85 | proxy_pass http://127.0.0.1:8890; | ||
| 86 | proxy_http_version 1.1; | ||
| 87 | proxy_set_header Upgrade $http_upgrade; | ||
| 88 | proxy_set_header Connection "upgrade"; | ||
| 89 | proxy_set_header Host $host; | ||
| 90 | proxy_read_timeout 3600s; | ||
| 91 | proxy_send_timeout 3600s; | ||
| 92 | } | ||
| 93 | ``` | ||
| 94 | |||
| 95 | ## Behaviour | ||
| 96 | |||
| 97 | - The WebSocket handshake sets an HttpOnly, SameSite=Strict session cookie. | ||
| 98 | There is no credential storage in the browser or gateway. | ||
| 99 | - A browser close starts the grace window. Upstream stays connected, crash | ||
| 100 | ticks are still applied to the core, and a reconnect with the same cookie | ||
| 101 | gets the current `View`. Expiry closes upstream; the server forfeits a live | ||
| 102 | hand, exactly like a desktop disconnect. | ||
| 103 | - A second socket with the same cookie takes over: the old tab receives | ||
| 104 | `{"type":"replaced"}` and is closed. | ||
| 105 | - A gateway restart loses live sessions, so their rounds are forfeited. There | ||
| 106 | is no protocol-level resume. | ||
| 107 | - The browser sends `{"type":"command","command":...}` and receives | ||
| 108 | `view`/`notice`/`error`/`replaced`/`hello`/`pong` JSON. It never sees the | ||
| 109 | game wire or a certificate. | ||
| 110 | |||
| 111 | ## Security notes | ||
| 112 | |||
| 113 | - Bind loopback. Do not expose the gateway directly. | ||
| 114 | - Keep `ZAGAN_WEB_ORIGIN` exact. The cookie is SameSite=Strict, and the | ||
| 115 | `Origin` check is a second lock on cross-site WebSocket attempts. | ||
| 116 | - The gateway is play-money only in v1. No payments, KYC or real-money copy. | ||
| 117 | - `ZAGAN_UPSTREAM_FINGERPRINT` is the browser's credential path: a wrong or | ||
| 118 | missing pin must fail closed. | ||
diff --git a/webd/src/config.rs b/webd/src/config.rs new file mode 100644 index 0000000..d3ada8f --- /dev/null +++ b/webd/src/config.rs | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | //! Gateway configuration from the environment. | ||
| 2 | //! | ||
| 3 | //! The upstream key is mandatory. There is no trust-on-first-use and no unsafe | ||
| 4 | //! mode: the browser's credentials ride on this connection. | ||
| 5 | |||
| 6 | use std::env; | ||
| 7 | use std::time::Duration; | ||
| 8 | |||
| 9 | pub const DEFAULT_BIND: &str = "127.0.0.1:8890"; | ||
| 10 | pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525"; | ||
| 11 | pub const DEFAULT_GRACE_SECONDS: u64 = 60; | ||
| 12 | pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz"; | ||
| 13 | |||
| 14 | #[derive(Debug, Clone)] | ||
| 15 | pub struct Config { | ||
| 16 | /// Loopback address nginx proxies `/ws` to. | ||
| 17 | pub bind: String, | ||
| 18 | /// Exact browser origin allowed on the WebSocket handshake. | ||
| 19 | pub origin: String, | ||
| 20 | /// Game server address, `host:port`. | ||
| 21 | pub upstream: String, | ||
| 22 | /// Lowercase hex SHA-256 of the upstream certificate's SPKI. | ||
| 23 | pub fingerprint: String, | ||
| 24 | /// How long a session survives without a browser socket. | ||
| 25 | pub grace: Duration, | ||
| 26 | /// `Secure` on the session cookie. Off only for local http testing. | ||
| 27 | pub cookie_secure: bool, | ||
| 28 | } | ||
| 29 | |||
| 30 | impl Config { | ||
| 31 | pub fn from_env() -> Result<Self, String> { | ||
| 32 | let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND); | ||
| 33 | let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN); | ||
| 34 | if !origin.starts_with("http://") && !origin.starts_with("https://") { | ||
| 35 | return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string()); | ||
| 36 | } | ||
| 37 | let origin = origin.trim_end_matches('/').to_string(); | ||
| 38 | |||
| 39 | let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM); | ||
| 40 | let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?; | ||
| 41 | let fingerprint = fingerprint.trim().to_ascii_lowercase(); | ||
| 42 | if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { | ||
| 43 | return Err( | ||
| 44 | "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI" | ||
| 45 | .to_string(), | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") { | ||
| 50 | Ok(raw) => raw | ||
| 51 | .trim() | ||
| 52 | .parse::<u64>() | ||
| 53 | .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?, | ||
| 54 | Err(_) => DEFAULT_GRACE_SECONDS, | ||
| 55 | }; | ||
| 56 | |||
| 57 | let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") { | ||
| 58 | Ok(raw) => matches!( | ||
| 59 | raw.trim().to_ascii_lowercase().as_str(), | ||
| 60 | "1" | "true" | "yes" | ||
| 61 | ), | ||
| 62 | Err(_) => origin.starts_with("https://"), | ||
| 63 | }; | ||
| 64 | |||
| 65 | Ok(Self { | ||
| 66 | bind, | ||
| 67 | origin, | ||
| 68 | upstream, | ||
| 69 | fingerprint, | ||
| 70 | grace: Duration::from_secs(grace_seconds), | ||
| 71 | cookie_secure, | ||
| 72 | }) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | fn value(name: &str, default: &str) -> String { | ||
| 77 | env::var(name) | ||
| 78 | .ok() | ||
| 79 | .map(|value| value.trim().to_string()) | ||
| 80 | .filter(|value| !value.is_empty()) | ||
| 81 | .unwrap_or_else(|| default.to_string()) | ||
| 82 | } | ||
| 83 | |||
| 84 | fn required(name: &str) -> Result<String, String> { | ||
| 85 | env::var(name) | ||
| 86 | .ok() | ||
| 87 | .map(|value| value.trim().to_string()) | ||
| 88 | .filter(|value| !value.is_empty()) | ||
| 89 | .ok_or_else(|| { | ||
| 90 | format!( | ||
| 91 | "{name} is required: the gateway pins the upstream key. \ | ||
| 92 | Use the SPKI fingerprint the desktop client shows." | ||
| 93 | ) | ||
| 94 | }) | ||
| 95 | } | ||
diff --git a/webd/src/main.rs b/webd/src/main.rs new file mode 100644 index 0000000..c2cf0ff --- /dev/null +++ b/webd/src/main.rs | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | //! Browser gateway for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! nginx terminates TLS, serves the static app and proxies `/ws` here. This | ||
| 4 | //! process owns one upstream TLS connection and one `ClientCore` per browser | ||
| 5 | //! session, so the browser never speaks the game wire protocol and never sees | ||
| 6 | //! a certificate. | ||
| 7 | |||
| 8 | mod config; | ||
| 9 | mod session; | ||
| 10 | |||
| 11 | use std::net::TcpListener; | ||
| 12 | use std::sync::Arc; | ||
| 13 | use std::thread; | ||
| 14 | |||
| 15 | use config::Config; | ||
| 16 | |||
| 17 | fn main() { | ||
| 18 | let config = match Config::from_env() { | ||
| 19 | Ok(config) => config, | ||
| 20 | Err(error) => { | ||
| 21 | eprintln!("webd: {error}"); | ||
| 22 | std::process::exit(2); | ||
| 23 | } | ||
| 24 | }; | ||
| 25 | |||
| 26 | let listener = match TcpListener::bind(&config.bind) { | ||
| 27 | Ok(listener) => listener, | ||
| 28 | Err(error) => { | ||
| 29 | eprintln!("webd: could not bind {}: {error}", config.bind); | ||
| 30 | std::process::exit(1); | ||
| 31 | } | ||
| 32 | }; | ||
| 33 | |||
| 34 | println!( | ||
| 35 | "webd listening on ws://{} for origin {}; upstream {} pinned to {}; grace {}s", | ||
| 36 | config.bind, | ||
| 37 | config.origin, | ||
| 38 | config.upstream, | ||
| 39 | config.fingerprint, | ||
| 40 | config.grace.as_secs() | ||
| 41 | ); | ||
| 42 | |||
| 43 | let registry = session::new_registry(); | ||
| 44 | for stream in listener.incoming() { | ||
| 45 | let stream = match stream { | ||
| 46 | Ok(stream) => stream, | ||
| 47 | Err(error) => { | ||
| 48 | eprintln!("webd: accept failed: {error}"); | ||
| 49 | continue; | ||
| 50 | } | ||
| 51 | }; | ||
| 52 | let _ = stream.set_nodelay(true); | ||
| 53 | let config = config.clone(); | ||
| 54 | let registry = Arc::clone(®istry); | ||
| 55 | thread::spawn(move || { | ||
| 56 | if let Err(error) = session::accept(stream, config, registry) { | ||
| 57 | eprintln!("webd: {error}"); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | } | ||
| 61 | } | ||
diff --git a/webd/src/session.rs b/webd/src/session.rs new file mode 100644 index 0000000..8a20721 --- /dev/null +++ b/webd/src/session.rs | |||
| @@ -0,0 +1,623 @@ | |||
| 1 | //! One gateway session: a browser socket, one upstream TLS connection and one | ||
| 2 | //! `ClientCore`. | ||
| 3 | //! | ||
| 4 | //! The browser sends `Command` JSON and renders `View` JSON. All game state, | ||
| 5 | //! protocol framing and balance arithmetic stay in the Rust core. | ||
| 6 | |||
| 7 | use std::collections::HashMap; | ||
| 8 | use std::net::TcpStream; | ||
| 9 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 10 | use std::sync::{Arc, Mutex, mpsc}; | ||
| 11 | use std::thread; | ||
| 12 | use std::time::{Duration, Instant}; | ||
| 13 | |||
| 14 | use rusqlite::Connection; | ||
| 15 | use serde::{Deserialize, Serialize}; | ||
| 16 | use tungstenite::error::Error as WsError; | ||
| 17 | use tungstenite::http::HeaderValue; | ||
| 18 | use tungstenite::http::header::ORIGIN; | ||
| 19 | use tungstenite::protocol::{WebSocket, WebSocketConfig}; | ||
| 20 | use tungstenite::{Message, accept_hdr_with_config}; | ||
| 21 | |||
| 22 | use zagan_bet::client::core::{ClientCore, Effect}; | ||
| 23 | use zagan_bet::client::database; | ||
| 24 | use zagan_bet::client::transport::{self, TlsStream}; | ||
| 25 | use zagan_bet::client::view::{Command, View}; | ||
| 26 | use zagan_bet::protocol::Packet; | ||
| 27 | use zagan_bet::version::VERSION; | ||
| 28 | |||
| 29 | use crate::config::Config; | ||
| 30 | |||
| 31 | const COOKIE_NAME: &str = "zagan_session"; | ||
| 32 | /// On HTTPS, use the `__Host-` prefix so a sibling origin cannot set a | ||
| 33 | /// domain-scoped cookie and fixate the session. | ||
| 34 | const HOST_COOKIE_NAME: &str = "__Host-zagan_session"; | ||
| 35 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); | ||
| 36 | const POLL: Duration = Duration::from_millis(10); | ||
| 37 | |||
| 38 | pub enum SocketEvent { | ||
| 39 | Attach(WebSocket<TcpStream>), | ||
| 40 | } | ||
| 41 | |||
| 42 | /// Session id → generation and the thread that owns it. | ||
| 43 | /// | ||
| 44 | /// The generation lets an ending thread remove its own entry without deleting | ||
| 45 | /// a replacement session that reused the same cookie. | ||
| 46 | pub type Registry = Arc<Mutex<HashMap<String, (u64, mpsc::Sender<SocketEvent>)>>>; | ||
| 47 | |||
| 48 | static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); | ||
| 49 | |||
| 50 | pub fn new_registry() -> Registry { | ||
| 51 | Arc::new(Mutex::new(HashMap::new())) | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Complete the WebSocket handshake, then attach this socket to an existing | ||
| 55 | /// session or start one. | ||
| 56 | pub fn accept(stream: TcpStream, config: Config, registry: Registry) -> Result<(), String> { | ||
| 57 | let mut session_id = None; | ||
| 58 | let origin = config.origin.clone(); | ||
| 59 | let secure = config.cookie_secure; | ||
| 60 | |||
| 61 | let ws_config = WebSocketConfig { | ||
| 62 | // A command or view is a few KiB at most; keep a compromised page from | ||
| 63 | // making the gateway buffer tens of megabytes. | ||
| 64 | max_message_size: Some(64 * 1024), | ||
| 65 | max_frame_size: Some(64 * 1024), | ||
| 66 | ..WebSocketConfig::default() | ||
| 67 | }; | ||
| 68 | let mut ws = accept_hdr_with_config( | ||
| 69 | stream, | ||
| 70 | |request: &tungstenite::handshake::server::Request, | ||
| 71 | mut response: tungstenite::handshake::server::Response| { | ||
| 72 | if let Some(header) = request.headers().get(ORIGIN) { | ||
| 73 | if header.to_str().ok() != Some(origin.as_str()) { | ||
| 74 | return Err(tungstenite::http::Response::builder() | ||
| 75 | .status(403) | ||
| 76 | .body(Some("origin not allowed".to_string())) | ||
| 77 | .expect("static response")); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | let found = request | ||
| 82 | .headers() | ||
| 83 | .get("cookie") | ||
| 84 | .and_then(|value| value.to_str().ok()) | ||
| 85 | .and_then(cookie_id); | ||
| 86 | let (id, fresh) = match found { | ||
| 87 | Some(id) => (id, false), | ||
| 88 | None => (random_id(), true), | ||
| 89 | }; | ||
| 90 | if fresh { | ||
| 91 | let mut cookie = format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict"); | ||
| 92 | if secure { | ||
| 93 | cookie.push_str("; Secure"); | ||
| 94 | } | ||
| 95 | if let Ok(value) = HeaderValue::from_str(&cookie) { | ||
| 96 | response.headers_mut().append("set-cookie", value); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | session_id = Some(id); | ||
| 100 | Ok(response) | ||
| 101 | }, | ||
| 102 | Some(ws_config), | ||
| 103 | ) | ||
| 104 | .map_err(|error| format!("WebSocket handshake failed: {error}"))?; | ||
| 105 | |||
| 106 | let id = session_id.ok_or_else(|| "handshake produced no session id".to_string())?; | ||
| 107 | ws.get_ref() | ||
| 108 | .set_nonblocking(true) | ||
| 109 | .map_err(|error| format!("could not make the browser socket nonblocking: {error}"))?; | ||
| 110 | |||
| 111 | let (sender, receiver) = mpsc::channel(); | ||
| 112 | let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); | ||
| 113 | { | ||
| 114 | let mut sessions = registry.lock().map_err(|_| "session registry poisoned")?; | ||
| 115 | if let Some((_, existing)) = sessions.get(&id).cloned() { | ||
| 116 | match existing.send(SocketEvent::Attach(ws)) { | ||
| 117 | Ok(()) => return Ok(()), | ||
| 118 | Err(mpsc::SendError(SocketEvent::Attach(returned))) => { | ||
| 119 | sessions.remove(&id); | ||
| 120 | ws = returned; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | sessions.insert(id.clone(), (generation, sender.clone())); | ||
| 125 | } | ||
| 126 | |||
| 127 | thread::spawn(move || { | ||
| 128 | let session = match Session::new(id.clone(), config) { | ||
| 129 | Ok(session) => Some(session), | ||
| 130 | Err(error) => { | ||
| 131 | eprintln!("[webd {id}] could not start: {error}"); | ||
| 132 | None | ||
| 133 | } | ||
| 134 | }; | ||
| 135 | if let Some(mut session) = session { | ||
| 136 | session.attach(ws); | ||
| 137 | session.poll_loop(&receiver); | ||
| 138 | } | ||
| 139 | if let Ok(mut sessions) = registry.lock() { | ||
| 140 | let own = sessions | ||
| 141 | .get(&id) | ||
| 142 | .map(|(generation_now, _)| *generation_now == generation) | ||
| 143 | .unwrap_or(false); | ||
| 144 | if own { | ||
| 145 | sessions.remove(&id); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | eprintln!("[webd {id}] session ended"); | ||
| 149 | }); | ||
| 150 | |||
| 151 | Ok(()) | ||
| 152 | } | ||
| 153 | |||
| 154 | fn cookie_id(raw: &str) -> Option<String> { | ||
| 155 | for pair in raw.split(';') { | ||
| 156 | let (name, value) = pair.split_once('=')?; | ||
| 157 | if matches!(name.trim(), COOKIE_NAME | HOST_COOKIE_NAME) { | ||
| 158 | let value = value.trim(); | ||
| 159 | if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { | ||
| 160 | return Some(value.to_ascii_lowercase()); | ||
| 161 | } | ||
| 162 | return None; | ||
| 163 | } | ||
| 164 | } | ||
| 165 | None | ||
| 166 | } | ||
| 167 | |||
| 168 | fn random_id() -> String { | ||
| 169 | use rand::RngCore; | ||
| 170 | let mut bytes = [0u8; 32]; | ||
| 171 | rand::thread_rng().fill_bytes(&mut bytes); | ||
| 172 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | ||
| 173 | } | ||
| 174 | |||
| 175 | #[derive(Deserialize)] | ||
| 176 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 177 | enum ClientMessage { | ||
| 178 | Command { command: Command }, | ||
| 179 | Ping, | ||
| 180 | } | ||
| 181 | |||
| 182 | #[derive(Serialize)] | ||
| 183 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 184 | enum ServerMessage<'a> { | ||
| 185 | Hello { | ||
| 186 | version: &'a str, | ||
| 187 | grace_seconds: u64, | ||
| 188 | /// Signed-in user, so a refresh during a game can skip the login card. | ||
| 189 | user: Option<&'a str>, | ||
| 190 | /// Estimated game-server Unix time, milliseconds. | ||
| 191 | time: u64, | ||
| 192 | }, | ||
| 193 | View { | ||
| 194 | view: &'a View, | ||
| 195 | }, | ||
| 196 | Notice { | ||
| 197 | text: &'a str, | ||
| 198 | }, | ||
| 199 | Error { | ||
| 200 | text: &'a str, | ||
| 201 | }, | ||
| 202 | Replaced, | ||
| 203 | Pong, | ||
| 204 | } | ||
| 205 | |||
| 206 | struct Session { | ||
| 207 | id: String, | ||
| 208 | config: Config, | ||
| 209 | core: ClientCore, | ||
| 210 | /// In-memory trust store seeded with the pinned fingerprint. No player | ||
| 211 | /// credentials are ever stored here. | ||
| 212 | trust: Connection, | ||
| 213 | upstream: Option<TlsStream>, | ||
| 214 | ws: Option<WebSocket<TcpStream>>, | ||
| 215 | pending_id: Option<u64>, | ||
| 216 | pending_since: Option<Instant>, | ||
| 217 | grace_until: Option<Instant>, | ||
| 218 | drop_upstream: bool, | ||
| 219 | } | ||
| 220 | |||
| 221 | impl Session { | ||
| 222 | fn new(id: String, config: Config) -> Result<Self, String> { | ||
| 223 | let trust = Connection::open_in_memory() | ||
| 224 | .map_err(|error| format!("could not open the trust store: {error}"))?; | ||
| 225 | database::init_db(&trust); | ||
| 226 | database::save_fingerprint(&trust, &config.upstream, &config.fingerprint) | ||
| 227 | .map_err(|error| format!("could not pin the upstream key: {error}"))?; | ||
| 228 | |||
| 229 | Ok(Self { | ||
| 230 | id, | ||
| 231 | config, | ||
| 232 | core: ClientCore::new(), | ||
| 233 | trust, | ||
| 234 | upstream: None, | ||
| 235 | ws: None, | ||
| 236 | pending_id: None, | ||
| 237 | pending_since: None, | ||
| 238 | grace_until: None, | ||
| 239 | drop_upstream: false, | ||
| 240 | }) | ||
| 241 | } | ||
| 242 | |||
| 243 | fn attach(&mut self, ws: WebSocket<TcpStream>) { | ||
| 244 | if let Some(mut old) = self.ws.take() { | ||
| 245 | send_json(&mut old, &ServerMessage::Replaced); | ||
| 246 | close_socket(&mut old); | ||
| 247 | } | ||
| 248 | eprintln!("[webd {}] browser attached", self.id); | ||
| 249 | self.ws = Some(ws); | ||
| 250 | self.grace_until = None; | ||
| 251 | let mut ws = self.ws.take().expect("just attached"); | ||
| 252 | if let Err(text) = self.ensure_upstream() { | ||
| 253 | send_json(&mut ws, &ServerMessage::Error { text: &text }); | ||
| 254 | } | ||
| 255 | send_json( | ||
| 256 | &mut ws, | ||
| 257 | &ServerMessage::Hello { | ||
| 258 | version: VERSION, | ||
| 259 | grace_seconds: self.config.grace.as_secs(), | ||
| 260 | user: self.core.user(), | ||
| 261 | time: self.core.server_now_ms(), | ||
| 262 | }, | ||
| 263 | ); | ||
| 264 | self.send_view(&mut ws); | ||
| 265 | self.ws = Some(ws); | ||
| 266 | } | ||
| 267 | |||
| 268 | fn poll_loop(&mut self, receiver: &mpsc::Receiver<SocketEvent>) { | ||
| 269 | loop { | ||
| 270 | match receiver.recv_timeout(POLL) { | ||
| 271 | Ok(SocketEvent::Attach(ws)) => self.attach(ws), | ||
| 272 | Err(mpsc::RecvTimeoutError::Timeout) => {} | ||
| 273 | Err(mpsc::RecvTimeoutError::Disconnected) => break, | ||
| 274 | } | ||
| 275 | |||
| 276 | self.pump_browser(); | ||
| 277 | self.pump_upstream(); | ||
| 278 | self.check_request_timeout(); | ||
| 279 | |||
| 280 | if self.ws.is_some() { | ||
| 281 | self.grace_until = None; | ||
| 282 | } else if let Some(deadline) = self.grace_until { | ||
| 283 | if Instant::now() >= deadline { | ||
| 284 | break; | ||
| 285 | } | ||
| 286 | } else { | ||
| 287 | self.grace_until = Some(Instant::now() + self.config.grace); | ||
| 288 | } | ||
| 289 | } | ||
| 290 | |||
| 291 | // Dropping the TLS stream closes upstream; the server forfeits any | ||
| 292 | // live hand, exactly like a desktop client disconnecting. | ||
| 293 | self.upstream = None; | ||
| 294 | eprintln!("[webd {}] grace expired", self.id); | ||
| 295 | } | ||
| 296 | |||
| 297 | fn pump_browser(&mut self) { | ||
| 298 | let Some(mut ws) = self.ws.take() else { | ||
| 299 | return; | ||
| 300 | }; | ||
| 301 | let mut keep = true; | ||
| 302 | |||
| 303 | loop { | ||
| 304 | match ws.read() { | ||
| 305 | Ok(Message::Text(text)) => { | ||
| 306 | if !self.handle_browser_text(&text, &mut ws) { | ||
| 307 | keep = false; | ||
| 308 | break; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | Ok(Message::Close(_)) => { | ||
| 312 | keep = false; | ||
| 313 | break; | ||
| 314 | } | ||
| 315 | Ok(Message::Ping(payload)) => { | ||
| 316 | let _ = send_message(&mut ws, Message::Pong(payload)); | ||
| 317 | } | ||
| 318 | Ok(Message::Binary(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => {} | ||
| 319 | Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => break, | ||
| 320 | Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => { | ||
| 321 | keep = false; | ||
| 322 | break; | ||
| 323 | } | ||
| 324 | Err(_) => { | ||
| 325 | keep = false; | ||
| 326 | break; | ||
| 327 | } | ||
| 328 | } | ||
| 329 | } | ||
| 330 | |||
| 331 | if keep { | ||
| 332 | self.ws = Some(ws); | ||
| 333 | } else { | ||
| 334 | self.grace_until = Some(Instant::now() + self.config.grace); | ||
| 335 | self.pending_id = None; | ||
| 336 | self.pending_since = None; | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | fn handle_browser_text(&mut self, text: &str, ws: &mut WebSocket<TcpStream>) -> bool { | ||
| 341 | let message = match serde_json::from_str::<ClientMessage>(text) { | ||
| 342 | Ok(message) => message, | ||
| 343 | Err(error) => { | ||
| 344 | send_json( | ||
| 345 | ws, | ||
| 346 | &ServerMessage::Error { | ||
| 347 | text: &format!("Malformed message: {error}"), | ||
| 348 | }, | ||
| 349 | ); | ||
| 350 | return true; | ||
| 351 | } | ||
| 352 | }; | ||
| 353 | |||
| 354 | match message { | ||
| 355 | ClientMessage::Ping => send_json(ws, &ServerMessage::Pong), | ||
| 356 | ClientMessage::Command { command } => { | ||
| 357 | if let Err(text) = self.ensure_upstream() { | ||
| 358 | send_json(ws, &ServerMessage::Error { text: &text }); | ||
| 359 | self.send_view(ws); | ||
| 360 | return true; | ||
| 361 | } | ||
| 362 | let effects = self.core.command(command); | ||
| 363 | self.apply_effects(effects, Some(ws)); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | true | ||
| 367 | } | ||
| 368 | |||
| 369 | fn ensure_upstream(&mut self) -> Result<(), String> { | ||
| 370 | if self.upstream.is_some() { | ||
| 371 | return Ok(()); | ||
| 372 | } | ||
| 373 | |||
| 374 | let mut trust = |_prompt: &transport::TrustPrompt| false; | ||
| 375 | match transport::connect(&self.config.upstream, &self.trust, false, &mut trust) { | ||
| 376 | Ok(connected) => { | ||
| 377 | self.core.set_connected(true); | ||
| 378 | self.core.set_server_time(connected.server_time); | ||
| 379 | self.core.set_host_info( | ||
| 380 | Some(self.config.upstream.clone()), | ||
| 381 | Some(self.config.fingerprint.clone()), | ||
| 382 | ); | ||
| 383 | self.upstream = Some(connected.stream); | ||
| 384 | eprintln!( | ||
| 385 | "[webd {}] upstream connected: {} ({}), ping {:.1}ms", | ||
| 386 | self.id, | ||
| 387 | self.config.upstream, | ||
| 388 | connected.server_version, | ||
| 389 | connected.elapsed.as_secs_f64() * 1000.0 | ||
| 390 | ); | ||
| 391 | Ok(()) | ||
| 392 | } | ||
| 393 | Err(error) => { | ||
| 394 | self.core.reset_after_disconnect(); | ||
| 395 | Err(format!("Upstream connection failed: {error}")) | ||
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | fn pump_upstream(&mut self) { | ||
| 401 | let Some(mut upstream) = self.upstream.take() else { | ||
| 402 | return; | ||
| 403 | }; | ||
| 404 | let mut ws = self.ws.take(); | ||
| 405 | let mut disconnected = None; | ||
| 406 | |||
| 407 | loop { | ||
| 408 | match upstream.read_available_packet() { | ||
| 409 | Ok(Some(Packet::Event { body, .. })) => { | ||
| 410 | if let Some(effects) = self.core.handle_async_line(&body) { | ||
| 411 | self.apply_effects(effects, ws.as_mut()); | ||
| 412 | } | ||
| 413 | } | ||
| 414 | Ok(Some(Packet::Reply { id, body })) => { | ||
| 415 | if self.pending_id == Some(id) { | ||
| 416 | self.pending_id = None; | ||
| 417 | self.pending_since = None; | ||
| 418 | } | ||
| 419 | let effects = self.core.handle_reply_id(id, &body); | ||
| 420 | self.apply_effects(effects, ws.as_mut()); | ||
| 421 | } | ||
| 422 | Ok(None) => break, | ||
| 423 | Err(error) => { | ||
| 424 | disconnected = Some(error.to_string()); | ||
| 425 | break; | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | if self.drop_upstream { | ||
| 431 | self.drop_upstream = false; | ||
| 432 | drop(upstream); | ||
| 433 | } else if disconnected.is_none() { | ||
| 434 | self.upstream = Some(upstream); | ||
| 435 | } | ||
| 436 | |||
| 437 | if let Some(message) = disconnected { | ||
| 438 | eprintln!("[webd {}] upstream lost: {message}", self.id); | ||
| 439 | self.core.reset_after_disconnect(); | ||
| 440 | self.pending_id = None; | ||
| 441 | self.pending_since = None; | ||
| 442 | if let Some(ws) = ws.as_mut() { | ||
| 443 | send_json( | ||
| 444 | ws, | ||
| 445 | &ServerMessage::Error { | ||
| 446 | text: "Connection to the game server was lost", | ||
| 447 | }, | ||
| 448 | ); | ||
| 449 | self.send_view(ws); | ||
| 450 | } | ||
| 451 | } | ||
| 452 | |||
| 453 | self.ws = ws; | ||
| 454 | } | ||
| 455 | |||
| 456 | fn check_request_timeout(&mut self) { | ||
| 457 | let Some(sent) = self.pending_since else { | ||
| 458 | return; | ||
| 459 | }; | ||
| 460 | if sent.elapsed() < REQUEST_TIMEOUT { | ||
| 461 | return; | ||
| 462 | } | ||
| 463 | |||
| 464 | let id = self.pending_id.take(); | ||
| 465 | self.pending_since = None; | ||
| 466 | self.upstream = None; | ||
| 467 | let mut ws = self.ws.take(); | ||
| 468 | if let Some(id) = id { | ||
| 469 | let effects = self.core.handle_reply_id(id, "ERROR Request timed out"); | ||
| 470 | self.apply_effects(effects, ws.as_mut()); | ||
| 471 | if let Some(ws) = ws.as_mut() { | ||
| 472 | send_json( | ||
| 473 | ws, | ||
| 474 | &ServerMessage::Notice { | ||
| 475 | text: "Request timed out. Disconnected.", | ||
| 476 | }, | ||
| 477 | ); | ||
| 478 | } | ||
| 479 | } | ||
| 480 | self.core.reset_after_disconnect(); | ||
| 481 | if let Some(ws) = ws.as_mut() { | ||
| 482 | self.send_view(ws); | ||
| 483 | } | ||
| 484 | self.ws = ws; | ||
| 485 | } | ||
| 486 | |||
| 487 | fn apply_effects(&mut self, effects: Vec<Effect>, mut ws: Option<&mut WebSocket<TcpStream>>) { | ||
| 488 | for effect in effects { | ||
| 489 | match effect { | ||
| 490 | Effect::Print(lines) => { | ||
| 491 | // A multi-line print is a terminal table; the structured | ||
| 492 | // `View` carries that data to the browser. | ||
| 493 | if lines.len() == 1 { | ||
| 494 | if let (Some(socket), Some(line)) = (ws.as_deref_mut(), lines.first()) { | ||
| 495 | send_json(socket, &ServerMessage::Notice { text: line }); | ||
| 496 | } | ||
| 497 | } | ||
| 498 | } | ||
| 499 | Effect::SendRequest { id, payload } => { | ||
| 500 | self.pending_id = Some(id); | ||
| 501 | self.pending_since = Some(Instant::now()); | ||
| 502 | match self.ensure_upstream() { | ||
| 503 | Ok(()) => { | ||
| 504 | if let Some(upstream) = self.upstream.as_mut() { | ||
| 505 | if let Err(error) = upstream.write_request(id, None, &payload) { | ||
| 506 | let text = format!("Could not send request: {error}"); | ||
| 507 | if let Some(socket) = ws.as_deref_mut() { | ||
| 508 | send_json(socket, &ServerMessage::Error { text: &text }); | ||
| 509 | } | ||
| 510 | self.drop_upstream = true; | ||
| 511 | self.upstream = None; | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | Err(text) => { | ||
| 516 | self.pending_id = None; | ||
| 517 | self.pending_since = None; | ||
| 518 | if let Some(socket) = ws.as_deref_mut() { | ||
| 519 | send_json(socket, &ServerMessage::Error { text: &text }); | ||
| 520 | } | ||
| 521 | } | ||
| 522 | } | ||
| 523 | } | ||
| 524 | Effect::Disconnect => { | ||
| 525 | self.upstream = None; | ||
| 526 | self.drop_upstream = true; | ||
| 527 | self.core.reset_after_disconnect(); | ||
| 528 | } | ||
| 529 | Effect::Render(_) | Effect::ClearRegion | Effect::Quit => {} | ||
| 530 | } | ||
| 531 | } | ||
| 532 | |||
| 533 | if let Some(socket) = ws { | ||
| 534 | self.send_view(socket); | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | fn send_view(&self, ws: &mut WebSocket<TcpStream>) { | ||
| 539 | let Some(view) = self.core.view() else { | ||
| 540 | return; | ||
| 541 | }; | ||
| 542 | send_json(ws, &ServerMessage::View { view: &view }); | ||
| 543 | } | ||
| 544 | } | ||
| 545 | |||
| 546 | fn send_message(ws: &mut WebSocket<TcpStream>, message: Message) -> Result<(), WsError> { | ||
| 547 | // The read side is nonblocking; writes are small and should not fail | ||
| 548 | // half-way because a browser is briefly not draining its socket. | ||
| 549 | let _ = ws.get_ref().set_nonblocking(false); | ||
| 550 | let result = ws.send(message); | ||
| 551 | let _ = ws.get_ref().set_nonblocking(true); | ||
| 552 | result | ||
| 553 | } | ||
| 554 | |||
| 555 | fn send_json(ws: &mut WebSocket<TcpStream>, message: &ServerMessage<'_>) { | ||
| 556 | let Ok(text) = serde_json::to_string(message) else { | ||
| 557 | return; | ||
| 558 | }; | ||
| 559 | let _ = send_message(ws, Message::Text(text)); | ||
| 560 | } | ||
| 561 | |||
| 562 | fn close_socket(ws: &mut WebSocket<TcpStream>) { | ||
| 563 | let _ = ws.get_ref().set_nonblocking(false); | ||
| 564 | let _ = ws.close(None); | ||
| 565 | let _ = ws.get_ref().set_nonblocking(true); | ||
| 566 | } | ||
| 567 | |||
| 568 | #[cfg(test)] | ||
| 569 | mod tests { | ||
| 570 | use super::*; | ||
| 571 | use zagan_bet::client::view::{LobbyView, View}; | ||
| 572 | |||
| 573 | #[test] | ||
| 574 | fn reads_only_well_formed_session_cookies() { | ||
| 575 | let id = "a".repeat(64); | ||
| 576 | assert_eq!( | ||
| 577 | cookie_id(&format!("other=1; {COOKIE_NAME}={id}; theme=dark")), | ||
| 578 | Some(id) | ||
| 579 | ); | ||
| 580 | assert_eq!(cookie_id(&format!("{COOKIE_NAME}=short")), None); | ||
| 581 | assert_eq!(cookie_id("other=1"), None); | ||
| 582 | assert_eq!(cookie_id(""), None); | ||
| 583 | } | ||
| 584 | |||
| 585 | #[test] | ||
| 586 | fn parses_a_browser_command_and_rejects_nonsense() { | ||
| 587 | let message: ClientMessage = serde_json::from_str( | ||
| 588 | r#"{"type":"command","command":{"Login":{"user":"alice","credential":"abc"}}}"#, | ||
| 589 | ) | ||
| 590 | .expect("login command"); | ||
| 591 | match message { | ||
| 592 | ClientMessage::Command { command } => { | ||
| 593 | assert_eq!( | ||
| 594 | command, | ||
| 595 | Command::Login { | ||
| 596 | user: "alice".to_string(), | ||
| 597 | credential: "abc".to_string() | ||
| 598 | } | ||
| 599 | ); | ||
| 600 | } | ||
| 601 | _ => panic!("expected a command"), | ||
| 602 | } | ||
| 603 | |||
| 604 | assert!(serde_json::from_str::<ClientMessage>("{}").is_err()); | ||
| 605 | assert!(serde_json::from_str::<ClientMessage>(r#"{"type":"nope"}"#).is_err()); | ||
| 606 | } | ||
| 607 | |||
| 608 | #[test] | ||
| 609 | fn serializes_a_view_for_the_browser() { | ||
| 610 | let view = View::Lobby(LobbyView { | ||
| 611 | connected: true, | ||
| 612 | user: Some("alice".to_string()), | ||
| 613 | account: Some("1".to_string()), | ||
| 614 | balance: Some(1000), | ||
| 615 | games: vec![("slots".to_string(), "Slots".to_string())], | ||
| 616 | login_error: None, | ||
| 617 | }); | ||
| 618 | let json = serde_json::to_string(&ServerMessage::View { view: &view }).unwrap(); | ||
| 619 | assert!(json.contains(r#""type":"view""#)); | ||
| 620 | assert!(json.contains(r#""screen":"lobby""#)); | ||
| 621 | assert!(json.contains(r#""user":"alice""#)); | ||
| 622 | } | ||
| 623 | } | ||
