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 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; 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; 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();