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/src/main.ts | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 web/play/src/main.ts (limited to 'web/play/src/main.ts') diff --git a/web/play/src/main.ts b/web/play/src/main.ts new file mode 100644 index 0000000..fd55250 --- /dev/null +++ b/web/play/src/main.ts @@ -0,0 +1,323 @@ +import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; +import { Gateway } from "./net"; +import { renderBlackjack } from "./blackjack"; +import { renderCrash } from "./crash"; +import { renderHistory } from "./history"; +import { renderRoulette } from "./roulette"; +import { renderSlots } from "./slots"; +import type { View } from "./types"; + +const authScreen = byId("auth-screen"); +const lobbyScreen = byId("lobby-screen"); +const gameScreen = byId("game-screen"); +const historyScreen = byId("history-screen"); +const sessionBar = byId("session-bar"); +const accountName = byId("account-name"); +const accountBalance = byId("account-balance"); +const gameGrid = byId("game-grid"); +const gameBoard = byId("game-board"); +const gameSide = byId("game-side"); +const gameTitle = byId("game-title"); +const gameBadge = byId("game-badge"); +const historyBody = byId("history-body"); +const authForm = byId("auth-form"); +const authUser = byId("auth-user"); +const authPass = byId("auth-pass"); +const authError = byId("auth-error"); +const authSubmit = byId("auth-submit"); +const tabLogin = byId("tab-login"); +const tabRegister = byId("tab-register"); +const reconnectOverlay = byId("reconnect-overlay"); +const reconnectMessage = byId("reconnect-message"); +const replacedOverlay = byId("replaced-overlay"); + +let view: View | null = null; +let currentUser: string | null = null; +let lastBalance: number | null = null; +let authMode: "login" | "register" = "login"; +let infoFor: string | null = null; +let serverOffset = 0; +let reconnectAttempts = 0; +const bets = new Map([ + ["slots", 10], + ["roulette", 10], + ["blackjack", 10], + ["crash", 10], +]); + +const titles: Record = { + slots: "Slots", + roulette: "Roulette", + blackjack: "Blackjack", + crash: "Crash", +}; + +const descriptions: Record = { + slots: "Five reels, five paylines. Match symbols from the left.", + roulette: "European single-zero wheel with inside and outside bets.", + blackjack: "Six-deck shoe. Blackjack pays 3:2.", + crash: "A rising multiplier. Cash out before it crashes.", +}; + +const basePath = location.pathname.includes("/play/") + ? location.pathname.slice(0, location.pathname.indexOf("/play/")) + : ""; +const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; + +const gateway = new Gateway(wsUrl, { + onHello(hello) { + serverOffset = hello.time - Date.now(); + currentUser = hello.user ?? null; + gameBadge.textContent = `v${hello.version}`; + }, + onView(next) { + view = next; + // Game and history views carry no account field; the lobby is the source + // of truth for who is signed in. A null lobby user is a logout or an + // expired session. + if (typeof next.balance === "number") lastBalance = next.balance; + if (next.screen === "lobby" && "user" in next) { + currentUser = (next.user as string | null) ?? null; + if (currentUser) authPass.value = ""; + else lastBalance = null; + } + reconnectAttempts = 0; + reconnectOverlay.hidden = true; + render(); + if (currentUser && view.balance === null && infoFor !== currentUser) { + infoFor = currentUser; + gateway.send("Info"); + } + if (!currentUser) infoFor = null; + }, + onNotice(text) { + if ( + text.startsWith("Connected to:") || + text.startsWith("Fingerprint:") || + text.startsWith("User:") || + text.startsWith("Account:") || + text.startsWith("Balance:") + ) { + return; + } + toast(text, toneFor(text)); + if (text.startsWith("Account created")) { + setAuthMode("login"); + authPass.value = ""; + authUser.focus(); + } + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onError(text) { + toast(text, "bad"); + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onReplaced() { + replacedOverlay.hidden = false; + }, + onPong() { + /* The gateway answered; the socket is alive. */ + }, + onOpen() { + reconnectOverlay.hidden = true; + }, + onClosed(willRetry) { + if (replacedOverlay.hidden === false) return; + if (!willRetry) { + reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; + reconnectOverlay.hidden = false; + return; + } + reconnectAttempts += 1; + reconnectMessage.textContent = + reconnectAttempts > 1 + ? "Still trying to reach the game server…" + : "Trying to reach the game server…"; + reconnectOverlay.hidden = false; + }, +}); + +function send(command: unknown): void { + gateway.send(command); +} + +function serverNow(): number { + return Date.now() + serverOffset; +} + +function setAuthMode(mode: "login" | "register"): void { + authMode = mode; + tabLogin.classList.toggle("active", mode === "login"); + tabRegister.classList.toggle("active", mode === "register"); + authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; + authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; + authError.textContent = ""; +} + +function show(screen: "auth" | "lobby" | "game" | "history"): void { + authScreen.hidden = screen !== "auth"; + lobbyScreen.hidden = screen !== "lobby"; + gameScreen.hidden = screen !== "game"; + historyScreen.hidden = screen !== "history"; +} + +function setBalance(balance: number | null | undefined): void { + accountBalance.textContent = money(balance ?? null); +} + +function render(): void { + if (!view) return; + const user = currentUser; + if (user) { + sessionBar.hidden = false; + accountName.textContent = String(user); + } else { + sessionBar.hidden = true; + } + + setBalance(lastBalance); + + if (!user) { + show("auth"); + authError.textContent = view.login_error ? String(view.login_error) : ""; + if (view.screen === "lobby" && view.connected === false) { + authError.textContent = "The game server is not reachable yet. Retrying…"; + } + return; + } + + switch (view.screen) { + case "lobby": + show("lobby"); + renderLobby(view); + break; + case "history": + show("history"); + renderHistory(historyBody, view); + break; + case "slots": + case "roulette": + case "blackjack": + case "crash": + show("game"); + gameTitle.textContent = titles[view.screen] ?? view.screen; + gameBadge.textContent = badge(view); + if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); + else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); + else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); + else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); + break; + default: + show("auth"); + } +} + +function badge(view: View): string { + if (view.screen === "crash") return String(view.status ?? ""); + if (view.screen === "blackjack") return String(view.status ?? ""); + if (view.screen === "slots") return String(view.phase ?? ""); + if (view.screen === "roulette") return String(view.phase ?? ""); + return ""; +} + +function renderLobby(lobby: View): void { + gameGrid.replaceChildren(); + const list: [string, string][] = lobby.games ?? []; + for (const [id, name] of list) { + const card = node("article", `game-card ${id}`); + card.append(node("h3", undefined, name || titles[id] || id)); + card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); + + const row = node("div", "row wrap"); + const label = node("label", "field"); + label.style.flex = "1"; + label.append(node("span", "label", "Bet")); + const input = node("input", "input bet-input") as HTMLInputElement; + input.type = "number"; + input.min = "1"; + input.step = "10"; + input.value = String(bets.get(id) ?? 10); + input.addEventListener("change", () => { + const value = Math.max(1, Math.floor(Number(input.value) || 1)); + input.value = String(value); + bets.set(id, value); + }); + label.append(input); + + const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); + play.addEventListener("click", () => { + const bet = Math.max(1, Math.floor(Number(input.value) || 1)); + bets.set(id, bet); + send({ Play: { game: id, bet } }); + }); + row.append(label, play); + card.append(row); + gameGrid.append(card); + } + + if (list.length === 0) { + gameGrid.append(node("p", "muted", "The server did not advertise any games.")); + } +} + +// Auth tabs and form ------------------------------------------------------- +tabLogin.addEventListener("click", () => setAuthMode("login")); +tabRegister.addEventListener("click", () => setAuthMode("register")); + +authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const user = authUser.value.trim(); + const password = authPass.value; + if (!user || !password) { + authError.textContent = "Enter a username and a password."; + return; + } + authSubmit.disabled = true; + authSubmit.textContent = "Working…"; + authError.textContent = ""; + try { + const credential = await sha256Hex(password); + if (authMode === "login") { + send({ Login: { user, credential } }); + } else { + send({ Register: { user, credential } }); + } + } catch { + authError.textContent = "This browser cannot hash the password (needs a secure context)."; + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + } +}); + +// Chrome and navigation ---------------------------------------------------- +byId("logout").addEventListener("click", () => send("Logout")); +byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); +byId("refresh-lobby").addEventListener("click", () => send("Info")); +byId("history-back").addEventListener("click", () => send("ShowLobby")); +byId("leave-game").addEventListener("click", () => { + if (!view) return; + const live = + (view.screen === "blackjack" && view.status === "player") || + (view.screen === "crash" && view.status === "running"); + if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; + send("Leave"); +}); +byId("reload-replaced").addEventListener("click", () => location.reload()); +byId("reload-reconnect").addEventListener("click", () => location.reload()); + +window.addEventListener("resize", () => { + window.clearTimeout((window as any).__resizeTimer); + (window as any).__resizeTimer = window.setTimeout(() => { + if (view) render(); + }, 140); +}); + +// A periodic ping keeps the gateway session visibly alive and refreshes the +// server clock estimate without changing game state. +window.setInterval(() => { + if (currentUser) gateway.ping(); +}, 25_000); + +gateway.connect(); -- cgit v1.2.3