From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- web/play/test/smoke.mjs | 294 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 web/play/test/smoke.mjs (limited to 'web/play/test') diff --git a/web/play/test/smoke.mjs b/web/play/test/smoke.mjs new file mode 100644 index 0000000..c1274bc --- /dev/null +++ b/web/play/test/smoke.mjs @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import { webcrypto } from "node:crypto"; +import { JSDOM, VirtualConsole } from "jsdom"; + +const html = fs.readFileSync(new URL("../index.html", import.meta.url), "utf8") + .replace('', ""); +const bundle = fs.readFileSync(new URL("../app.js", import.meta.url), "utf8"); + +const failures = []; +const virtualConsole = new VirtualConsole(); +virtualConsole.on("jsdomError", (error) => failures.push(String(error))); + +const dom = new JSDOM(html, { + url: "http://127.0.0.1:8899/play/", + runScripts: "outside-only", + pretendToBeVisual: true, + virtualConsole, +}); +const { window } = dom; + +Object.defineProperty(window, "crypto", { value: webcrypto }); +window.devicePixelRatio = 1; +window.confirm = () => true; + +class MockWebSocket { + static OPEN = 1; + static instances = []; + readyState = MockWebSocket.OPEN; + sent = []; + listeners = new Map(); + + constructor(url) { + this.url = url; + MockWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open", {})); + } + + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + send(raw) { + this.sent.push(JSON.parse(raw)); + } + + close() { + this.emit("close", {}); + } + + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + message(message) { + this.emit("message", { data: JSON.stringify(message) }); + } +} +window.WebSocket = MockWebSocket; +window.ResizeObserver = class { + observe() {} + disconnect() {} +}; +window.requestAnimationFrame = (callback) => window.setTimeout(() => callback(Date.now()), 0); +window.cancelAnimationFrame = (handle) => window.clearTimeout(handle); + +const context = new Proxy( + {}, + { + get(target, key) { + if (key === "measureText") return () => ({ width: 10 }); + if (key in target) return target[key]; + return () => undefined; + }, + set(target, key, value) { + target[key] = value; + return true; + }, + }, +); +window.HTMLCanvasElement.prototype.getContext = function getContext() { + return context; +}; + +window.eval(bundle); + +const document = window.document; +const byId = (id) => document.getElementById(id); +const assert = (condition, message) => { + if (!condition) failures.push(message); +}; + +const socket = MockWebSocket.instances[0]; +assert(socket, "the app opened a WebSocket"); +assert(socket.url === "ws://127.0.0.1:8899/ws", `unexpected socket url ${socket.url}`); + +const hello = { + type: "hello", + version: "5.0.0", + grace_seconds: 60, + time: Date.now(), +}; +const lobby = { + type: "view", + view: { + screen: "lobby", + connected: true, + user: null, + account: null, + balance: null, + games: [ + ["blackjack", "Blackjack"], + ["crash", "Crash"], + ["roulette", "Roulette"], + ["slots", "Slots"], + ], + login_error: null, + }, +}; +socket.message(hello); +socket.message(lobby); + +assert(byId("auth-screen").hidden === false, "auth is shown before login"); +assert(byId("session-bar").hidden === true, "no session bar before login"); + +byId("auth-user").value = "alice"; +byId("auth-pass").value = "secret"; +byId("auth-form").dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); + +await new Promise((resolve) => setTimeout(resolve, 10)); +const login = socket.sent.find((message) => message.command?.Login); +assert(login, "the login form sends a Login command"); +assert(login.command.Login.user === "alice", "the username is sent"); +assert( + /^[0-9a-f]{64}$/.test(login.command.Login.credential), + "the password is sent as a SHA-256 hex digest", +); + +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: null, + games: lobby.view.games, + login_error: null, + }, +}); +assert( + socket.sent.some((message) => message.command === "Info"), + "the app asks for the balance after login", +); +socket.message({ + type: "view", + view: { + screen: "lobby", + connected: true, + user: "alice", + account: "1", + balance: 1000, + games: lobby.view.games, + login_error: null, + }, +}); +assert(byId("auth-screen").hidden === true, "auth is hidden after login"); +assert(byId("lobby-screen").hidden === false, "the lobby is shown after login"); +assert(byId("account-name").textContent === "alice", "the account name is shown"); +assert(byId("account-balance").textContent.includes("1"), "the balance is shown"); +assert(document.querySelectorAll(".game-card").length === 4, "all four game cards render"); +socket.message({ + type: "view", + view: { + screen: "slots", + bet: 10, + balance: 1000, + reels: "CLOGDSCLOGDSLLO".split(""), + win_cells: [0, 1, 2], + win_lines: [1], + win_type: "win", + winnings: 20, + phase: "result", + error: null, + }, +}); +assert(byId("game-screen").hidden === false, "the game screen is shown"); +assert(document.querySelectorAll(".reel").length === 5, "five reels render"); +assert(document.querySelectorAll(".tile").length === 15, "fifteen tiles render"); +assert(byId("game-side").textContent.includes("SPIN"), "the slots sidebar has a spin action"); + +socket.message({ + type: "view", + view: { + screen: "roulette", + bet_amount: 10, + balance: 1000, + chip: 25, + bets: { chips: [{ target: { bet: "straight", number: 5 }, amount: 25 }] }, + cursor: { Lattice: { x: 1, y: 5 } }, + phase: "result", + number: 5, + color: "red", + winnings: 900, + error: null, + }, +}); +assert(document.querySelector(".roulette-canvas"), "the roulette canvas renders"); +assert(document.querySelectorAll(".chip-btn").length === 5, "the chip selector renders"); +assert(document.querySelector(".wheel-result")?.textContent.includes("5"), "the result number renders"); + +socket.message({ + type: "view", + view: { + screen: "blackjack", + status: "player", + balance: 990, + bet: 10, + hand: { + session_id: "bj1", + phase: "player", + player: ["10S", "6H"], + dealer: ["9D", "?"], + player_total: 16, + dealer_total: 9, + bet: 10, + base_bet: 10, + balance: 990, + actions: ["hit", "stand", "double"], + result: "playing", + winnings: 0, + }, + error: null, + }, +}); +assert(document.querySelectorAll(".card").length === 4, "both blackjack hands render"); +assert(byId("game-side").textContent.includes("HIT"), "blackjack actions render"); +assert(byId("game-side").textContent.includes("FORFEIT") === false, "forfeit is a labelled button, not uppercase"); +assert(byId("game-side").textContent.includes("Forfeit"), "blackjack forfeit renders"); + +socket.message({ + type: "view", + view: { + screen: "crash", + status: "running", + balance: 990, + bet: 10, + multiplier: 1.2, + potential: 12, + crash_point: null, + payout: 0, + started_at: Date.now(), + commit: "ab".repeat(32), + seed: null, + error: null, + }, +}); +assert(document.querySelector(".crash-canvas"), "the crash canvas renders"); +assert(document.querySelector(".crash-number")?.textContent.includes("×"), "the crash multiplier renders"); +assert(byId("game-side").textContent.includes("CASH OUT"), "crash exposes cash out while running"); +assert(byId("game-side").textContent.includes("commit"), "crash shows the round commitment"); + +socket.message({ + type: "view", + view: { + screen: "history", + records: [ + { + game: "slots", + bet: 10, + winnings: 20, + balance_after: 1010, + at: "2026-11-08 03:00:00", + details: { win_type: "win" }, + }, + ], + }, +}); +assert(byId("history-screen").hidden === false, "the history screen renders"); +assert(document.querySelector("table tbody tr"), "history rows render"); + +socket.message({ type: "replaced" }); +assert(byId("replaced-overlay").hidden === false, "a takeover stops the old tab"); + +await new Promise((resolve) => setTimeout(resolve, 30)); +dom.window.close(); + +if (failures.length > 0) { + console.error("web smoke test failed:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("web smoke test passed"); -- cgit v1.2.3