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/src/main.ts | |
| 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/src/main.ts')
| -rw-r--r-- | web/play/src/main.ts | 323 |
1 files changed, 323 insertions, 0 deletions
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 @@ | |||
| 1 | import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; | ||
| 2 | import { Gateway } from "./net"; | ||
| 3 | import { renderBlackjack } from "./blackjack"; | ||
| 4 | import { renderCrash } from "./crash"; | ||
| 5 | import { renderHistory } from "./history"; | ||
| 6 | import { renderRoulette } from "./roulette"; | ||
| 7 | import { renderSlots } from "./slots"; | ||
| 8 | import type { View } from "./types"; | ||
| 9 | |||
| 10 | const authScreen = byId("auth-screen"); | ||
| 11 | const lobbyScreen = byId("lobby-screen"); | ||
| 12 | const gameScreen = byId("game-screen"); | ||
| 13 | const historyScreen = byId("history-screen"); | ||
| 14 | const sessionBar = byId("session-bar"); | ||
| 15 | const accountName = byId("account-name"); | ||
| 16 | const accountBalance = byId("account-balance"); | ||
| 17 | const gameGrid = byId("game-grid"); | ||
| 18 | const gameBoard = byId("game-board"); | ||
| 19 | const gameSide = byId("game-side"); | ||
| 20 | const gameTitle = byId("game-title"); | ||
| 21 | const gameBadge = byId("game-badge"); | ||
| 22 | const historyBody = byId("history-body"); | ||
| 23 | const authForm = byId<HTMLFormElement>("auth-form"); | ||
| 24 | const authUser = byId<HTMLInputElement>("auth-user"); | ||
| 25 | const authPass = byId<HTMLInputElement>("auth-pass"); | ||
| 26 | const authError = byId("auth-error"); | ||
| 27 | const authSubmit = byId<HTMLButtonElement>("auth-submit"); | ||
| 28 | const tabLogin = byId<HTMLButtonElement>("tab-login"); | ||
| 29 | const tabRegister = byId<HTMLButtonElement>("tab-register"); | ||
| 30 | const reconnectOverlay = byId("reconnect-overlay"); | ||
| 31 | const reconnectMessage = byId("reconnect-message"); | ||
| 32 | const replacedOverlay = byId("replaced-overlay"); | ||
| 33 | |||
| 34 | let view: View | null = null; | ||
| 35 | let currentUser: string | null = null; | ||
| 36 | let lastBalance: number | null = null; | ||
| 37 | let authMode: "login" | "register" = "login"; | ||
| 38 | let infoFor: string | null = null; | ||
| 39 | let serverOffset = 0; | ||
| 40 | let reconnectAttempts = 0; | ||
| 41 | const bets = new Map<string, number>([ | ||
| 42 | ["slots", 10], | ||
| 43 | ["roulette", 10], | ||
| 44 | ["blackjack", 10], | ||
| 45 | ["crash", 10], | ||
| 46 | ]); | ||
| 47 | |||
| 48 | const titles: Record<string, string> = { | ||
| 49 | slots: "Slots", | ||
| 50 | roulette: "Roulette", | ||
| 51 | blackjack: "Blackjack", | ||
| 52 | crash: "Crash", | ||
| 53 | }; | ||
| 54 | |||
| 55 | const descriptions: Record<string, string> = { | ||
| 56 | slots: "Five reels, five paylines. Match symbols from the left.", | ||
| 57 | roulette: "European single-zero wheel with inside and outside bets.", | ||
| 58 | blackjack: "Six-deck shoe. Blackjack pays 3:2.", | ||
| 59 | crash: "A rising multiplier. Cash out before it crashes.", | ||
| 60 | }; | ||
| 61 | |||
| 62 | const basePath = location.pathname.includes("/play/") | ||
| 63 | ? location.pathname.slice(0, location.pathname.indexOf("/play/")) | ||
| 64 | : ""; | ||
| 65 | const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; | ||
| 66 | |||
| 67 | const gateway = new Gateway(wsUrl, { | ||
| 68 | onHello(hello) { | ||
| 69 | serverOffset = hello.time - Date.now(); | ||
| 70 | currentUser = hello.user ?? null; | ||
| 71 | gameBadge.textContent = `v${hello.version}`; | ||
| 72 | }, | ||
| 73 | onView(next) { | ||
| 74 | view = next; | ||
| 75 | // Game and history views carry no account field; the lobby is the source | ||
| 76 | // of truth for who is signed in. A null lobby user is a logout or an | ||
| 77 | // expired session. | ||
| 78 | if (typeof next.balance === "number") lastBalance = next.balance; | ||
| 79 | if (next.screen === "lobby" && "user" in next) { | ||
| 80 | currentUser = (next.user as string | null) ?? null; | ||
| 81 | if (currentUser) authPass.value = ""; | ||
| 82 | else lastBalance = null; | ||
| 83 | } | ||
| 84 | reconnectAttempts = 0; | ||
| 85 | reconnectOverlay.hidden = true; | ||
| 86 | render(); | ||
| 87 | if (currentUser && view.balance === null && infoFor !== currentUser) { | ||
| 88 | infoFor = currentUser; | ||
| 89 | gateway.send("Info"); | ||
| 90 | } | ||
| 91 | if (!currentUser) infoFor = null; | ||
| 92 | }, | ||
| 93 | onNotice(text) { | ||
| 94 | if ( | ||
| 95 | text.startsWith("Connected to:") || | ||
| 96 | text.startsWith("Fingerprint:") || | ||
| 97 | text.startsWith("User:") || | ||
| 98 | text.startsWith("Account:") || | ||
| 99 | text.startsWith("Balance:") | ||
| 100 | ) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | toast(text, toneFor(text)); | ||
| 104 | if (text.startsWith("Account created")) { | ||
| 105 | setAuthMode("login"); | ||
| 106 | authPass.value = ""; | ||
| 107 | authUser.focus(); | ||
| 108 | } | ||
| 109 | authSubmit.disabled = false; | ||
| 110 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 111 | }, | ||
| 112 | onError(text) { | ||
| 113 | toast(text, "bad"); | ||
| 114 | authSubmit.disabled = false; | ||
| 115 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 116 | }, | ||
| 117 | onReplaced() { | ||
| 118 | replacedOverlay.hidden = false; | ||
| 119 | }, | ||
| 120 | onPong() { | ||
| 121 | /* The gateway answered; the socket is alive. */ | ||
| 122 | }, | ||
| 123 | onOpen() { | ||
| 124 | reconnectOverlay.hidden = true; | ||
| 125 | }, | ||
| 126 | onClosed(willRetry) { | ||
| 127 | if (replacedOverlay.hidden === false) return; | ||
| 128 | if (!willRetry) { | ||
| 129 | reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; | ||
| 130 | reconnectOverlay.hidden = false; | ||
| 131 | return; | ||
| 132 | } | ||
| 133 | reconnectAttempts += 1; | ||
| 134 | reconnectMessage.textContent = | ||
| 135 | reconnectAttempts > 1 | ||
| 136 | ? "Still trying to reach the game server…" | ||
| 137 | : "Trying to reach the game server…"; | ||
| 138 | reconnectOverlay.hidden = false; | ||
| 139 | }, | ||
| 140 | }); | ||
| 141 | |||
| 142 | function send(command: unknown): void { | ||
| 143 | gateway.send(command); | ||
| 144 | } | ||
| 145 | |||
| 146 | function serverNow(): number { | ||
| 147 | return Date.now() + serverOffset; | ||
| 148 | } | ||
| 149 | |||
| 150 | function setAuthMode(mode: "login" | "register"): void { | ||
| 151 | authMode = mode; | ||
| 152 | tabLogin.classList.toggle("active", mode === "login"); | ||
| 153 | tabRegister.classList.toggle("active", mode === "register"); | ||
| 154 | authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; | ||
| 155 | authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; | ||
| 156 | authError.textContent = ""; | ||
| 157 | } | ||
| 158 | |||
| 159 | function show(screen: "auth" | "lobby" | "game" | "history"): void { | ||
| 160 | authScreen.hidden = screen !== "auth"; | ||
| 161 | lobbyScreen.hidden = screen !== "lobby"; | ||
| 162 | gameScreen.hidden = screen !== "game"; | ||
| 163 | historyScreen.hidden = screen !== "history"; | ||
| 164 | } | ||
| 165 | |||
| 166 | function setBalance(balance: number | null | undefined): void { | ||
| 167 | accountBalance.textContent = money(balance ?? null); | ||
| 168 | } | ||
| 169 | |||
| 170 | function render(): void { | ||
| 171 | if (!view) return; | ||
| 172 | const user = currentUser; | ||
| 173 | if (user) { | ||
| 174 | sessionBar.hidden = false; | ||
| 175 | accountName.textContent = String(user); | ||
| 176 | } else { | ||
| 177 | sessionBar.hidden = true; | ||
| 178 | } | ||
| 179 | |||
| 180 | setBalance(lastBalance); | ||
| 181 | |||
| 182 | if (!user) { | ||
| 183 | show("auth"); | ||
| 184 | authError.textContent = view.login_error ? String(view.login_error) : ""; | ||
| 185 | if (view.screen === "lobby" && view.connected === false) { | ||
| 186 | authError.textContent = "The game server is not reachable yet. Retrying…"; | ||
| 187 | } | ||
| 188 | return; | ||
| 189 | } | ||
| 190 | |||
| 191 | switch (view.screen) { | ||
| 192 | case "lobby": | ||
| 193 | show("lobby"); | ||
| 194 | renderLobby(view); | ||
| 195 | break; | ||
| 196 | case "history": | ||
| 197 | show("history"); | ||
| 198 | renderHistory(historyBody, view); | ||
| 199 | break; | ||
| 200 | case "slots": | ||
| 201 | case "roulette": | ||
| 202 | case "blackjack": | ||
| 203 | case "crash": | ||
| 204 | show("game"); | ||
| 205 | gameTitle.textContent = titles[view.screen] ?? view.screen; | ||
| 206 | gameBadge.textContent = badge(view); | ||
| 207 | if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); | ||
| 208 | else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); | ||
| 209 | else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); | ||
| 210 | else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); | ||
| 211 | break; | ||
| 212 | default: | ||
| 213 | show("auth"); | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | function badge(view: View): string { | ||
| 218 | if (view.screen === "crash") return String(view.status ?? ""); | ||
| 219 | if (view.screen === "blackjack") return String(view.status ?? ""); | ||
| 220 | if (view.screen === "slots") return String(view.phase ?? ""); | ||
| 221 | if (view.screen === "roulette") return String(view.phase ?? ""); | ||
| 222 | return ""; | ||
| 223 | } | ||
| 224 | |||
| 225 | function renderLobby(lobby: View): void { | ||
| 226 | gameGrid.replaceChildren(); | ||
| 227 | const list: [string, string][] = lobby.games ?? []; | ||
| 228 | for (const [id, name] of list) { | ||
| 229 | const card = node("article", `game-card ${id}`); | ||
| 230 | card.append(node("h3", undefined, name || titles[id] || id)); | ||
| 231 | card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); | ||
| 232 | |||
| 233 | const row = node("div", "row wrap"); | ||
| 234 | const label = node("label", "field"); | ||
| 235 | label.style.flex = "1"; | ||
| 236 | label.append(node("span", "label", "Bet")); | ||
| 237 | const input = node("input", "input bet-input") as HTMLInputElement; | ||
| 238 | input.type = "number"; | ||
| 239 | input.min = "1"; | ||
| 240 | input.step = "10"; | ||
| 241 | input.value = String(bets.get(id) ?? 10); | ||
| 242 | input.addEventListener("change", () => { | ||
| 243 | const value = Math.max(1, Math.floor(Number(input.value) || 1)); | ||
| 244 | input.value = String(value); | ||
| 245 | bets.set(id, value); | ||
| 246 | }); | ||
| 247 | label.append(input); | ||
| 248 | |||
| 249 | const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); | ||
| 250 | play.addEventListener("click", () => { | ||
| 251 | const bet = Math.max(1, Math.floor(Number(input.value) || 1)); | ||
| 252 | bets.set(id, bet); | ||
| 253 | send({ Play: { game: id, bet } }); | ||
| 254 | }); | ||
| 255 | row.append(label, play); | ||
| 256 | card.append(row); | ||
| 257 | gameGrid.append(card); | ||
| 258 | } | ||
| 259 | |||
| 260 | if (list.length === 0) { | ||
| 261 | gameGrid.append(node("p", "muted", "The server did not advertise any games.")); | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | // Auth tabs and form ------------------------------------------------------- | ||
| 266 | tabLogin.addEventListener("click", () => setAuthMode("login")); | ||
| 267 | tabRegister.addEventListener("click", () => setAuthMode("register")); | ||
| 268 | |||
| 269 | authForm.addEventListener("submit", async (event) => { | ||
| 270 | event.preventDefault(); | ||
| 271 | const user = authUser.value.trim(); | ||
| 272 | const password = authPass.value; | ||
| 273 | if (!user || !password) { | ||
| 274 | authError.textContent = "Enter a username and a password."; | ||
| 275 | return; | ||
| 276 | } | ||
| 277 | authSubmit.disabled = true; | ||
| 278 | authSubmit.textContent = "Working…"; | ||
| 279 | authError.textContent = ""; | ||
| 280 | try { | ||
| 281 | const credential = await sha256Hex(password); | ||
| 282 | if (authMode === "login") { | ||
| 283 | send({ Login: { user, credential } }); | ||
| 284 | } else { | ||
| 285 | send({ Register: { user, credential } }); | ||
| 286 | } | ||
| 287 | } catch { | ||
| 288 | authError.textContent = "This browser cannot hash the password (needs a secure context)."; | ||
| 289 | authSubmit.disabled = false; | ||
| 290 | authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; | ||
| 291 | } | ||
| 292 | }); | ||
| 293 | |||
| 294 | // Chrome and navigation ---------------------------------------------------- | ||
| 295 | byId("logout").addEventListener("click", () => send("Logout")); | ||
| 296 | byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); | ||
| 297 | byId("refresh-lobby").addEventListener("click", () => send("Info")); | ||
| 298 | byId("history-back").addEventListener("click", () => send("ShowLobby")); | ||
| 299 | byId("leave-game").addEventListener("click", () => { | ||
| 300 | if (!view) return; | ||
| 301 | const live = | ||
| 302 | (view.screen === "blackjack" && view.status === "player") || | ||
| 303 | (view.screen === "crash" && view.status === "running"); | ||
| 304 | if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; | ||
| 305 | send("Leave"); | ||
| 306 | }); | ||
| 307 | byId("reload-replaced").addEventListener("click", () => location.reload()); | ||
| 308 | byId("reload-reconnect").addEventListener("click", () => location.reload()); | ||
| 309 | |||
| 310 | window.addEventListener("resize", () => { | ||
| 311 | window.clearTimeout((window as any).__resizeTimer); | ||
| 312 | (window as any).__resizeTimer = window.setTimeout(() => { | ||
| 313 | if (view) render(); | ||
| 314 | }, 140); | ||
| 315 | }); | ||
| 316 | |||
| 317 | // A periodic ping keeps the gateway session visibly alive and refreshes the | ||
| 318 | // server clock estimate without changing game state. | ||
| 319 | window.setInterval(() => { | ||
| 320 | if (currentUser) gateway.ping(); | ||
| 321 | }, 25_000); | ||
| 322 | |||
| 323 | gateway.connect(); | ||
