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 /web/play/test | |
| 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.
Diffstat (limited to 'web/play/test')
| -rw-r--r-- | web/play/test/smoke.mjs | 294 |
1 files changed, 294 insertions, 0 deletions
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"); | ||
