summaryrefslogtreecommitdiffstats
path: root/web/play/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:32:24 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:32:24 +0100
commitfda24c47a8262388ada92722850a362da65aaa6e (patch)
tree13363fa1461ae4e10087acba562a527b8bde4228 /web/play/src
parentv5.0.0: protocol v5 and the browser frontend (diff)
downloadzagan-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.ts14
-rw-r--r--web/play/src/crash.ts14
-rw-r--r--web/play/src/main.ts6
-rw-r--r--web/play/src/roulette.ts15
-rw-r--r--web/play/src/slots.ts18
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 @@
1import type { View } from "./types"; 1import type { View } from "./types";
2import { cardFace, clear, money, node } from "./dom"; 2import { cardFace, clear, money, node } from "./dom";
3 3
4let lastAction = 0;
5
6function actionAllowed(): boolean {
7 if (Date.now() - lastAction < 400) return false;
8 lastAction = Date.now();
9 return true;
10}
11
4function cardElement(card: string): HTMLElement { 12function 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;
31let frameHandle: number | undefined; 31let frameHandle: number | undefined;
32let verifyToken = 0; 32let verifyToken = 0;
33let lastStatus = ""; 33let lastStatus = "";
34let lastAction = 0;
34 35
35function build( 36function 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.
125export function stopCrashAnimation(): void {
126 if (frameHandle !== undefined) {
127 cancelAnimationFrame(frameHandle);
128 frameHandle = undefined;
129 }
130 current = null;
131}
132
121export function renderCrash( 133export 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 @@
1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; 1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom";
2import { Gateway } from "./net"; 2import { Gateway } from "./net";
3import { renderBlackjack } from "./blackjack"; 3import { renderBlackjack } from "./blackjack";
4import { renderCrash } from "./crash"; 4import { renderCrash, stopCrashAnimation } from "./crash";
5import { renderHistory } from "./history"; 5import { renderHistory } from "./history";
6import { renderRoulette } from "./roulette"; 6import { renderRoulette } from "./roulette";
7import { renderSlots } from "./slots"; 7import { renderSlots, stopSlotsAnimation } from "./slots";
8import type { View } from "./types"; 8import type { View } from "./types";
9 9
10const authScreen = byId("auth-screen"); 10const 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
23let observer: ResizeObserver | null = null;
24let lastSpin = 0;
25
23type Layout = { 26type 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";
7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; 7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"];
8 8
9let spinUntil = 0; 9let spinUntil = 0;
10let lastSpin = 0;
10let animation: number | undefined; 11let animation: number | undefined;
11let latest: View | null = null; 12let latest: View | null = null;
12let latestBoard: HTMLElement | null = null; 13let 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.
20export 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
18function spinning(view: View): boolean { 30function 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 });