diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:32:24 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:32:24 +0100 |
| commit | fda24c47a8262388ada92722850a362da65aaa6e (patch) | |
| tree | 13363fa1461ae4e10087acba562a527b8bde4228 /web/play/src | |
| parent | v5.0.0: protocol v5 and the browser frontend (diff) | |
| download | zagan-bet-fda24c47a8262388ada92722850a362da65aaa6e.tar.gz zagan-bet-fda24c47a8262388ada92722850a362da65aaa6e.zip | |
web: accept the serialized reels array and stop game loops on navigation
The Rust SlotsView serializes reels as an array of chars, so the browser joins it instead of String(array). The crash chart, reel animation and roulette observer now stop when another screen takes over, and a returning crash screen rebuilds its detached canvas. Action buttons ignore a double click before the server view arrives.
Also tightens the gateway session registry with a generation token, caps WebSocket messages at 64 KiB, uses the __Host- cookie prefix on HTTPS, and ships the systemd unit.
Diffstat (limited to 'web/play/src')
| -rw-r--r-- | web/play/src/blackjack.ts | 14 | ||||
| -rw-r--r-- | web/play/src/crash.ts | 14 | ||||
| -rw-r--r-- | web/play/src/main.ts | 6 | ||||
| -rw-r--r-- | web/play/src/roulette.ts | 15 | ||||
| -rw-r--r-- | web/play/src/slots.ts | 18 |
5 files changed, 59 insertions, 8 deletions
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts index 3537f45..49d46fa 100644 --- a/web/play/src/blackjack.ts +++ b/web/play/src/blackjack.ts | |||
| @@ -1,6 +1,14 @@ | |||
| 1 | import type { View } from "./types"; | 1 | import type { View } from "./types"; |
| 2 | import { cardFace, clear, money, node } from "./dom"; | 2 | import { cardFace, clear, money, node } from "./dom"; |
| 3 | 3 | ||
| 4 | let lastAction = 0; | ||
| 5 | |||
| 6 | function actionAllowed(): boolean { | ||
| 7 | if (Date.now() - lastAction < 400) return false; | ||
| 8 | lastAction = Date.now(); | ||
| 9 | return true; | ||
| 10 | } | ||
| 11 | |||
| 4 | function cardElement(card: string): HTMLElement { | 12 | function cardElement(card: string): HTMLElement { |
| 5 | if (card === "?") { | 13 | if (card === "?") { |
| 6 | const back = node("div", "card back"); | 14 | const back = node("div", "card back"); |
| @@ -124,6 +132,7 @@ export function renderBlackjack( | |||
| 124 | ? node("button", "btn primary", label) | 132 | ? node("button", "btn primary", label) |
| 125 | : node("button", "btn", label); | 133 | : node("button", "btn", label); |
| 126 | button.addEventListener("click", () => { | 134 | button.addEventListener("click", () => { |
| 135 | if (!actionAllowed()) return; | ||
| 127 | if (action === "hit") send("Hit"); | 136 | if (action === "hit") send("Hit"); |
| 128 | else if (action === "stand") send("Stand"); | 137 | else if (action === "stand") send("Stand"); |
| 129 | else send("Double"); | 138 | else send("Double"); |
| @@ -133,12 +142,15 @@ export function renderBlackjack( | |||
| 133 | actions.append(actionsRow); | 142 | actions.append(actionsRow); |
| 134 | const forfeit = node("button", "btn danger wide", "Forfeit hand"); | 143 | const forfeit = node("button", "btn danger wide", "Forfeit hand"); |
| 135 | forfeit.addEventListener("click", () => { | 144 | forfeit.addEventListener("click", () => { |
| 145 | if (!actionAllowed()) return; | ||
| 136 | if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); | 146 | if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); |
| 137 | }); | 147 | }); |
| 138 | actions.append(forfeit); | 148 | actions.append(forfeit); |
| 139 | } else if (canDeal) { | 149 | } else if (canDeal) { |
| 140 | const deal = node("button", "btn primary wide big", "DEAL"); | 150 | const deal = node("button", "btn primary wide big", "DEAL"); |
| 141 | deal.addEventListener("click", () => send("Deal")); | 151 | deal.addEventListener("click", () => { |
| 152 | if (actionAllowed()) send("Deal"); | ||
| 153 | }); | ||
| 142 | actions.append(deal); | 154 | actions.append(deal); |
| 143 | } | 155 | } |
| 144 | 156 | ||
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts index 6f3c8dd..a683dcd 100644 --- a/web/play/src/crash.ts +++ b/web/play/src/crash.ts | |||
| @@ -31,6 +31,7 @@ let sendCommand: ((command: unknown) => void) | null = null; | |||
| 31 | let frameHandle: number | undefined; | 31 | let frameHandle: number | undefined; |
| 32 | let verifyToken = 0; | 32 | let verifyToken = 0; |
| 33 | let lastStatus = ""; | 33 | let lastStatus = ""; |
| 34 | let lastAction = 0; | ||
| 34 | 35 | ||
| 35 | function build( | 36 | function build( |
| 36 | board: HTMLElement, | 37 | board: HTMLElement, |
| @@ -88,6 +89,8 @@ function build( | |||
| 88 | const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; | 89 | const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; |
| 89 | const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; | 90 | const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; |
| 90 | primary.addEventListener("click", () => { | 91 | primary.addEventListener("click", () => { |
| 92 | if (Date.now() - lastAction < 500) return; | ||
| 93 | lastAction = Date.now(); | ||
| 91 | if (current?.status === "running") sendCommand?.("CashOut"); | 94 | if (current?.status === "running") sendCommand?.("CashOut"); |
| 92 | else sendCommand?.("Start"); | 95 | else sendCommand?.("Start"); |
| 93 | }); | 96 | }); |
| @@ -118,6 +121,15 @@ function build( | |||
| 118 | }; | 121 | }; |
| 119 | } | 122 | } |
| 120 | 123 | ||
| 124 | /// Stop the chart loop when another screen takes over. | ||
| 125 | export function stopCrashAnimation(): void { | ||
| 126 | if (frameHandle !== undefined) { | ||
| 127 | cancelAnimationFrame(frameHandle); | ||
| 128 | frameHandle = undefined; | ||
| 129 | } | ||
| 130 | current = null; | ||
| 131 | } | ||
| 132 | |||
| 121 | export function renderCrash( | 133 | export function renderCrash( |
| 122 | board: HTMLElement, | 134 | board: HTMLElement, |
| 123 | side: HTMLElement, | 135 | side: HTMLElement, |
| @@ -125,7 +137,7 @@ export function renderCrash( | |||
| 125 | send: (command: unknown) => void, | 137 | send: (command: unknown) => void, |
| 126 | now: Clock, | 138 | now: Clock, |
| 127 | ): void { | 139 | ): void { |
| 128 | if (!parts || parts.board !== board) { | 140 | if (!parts || parts.board !== board || !parts.canvas.isConnected) { |
| 129 | parts = build(board, side, send, now); | 141 | parts = build(board, side, send, now); |
| 130 | lastStatus = ""; | 142 | lastStatus = ""; |
| 131 | } | 143 | } |
diff --git a/web/play/src/main.ts b/web/play/src/main.ts index fd55250..f051429 100644 --- a/web/play/src/main.ts +++ b/web/play/src/main.ts | |||
| @@ -1,10 +1,10 @@ | |||
| 1 | import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; | 1 | import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; |
| 2 | import { Gateway } from "./net"; | 2 | import { Gateway } from "./net"; |
| 3 | import { renderBlackjack } from "./blackjack"; | 3 | import { renderBlackjack } from "./blackjack"; |
| 4 | import { renderCrash } from "./crash"; | 4 | import { renderCrash, stopCrashAnimation } from "./crash"; |
| 5 | import { renderHistory } from "./history"; | 5 | import { renderHistory } from "./history"; |
| 6 | import { renderRoulette } from "./roulette"; | 6 | import { renderRoulette } from "./roulette"; |
| 7 | import { renderSlots } from "./slots"; | 7 | import { renderSlots, stopSlotsAnimation } from "./slots"; |
| 8 | import type { View } from "./types"; | 8 | import type { View } from "./types"; |
| 9 | 9 | ||
| 10 | const authScreen = byId("auth-screen"); | 10 | const authScreen = byId("auth-screen"); |
| @@ -72,6 +72,8 @@ const gateway = new Gateway(wsUrl, { | |||
| 72 | }, | 72 | }, |
| 73 | onView(next) { | 73 | onView(next) { |
| 74 | view = next; | 74 | view = next; |
| 75 | if (next.screen !== "slots") stopSlotsAnimation(); | ||
| 76 | if (next.screen !== "crash") stopCrashAnimation(); | ||
| 75 | // Game and history views carry no account field; the lobby is the source | 77 | // 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 | 78 | // of truth for who is signed in. A null lobby user is a logout or an |
| 77 | // expired session. | 79 | // expired session. |
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts index cbb7c6e..a42686a 100644 --- a/web/play/src/roulette.ts +++ b/web/play/src/roulette.ts | |||
| @@ -20,6 +20,9 @@ type Hotspot = { | |||
| 20 | rank: number; | 20 | rank: number; |
| 21 | }; | 21 | }; |
| 22 | 22 | ||
| 23 | let observer: ResizeObserver | null = null; | ||
| 24 | let lastSpin = 0; | ||
| 25 | |||
| 23 | type Layout = { | 26 | type Layout = { |
| 24 | width: number; | 27 | width: number; |
| 25 | height: number; | 28 | height: number; |
| @@ -413,9 +416,11 @@ export function renderRoulette( | |||
| 413 | const zone = hit(event.clientX, event.clientY); | 416 | const zone = hit(event.clientX, event.clientY); |
| 414 | canvas.style.cursor = zone && !spinning ? "pointer" : "default"; | 417 | canvas.style.cursor = zone && !spinning ? "pointer" : "default"; |
| 415 | }); | 418 | }); |
| 416 | new ResizeObserver(() => { | 419 | observer?.disconnect(); |
| 420 | observer = new ResizeObserver(() => { | ||
| 417 | current = draw(canvas, view); | 421 | current = draw(canvas, view); |
| 418 | }).observe(canvas); | 422 | }); |
| 423 | observer.observe(canvas); | ||
| 419 | 424 | ||
| 420 | // Sidebar | 425 | // Sidebar |
| 421 | side.append(node("div", "label", "Roulette")); | 426 | side.append(node("div", "label", "Roulette")); |
| @@ -459,7 +464,11 @@ export function renderRoulette( | |||
| 459 | const actions = node("div", "actions"); | 464 | const actions = node("div", "actions"); |
| 460 | const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); | 465 | const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); |
| 461 | spin.disabled = spinning || total === 0; | 466 | spin.disabled = spinning || total === 0; |
| 462 | spin.addEventListener("click", () => send("Spin")); | 467 | spin.addEventListener("click", () => { |
| 468 | if (Date.now() - lastSpin < 600) return; | ||
| 469 | lastSpin = Date.now(); | ||
| 470 | send("Spin"); | ||
| 471 | }); | ||
| 463 | const clearBets = node("button", "btn wide", "Clear bets"); | 472 | const clearBets = node("button", "btn wide", "Clear bets"); |
| 464 | clearBets.disabled = spinning || total === 0; | 473 | clearBets.disabled = spinning || total === 0; |
| 465 | clearBets.addEventListener("click", () => send("ClearBets")); | 474 | clearBets.addEventListener("click", () => send("ClearBets")); |
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts index dba1e25..7c4677d 100644 --- a/web/play/src/slots.ts +++ b/web/play/src/slots.ts | |||
| @@ -7,6 +7,7 @@ const SYMBOLS = "CLOGDS"; | |||
| 7 | const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; | 7 | const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; |
| 8 | 8 | ||
| 9 | let spinUntil = 0; | 9 | let spinUntil = 0; |
| 10 | let lastSpin = 0; | ||
| 10 | let animation: number | undefined; | 11 | let animation: number | undefined; |
| 11 | let latest: View | null = null; | 12 | let latest: View | null = null; |
| 12 | let latestBoard: HTMLElement | null = null; | 13 | let latestBoard: HTMLElement | null = null; |
| @@ -15,6 +16,17 @@ export function requestSlotsSpin(): void { | |||
| 15 | spinUntil = Date.now() + 900; | 16 | spinUntil = Date.now() + 900; |
| 16 | } | 17 | } |
| 17 | 18 | ||
| 19 | /// Stop the reel animation when another screen takes over. | ||
| 20 | export function stopSlotsAnimation(): void { | ||
| 21 | if (animation !== undefined) { | ||
| 22 | window.clearInterval(animation); | ||
| 23 | animation = undefined; | ||
| 24 | } | ||
| 25 | latest = null; | ||
| 26 | latestBoard = null; | ||
| 27 | spinUntil = 0; | ||
| 28 | } | ||
| 29 | |||
| 18 | function spinning(view: View): boolean { | 30 | function spinning(view: View): boolean { |
| 19 | return view.phase === "spinning" || Date.now() < spinUntil; | 31 | return view.phase === "spinning" || Date.now() < spinUntil; |
| 20 | } | 32 | } |
| @@ -42,7 +54,9 @@ export function renderSlots( | |||
| 42 | 54 | ||
| 43 | const reels = node("div", "reels"); | 55 | const reels = node("div", "reels"); |
| 44 | const tiles: HTMLElement[] = []; | 56 | const tiles: HTMLElement[] = []; |
| 45 | const flat = String(view.reels ?? ""); | 57 | const flat = Array.isArray(view.reels) |
| 58 | ? view.reels.join("") | ||
| 59 | : String(view.reels ?? ""); | ||
| 46 | for (let column = 0; column < REELS; column += 1) { | 60 | for (let column = 0; column < REELS; column += 1) { |
| 47 | const reel = node("div", "reel"); | 61 | const reel = node("div", "reel"); |
| 48 | for (let row = 0; row < ROWS; row += 1) { | 62 | for (let row = 0; row < ROWS; row += 1) { |
| @@ -133,6 +147,8 @@ export function renderSlots( | |||
| 133 | const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); | 147 | const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); |
| 134 | spin.disabled = active; | 148 | spin.disabled = active; |
| 135 | spin.addEventListener("click", () => { | 149 | spin.addEventListener("click", () => { |
| 150 | if (Date.now() - lastSpin < 900) return; | ||
| 151 | lastSpin = Date.now(); | ||
| 136 | requestSlotsSpin(); | 152 | requestSlotsSpin(); |
| 137 | send("Spin"); | 153 | send("Spin"); |
| 138 | }); | 154 | }); |
