import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; import { Gateway } from "./net"; import { renderBlackjack } from "./blackjack"; import { renderCrash, stopCrashAnimation } from "./crash"; import { renderHistory } from "./history"; import { renderRoulette } from "./roulette"; import { renderSlots, stopSlotsAnimation } 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 versionPill = byId("version-pill"); const accountNumber = byId("account-number"); const lobbyCount = byId("lobby-count"); 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 authSwitch = byId("auth-switch"); const authTitle = byId("auth-title"); 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; let lastScreen: string | null = null; 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: "Spin the reels. Three or more matching symbols from the left win.", roulette: "Put chips on the table, then spin. Straight numbers pay the most.", blackjack: "Get closer to 21 than the dealer without going over.", crash: "Start the round, then cash out before the multiplier crashes.", }; const basePath = location.pathname.includes("/play/") ? location.pathname.slice(0, location.pathname.indexOf("/play/")) : ""; const defaultWsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; // A local-only escape hatch for `scripts/dev-web.sh`, which serves the static // page and the gateway on different loopback ports. Never honour ?ws= on a // real host: it would let a crafted link send a password hash elsewhere. const devWs = new URLSearchParams(location.search).get("ws"); const loopback = location.hostname === "127.0.0.1" || location.hostname === "localhost" || location.hostname === "::1"; const wsUrl = devWs && loopback && (devWs.startsWith("ws://") || devWs.startsWith("wss://")) ? devWs : defaultWsUrl; const gateway = new Gateway(wsUrl, { onHello(hello) { serverOffset = hello.time - Date.now(); currentUser = hello.user ?? null; if (typeof hello.balance === "number") lastBalance = hello.balance; versionPill.textContent = `v${hello.version}`; }, onView(next) { if (next.screen !== lastScreen) { byId("toasts").replaceChildren(); lastScreen = next.screen; } view = next; if (next.screen !== "slots") stopSlotsAnimation(); if (next.screen !== "crash") stopCrashAnimation(); // 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; authTitle.textContent = mode === "login" ? "Sign in" : "Create account"; authSubmit.textContent = mode === "login" ? "LOG IN" : "CREATE ACCOUNT"; authSwitch.textContent = mode === "login" ? "CREATE ACCOUNT" : "BACK TO SIGN IN"; 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; accountNumber.textContent = "—"; } 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"); 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 renderLobby(lobby: View): void { accountNumber.textContent = lobby.account ? String(lobby.account) : "—"; const order = ["slots", "roulette", "blackjack", "crash"]; const list: [string, string][] = [...(lobby.games ?? [])].sort( (a, b) => order.indexOf(a[0]) - order.indexOf(b[0]), ); lobbyCount.textContent = `${list.length} ${list.length === 1 ? "game" : "games"}`; gameGrid.replaceChildren(); 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 play = node("button", "btn primary wide", "PLAY"); play.addEventListener("click", () => { send({ Play: { game: id, bet: bets.get(id) ?? 10 } }); }); card.append(play); gameGrid.append(card); } if (list.length === 0) { gameGrid.append(node("p", "muted", "The server did not advertise any games.")); } } // Auth mode and form ------------------------------------------------------- authSwitch.addEventListener("click", () => setAuthMode(authMode === "login" ? "register" : "login"), ); 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("history-back").addEventListener("click", () => send("ShowLobby")); 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();