summaryrefslogtreecommitdiffstats
path: root/web/play/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--web/play/src/blackjack.ts152
-rw-r--r--web/play/src/crash.ts365
-rw-r--r--web/play/src/dom.ts104
-rw-r--r--web/play/src/history.ts60
-rw-r--r--web/play/src/main.ts323
-rw-r--r--web/play/src/net.ts120
-rw-r--r--web/play/src/roulette.ts474
-rw-r--r--web/play/src/slots.ts144
-rw-r--r--web/play/src/types.ts27
9 files changed, 1769 insertions, 0 deletions
diff --git a/web/play/src/blackjack.ts b/web/play/src/blackjack.ts
new file mode 100644
index 0000000..3537f45
--- /dev/null
+++ b/web/play/src/blackjack.ts
@@ -0,0 +1,152 @@
1import type { View } from "./types";
2import { cardFace, clear, money, node } from "./dom";
3
4function cardElement(card: string): HTMLElement {
5 if (card === "?") {
6 const back = node("div", "card back");
7 back.append(node("span", "rank", "?"), node("span", "suit", ""));
8 return back;
9 }
10 const face = cardFace(card);
11 const element = node("div", "card");
12 if (face.red) element.classList.add("red");
13 element.append(node("span", "rank", face.rank), node("span", "suit", face.suit));
14 return element;
15}
16
17function handBlock(label: string, cards: string[], total: number): HTMLElement {
18 const block = node("div", "hand");
19 block.append(node("div", "label", label));
20 const row = node("div", "cards");
21 for (const card of cards) row.append(cardElement(card));
22 const sum = node("div", "total");
23 sum.append(document.createTextNode("Total "), node("strong", undefined, String(total)));
24 block.append(row, sum);
25 return block;
26}
27
28function outcome(view: View, hand: any): string {
29 const result = String(hand?.result ?? "");
30 const winnings = Number(hand?.winnings ?? 0);
31 switch (result) {
32 case "blackjack":
33 return `Blackjack — won ${money(winnings)}`;
34 case "win":
35 return `You win — ${money(winnings)}`;
36 case "push":
37 return `Push — ${money(winnings)} returned`;
38 case "bust":
39 return "Bust — the bet is lost";
40 case "lose":
41 return "Dealer wins — the bet is lost";
42 case "forfeit":
43 return "Hand forfeited — the bet is lost";
44 case "playing":
45 return view.status === "player" ? "Your move." : "Dealing…";
46 default:
47 return "Ready to deal.";
48 }
49}
50
51export function renderBlackjack(
52 board: HTMLElement,
53 side: HTMLElement,
54 view: View,
55 send: (command: unknown) => void,
56): void {
57 clear(board);
58 clear(side);
59
60 const hand = view.hand;
61 const playerPhase = hand?.phase === "player" || view.status === "player";
62 const done = hand?.phase === "done" || view.status === "done";
63 const canDeal = !hand || done;
64
65 board.append(node("h2", undefined, "Blackjack"));
66 board.append(
67 node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."),
68 );
69
70 if (hand) {
71 board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0)));
72 board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0)));
73 } else {
74 const empty = node("div", "outcome");
75 empty.textContent = "Press DEAL to start a hand.";
76 board.append(empty);
77 }
78
79 if (done || (hand && !playerPhase)) {
80 const result = node("div", "outcome");
81 result.textContent = outcome(view, hand);
82 if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") {
83 result.classList.add("win-text");
84 }
85 board.append(result);
86 }
87
88 if (view.error) board.append(node("p", "error-text", String(view.error)));
89
90 // Sidebar
91 side.append(node("div", "label", "Blackjack"));
92 const bet = Number(hand?.bet ?? view.bet ?? 0);
93 const balance = view.balance ?? view.hand?.balance;
94 side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet."));
95
96 if (canDeal) {
97 const betRow = node("div", "row");
98 const minus = node("button", "btn small", "−10");
99 const amount = node("strong", "mono", money(bet));
100 const plus = node("button", "btn small", "+10");
101 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
102 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
103 betRow.append(minus, amount, plus);
104 side.append(node("div", "label", "Bet"), betRow);
105 } else {
106 const stat = node("div", "stat");
107 stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet)));
108 side.append(stat);
109 }
110
111 if (balance !== undefined) {
112 const stat = node("div", "stat");
113 stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance))));
114 side.append(stat);
115 }
116
117 const actions = node("div", "actions");
118 if (playerPhase) {
119 const actionsRow = node("div", "row wrap");
120 for (const action of hand?.actions ?? []) {
121 const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE";
122 const button =
123 action === "stand"
124 ? node("button", "btn primary", label)
125 : node("button", "btn", label);
126 button.addEventListener("click", () => {
127 if (action === "hit") send("Hit");
128 else if (action === "stand") send("Stand");
129 else send("Double");
130 });
131 actionsRow.append(button);
132 }
133 actions.append(actionsRow);
134 const forfeit = node("button", "btn danger wide", "Forfeit hand");
135 forfeit.addEventListener("click", () => {
136 if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit");
137 });
138 actions.append(forfeit);
139 } else if (canDeal) {
140 const deal = node("button", "btn primary wide big", "DEAL");
141 deal.addEventListener("click", () => send("Deal"));
142 actions.append(deal);
143 }
144
145 const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table");
146 leave.addEventListener("click", () => {
147 if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return;
148 send("Leave");
149 });
150 actions.append(leave);
151 side.append(actions);
152}
diff --git a/web/play/src/crash.ts b/web/play/src/crash.ts
new file mode 100644
index 0000000..6f3c8dd
--- /dev/null
+++ b/web/play/src/crash.ts
@@ -0,0 +1,365 @@
1import type { View } from "./types";
2import { clear, money, node, shortHash } from "./dom";
3
4const GROWTH = Math.LN2 / 5;
5
6type Clock = { now: () => number };
7
8type Parts = {
9 board: HTMLElement;
10 side: HTMLElement;
11 number: HTMLElement;
12 meta: HTMLElement;
13 verify: HTMLElement;
14 canvas: HTMLCanvasElement;
15 bet: HTMLElement;
16 potential: HTMLElement;
17 balance: HTMLElement;
18 status: HTMLElement;
19 lastCrash: HTMLElement;
20 error: HTMLElement;
21 primary: HTMLButtonElement;
22 leave: HTMLButtonElement;
23 minus: HTMLButtonElement;
24 plus: HTMLButtonElement;
25};
26
27let parts: Parts | null = null;
28let current: View | null = null;
29let clock: Clock | null = null;
30let sendCommand: ((command: unknown) => void) | null = null;
31let frameHandle: number | undefined;
32let verifyToken = 0;
33let lastStatus = "";
34
35function build(
36 board: HTMLElement,
37 side: HTMLElement,
38 send: (command: unknown) => void,
39 now: Clock,
40): Parts {
41 clear(board);
42 clear(side);
43 sendCommand = send;
44 clock = now;
45
46 board.append(node("h2", undefined, "Crash"));
47 board.append(
48 node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."),
49 );
50
51 const wrap = node("div", "crash-wrap");
52 const number = node("div", "crash-number", "1.00×");
53 const meta = node("div", "crash-meta");
54 const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement;
55 const verify = node("div", "verify", "Waiting for a round commitment.");
56 wrap.append(number, meta, canvas, verify);
57 board.append(wrap);
58
59 side.append(node("div", "label", "Crash"));
60 side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends."));
61
62 const betRow = node("div", "row");
63 const minus = node("button", "btn small", "−10") as HTMLButtonElement;
64 const bet = node("strong", "mono", "—");
65 const plus = node("button", "btn small", "+10") as HTMLButtonElement;
66 minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 }));
67 plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 }));
68 betRow.append(minus, bet, plus);
69 side.append(node("div", "label", "Bet"), betRow);
70
71 const potential = node("strong", "mono", "—");
72 const balance = node("strong", "mono", "—");
73 for (const [label, value] of [
74 ["Potential", potential],
75 ["Balance", balance],
76 ] as const) {
77 const row = node("div", "stat");
78 row.append(node("span", "muted", label), value);
79 side.append(row);
80 }
81
82 const status = node("p", "note", "Ready.");
83 const lastCrash = node("p", "note", "");
84 const error = node("p", "error-text");
85 side.append(status, lastCrash, error);
86
87 const actions = node("div", "actions");
88 const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement;
89 const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement;
90 primary.addEventListener("click", () => {
91 if (current?.status === "running") sendCommand?.("CashOut");
92 else sendCommand?.("Start");
93 });
94 leave.addEventListener("click", () => {
95 if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return;
96 sendCommand?.("Leave");
97 });
98 actions.append(primary, leave);
99 side.append(actions);
100
101 return {
102 board,
103 side,
104 number,
105 meta,
106 verify,
107 canvas,
108 bet,
109 potential,
110 balance,
111 status,
112 lastCrash,
113 error,
114 primary,
115 leave,
116 minus,
117 plus,
118 };
119}
120
121export function renderCrash(
122 board: HTMLElement,
123 side: HTMLElement,
124 view: View,
125 send: (command: unknown) => void,
126 now: Clock,
127): void {
128 if (!parts || parts.board !== board) {
129 parts = build(board, side, send, now);
130 lastStatus = "";
131 }
132 const p = parts;
133 sendCommand = send;
134 clock = now;
135 current = view;
136
137 if (view.status !== lastStatus) {
138 if (view.status === "starting" || view.status === "running") {
139 if (lastStatus !== "running") {
140 p.verify.className = "verify";
141 p.verify.textContent = "Round committed; seed hidden until the round ends.";
142 }
143 }
144 lastStatus = String(view.status);
145 }
146
147 const running = view.status === "running";
148 const starting = view.status === "starting";
149 const live = running || starting;
150
151 p.bet.textContent = money(Number(view.bet ?? 0));
152 p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0));
153 p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance));
154 p.error.textContent = view.error ? String(view.error) : "";
155
156 p.minus.disabled = live;
157 p.plus.disabled = live;
158 p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START";
159 p.primary.disabled = starting;
160 p.primary.className = running ? "btn primary wide big" : "btn primary wide big";
161 p.leave.textContent = running ? "Forfeit round" : "Leave table";
162
163 if (running) {
164 p.status.textContent = "Rising — cash out before the crash.";
165 p.status.className = "note win-text";
166 } else if (starting) {
167 p.status.textContent = "Starting the round…";
168 p.status.className = "note";
169 } else if (view.status === "cashed") {
170 p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`;
171 p.status.className = "note win-text";
172 } else if (view.status === "crashed") {
173 p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`;
174 p.status.className = "note error-text";
175 } else if (view.status === "forfeit") {
176 p.status.textContent = "Round forfeited.";
177 p.status.className = "note error-text";
178 } else {
179 p.status.textContent = "Ready. Start a round when you want.";
180 p.status.className = "note";
181 }
182
183 if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") {
184 p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`;
185 }
186
187 const commit = view.commit ? String(view.commit) : "";
188 p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet";
189
190 if (view.seed && commit) {
191 const token = ++verifyToken;
192 const seed = String(view.seed);
193 const crashPoint = Number(view.crash_point ?? 0);
194 void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => {
195 if (token !== verifyToken || !parts) return;
196 const ok = commitOk && pointOk;
197 parts.verify.className = `verify ${ok ? "ok" : "bad"}`;
198 parts.verify.textContent = ok
199 ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed`
200 : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`;
201 });
202 } else if (!live && !commit) {
203 p.verify.className = "verify";
204 p.verify.textContent = "Waiting for a round commitment.";
205 }
206
207 if (frameHandle !== undefined) {
208 cancelAnimationFrame(frameHandle);
209 frameHandle = undefined;
210 }
211 if (running) {
212 const loop = () => {
213 if (!parts || current?.status !== "running") {
214 frameHandle = undefined;
215 return;
216 }
217 update(parts, current, now);
218 frameHandle = requestAnimationFrame(loop);
219 };
220 frameHandle = requestAnimationFrame(loop);
221 } else {
222 update(p, view, now);
223 }
224}
225
226function currentMultiplier(view: View, now: Clock): number {
227 if (view.status === "running") {
228 const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000);
229 const local = Math.min(1000, Math.exp(GROWTH * elapsed));
230 return Math.max(local, Number(view.multiplier ?? 1));
231 }
232 if (view.status === "cashed") return Number(view.multiplier ?? 1);
233 if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1);
234 return 1;
235}
236
237function update(p: Parts, view: View, now: Clock): void {
238 const multiplier = currentMultiplier(view, now);
239 p.number.textContent = `${multiplier.toFixed(2)}×`;
240 p.number.className = `crash-number ${
241 view.status === "running"
242 ? "running"
243 : view.status === "cashed"
244 ? "cashed"
245 : view.status === "crashed"
246 ? "crashed"
247 : ""
248 }`;
249 p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier));
250 drawChart(p.canvas, view, multiplier, now);
251}
252
253function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void {
254 const width = canvas.clientWidth || 600;
255 const height = canvas.clientHeight || 360;
256 const dpr = window.devicePixelRatio || 1;
257 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
258 canvas.width = Math.round(width * dpr);
259 canvas.height = Math.round(height * dpr);
260 }
261 const ctx = canvas.getContext("2d");
262 if (!ctx) return;
263 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
264 ctx.clearRect(0, 0, width, height);
265
266 const padL = 54;
267 const padR = 16;
268 const padT = 18;
269 const padB = 34;
270 const plotW = Math.max(1, width - padL - padR);
271 const plotH = Math.max(1, height - padT - padB);
272
273 const elapsed =
274 view.status === "running"
275 ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000)
276 : multiplier > 1
277 ? Math.log(multiplier) / GROWTH
278 : 1;
279 const xMax = Math.max(5, elapsed * 1.08);
280 const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2);
281 const x = (seconds: number) => padL + (seconds / xMax) * plotW;
282 const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH;
283
284 ctx.strokeStyle = "#242e38";
285 ctx.fillStyle = "#8a98a6";
286 ctx.font = "11px ui-monospace, monospace";
287 ctx.lineWidth = 1;
288 for (let step = 0; step <= 4; step += 1) {
289 const value = 1 + ((yMax - 1) * step) / 4;
290 const py = y(value);
291 ctx.beginPath();
292 ctx.moveTo(padL, py);
293 ctx.lineTo(width - padR, py);
294 ctx.stroke();
295 ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4);
296 }
297 for (let step = 0; step <= 4; step += 1) {
298 const seconds = (xMax * step) / 4;
299 const px = x(seconds);
300 ctx.beginPath();
301 ctx.moveTo(px, padT);
302 ctx.lineTo(px, padT + plotH);
303 ctx.strokeStyle = "#1b232c";
304 ctx.stroke();
305 ctx.fillStyle = "#5b6773";
306 ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12);
307 }
308
309 const line =
310 view.status === "crashed"
311 ? "#e05a5a"
312 : view.status === "cashed"
313 ? "#4ec27b"
314 : "#e0b64a";
315
316 ctx.beginPath();
317 const samples = 180;
318 for (let index = 0; index <= samples; index += 1) {
319 const seconds = (elapsed * index) / samples;
320 const value = Math.min(yMax, Math.exp(GROWTH * seconds));
321 const px = x(seconds);
322 const py = y(value);
323 if (index === 0) ctx.moveTo(px, py);
324 else ctx.lineTo(px, py);
325 }
326 ctx.strokeStyle = line;
327 ctx.lineWidth = 2.4;
328 ctx.stroke();
329
330 const endX = x(elapsed);
331 const endY = y(Math.min(yMax, multiplier));
332 ctx.beginPath();
333 ctx.arc(endX, endY, 4.5, 0, Math.PI * 2);
334 ctx.fillStyle = line;
335 ctx.fill();
336
337 ctx.fillStyle = line;
338 ctx.font = "600 12px ui-monospace, monospace";
339 ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10));
340}
341
342async function verifyReveal(
343 seed: string,
344 commit: string,
345 crashPoint: number,
346): Promise<[boolean, boolean]> {
347 const bytes = new Uint8Array(seed.length / 2);
348 for (let index = 0; index < bytes.length; index += 1) {
349 bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16);
350 }
351 const digest = await crypto.subtle.digest("SHA-256", bytes);
352 const actual = [...new Uint8Array(digest)]
353 .map((byte) => byte.toString(16).padStart(2, "0"))
354 .join("");
355 const commitOk = actual === commit.toLowerCase();
356
357 let uniform = 0;
358 for (let index = 0; index < 8; index += 1) {
359 uniform = uniform * 256 + bytes[index];
360 }
361 uniform /= 2 ** 64;
362 const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform)));
363 const pointOk = Math.abs(point - crashPoint) < 1e-6;
364 return [commitOk, pointOk];
365}
diff --git a/web/play/src/dom.ts b/web/play/src/dom.ts
new file mode 100644
index 0000000..d8ed212
--- /dev/null
+++ b/web/play/src/dom.ts
@@ -0,0 +1,104 @@
1export function byId<T extends HTMLElement = HTMLElement>(id: string): T {
2 const node = document.getElementById(id);
3 if (!node) throw new Error(`missing #${id}`);
4 return node as T;
5}
6
7export function node<K extends keyof HTMLElementTagNameMap>(
8 tag: K,
9 className?: string,
10 text?: string,
11): HTMLElementTagNameMap[K] {
12 const element = document.createElement(tag);
13 if (className) element.className = className;
14 if (text !== undefined) element.textContent = text;
15 return element;
16}
17
18export function clear(element: HTMLElement): void {
19 element.replaceChildren();
20}
21
22export function groupDigits(amount: number): string {
23 const negative = amount < 0;
24 const digits = String(Math.abs(Math.trunc(amount)));
25 let grouped = "";
26 for (let index = 0; index < digits.length; index += 1) {
27 if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009";
28 grouped += digits[index];
29 }
30 return negative ? `-${grouped}` : grouped;
31}
32
33export function money(amount: number | null | undefined): string {
34 if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr";
35 return `${groupDigits(amount)} Kr`;
36}
37
38export function signedMoney(amount: number): string {
39 return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`;
40}
41
42export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void {
43 const root = document.getElementById("toasts");
44 if (!root || !text) return;
45 const item = node("div", `toast ${tone}`, text);
46 root.append(item);
47 window.setTimeout(() => {
48 item.style.opacity = "0";
49 item.style.transition = "opacity 220ms ease";
50 window.setTimeout(() => item.remove(), 240);
51 }, 4200);
52 while (root.children.length > 4) root.firstElementChild?.remove();
53}
54
55export function toneFor(text: string): "info" | "good" | "bad" {
56 const lowered = text.toLowerCase();
57 if (
58 text.startsWith("ERROR") ||
59 lowered.includes("failed") ||
60 lowered.includes("lost") ||
61 lowered.includes("invalid") ||
62 lowered.includes("forfeit")
63 ) {
64 return "bad";
65 }
66 if (
67 text.startsWith("Logged in") ||
68 text.startsWith("Account created") ||
69 lowered.includes("won") ||
70 lowered.includes("cashed")
71 ) {
72 return "good";
73 }
74 return "info";
75}
76
77export async function sha256Hex(text: string): Promise<string> {
78 const bytes = new TextEncoder().encode(text);
79 const digest = await crypto.subtle.digest("SHA-256", bytes);
80 return [...new Uint8Array(digest)]
81 .map((byte) => byte.toString(16).padStart(2, "0"))
82 .join("");
83}
84
85export function shortHash(hash: string, keep = 10): string {
86 if (hash.length <= keep * 2) return hash;
87 return `${hash.slice(0, keep)}…${hash.slice(-keep)}`;
88}
89
90export function formatTime(raw: string): string {
91 // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC.
92 const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z");
93 if (Number.isNaN(parsed)) return raw;
94 return new Date(parsed).toLocaleString();
95}
96
97export function cardFace(card: string): { rank: string; suit: string; red: boolean } {
98 if (card === "?" || !card) return { rank: "?", suit: "", red: false };
99 const suit = card.slice(-1);
100 const rank = card.slice(0, -1);
101 const glyph =
102 suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit;
103 return { rank, suit: glyph, red: suit === "D" || suit === "H" };
104}
diff --git a/web/play/src/history.ts b/web/play/src/history.ts
new file mode 100644
index 0000000..9e7a564
--- /dev/null
+++ b/web/play/src/history.ts
@@ -0,0 +1,60 @@
1import type { View } from "./types";
2import { clear, formatTime, money, node, signedMoney } from "./dom";
3
4function summary(record: any): string {
5 const details = record.details ?? {};
6 switch (record.game) {
7 case "slots":
8 return String(details.win_type ?? "spin");
9 case "roulette":
10 return `${details.number ?? "?"} ${details.color ?? ""}`.trim();
11 case "blackjack":
12 return String(details.result ?? "");
13 case "crash": {
14 const result = String(details.result ?? "");
15 const point = details.crash_point;
16 return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`;
17 }
18 default:
19 return "";
20 }
21}
22
23export function renderHistory(root: HTMLElement, view: View): void {
24 clear(root);
25 const records: any[] = view.records ?? [];
26 if (records.length === 0) {
27 const empty = node("div", "board");
28 empty.append(
29 node("h2", undefined, "No plays yet"),
30 node("p", "muted", "Your settled plays will appear here."),
31 );
32 root.append(empty);
33 return;
34 }
35
36 const wrap = node("div", "table-wrap");
37 const table = node("table");
38 const head = node("thead");
39 const headRow = node("tr");
40 for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) {
41 headRow.append(node("th", undefined, label));
42 }
43 head.append(headRow);
44 const body = node("tbody");
45 for (const record of records) {
46 const row = node("tr");
47 row.append(node("td", "mono", formatTime(String(record.at ?? ""))));
48 row.append(node("td", undefined, String(record.game ?? "")));
49 row.append(node("td", "num", money(Number(record.bet ?? 0))));
50 row.append(node("td", "muted", summary(record)));
51 const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0);
52 const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net));
53 row.append(netCell);
54 row.append(node("td", "num", money(Number(record.balance_after ?? 0))));
55 body.append(row);
56 }
57 table.append(head, body);
58 wrap.append(table);
59 root.append(wrap);
60}
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 @@
1import { byId, money, node, sha256Hex, toast, toneFor } from "./dom";
2import { Gateway } from "./net";
3import { renderBlackjack } from "./blackjack";
4import { renderCrash } from "./crash";
5import { renderHistory } from "./history";
6import { renderRoulette } from "./roulette";
7import { renderSlots } from "./slots";
8import type { View } from "./types";
9
10const authScreen = byId("auth-screen");
11const lobbyScreen = byId("lobby-screen");
12const gameScreen = byId("game-screen");
13const historyScreen = byId("history-screen");
14const sessionBar = byId("session-bar");
15const accountName = byId("account-name");
16const accountBalance = byId("account-balance");
17const gameGrid = byId("game-grid");
18const gameBoard = byId("game-board");
19const gameSide = byId("game-side");
20const gameTitle = byId("game-title");
21const gameBadge = byId("game-badge");
22const historyBody = byId("history-body");
23const authForm = byId<HTMLFormElement>("auth-form");
24const authUser = byId<HTMLInputElement>("auth-user");
25const authPass = byId<HTMLInputElement>("auth-pass");
26const authError = byId("auth-error");
27const authSubmit = byId<HTMLButtonElement>("auth-submit");
28const tabLogin = byId<HTMLButtonElement>("tab-login");
29const tabRegister = byId<HTMLButtonElement>("tab-register");
30const reconnectOverlay = byId("reconnect-overlay");
31const reconnectMessage = byId("reconnect-message");
32const replacedOverlay = byId("replaced-overlay");
33
34let view: View | null = null;
35let currentUser: string | null = null;
36let lastBalance: number | null = null;
37let authMode: "login" | "register" = "login";
38let infoFor: string | null = null;
39let serverOffset = 0;
40let reconnectAttempts = 0;
41const bets = new Map<string, number>([
42 ["slots", 10],
43 ["roulette", 10],
44 ["blackjack", 10],
45 ["crash", 10],
46]);
47
48const titles: Record<string, string> = {
49 slots: "Slots",
50 roulette: "Roulette",
51 blackjack: "Blackjack",
52 crash: "Crash",
53};
54
55const 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
62const basePath = location.pathname.includes("/play/")
63 ? location.pathname.slice(0, location.pathname.indexOf("/play/"))
64 : "";
65const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`;
66
67const 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
142function send(command: unknown): void {
143 gateway.send(command);
144}
145
146function serverNow(): number {
147 return Date.now() + serverOffset;
148}
149
150function 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
159function 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
166function setBalance(balance: number | null | undefined): void {
167 accountBalance.textContent = money(balance ?? null);
168}
169
170function 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
217function 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
225function 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 -------------------------------------------------------
266tabLogin.addEventListener("click", () => setAuthMode("login"));
267tabRegister.addEventListener("click", () => setAuthMode("register"));
268
269authForm.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 ----------------------------------------------------
295byId("logout").addEventListener("click", () => send("Logout"));
296byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } }));
297byId("refresh-lobby").addEventListener("click", () => send("Info"));
298byId("history-back").addEventListener("click", () => send("ShowLobby"));
299byId("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});
307byId("reload-replaced").addEventListener("click", () => location.reload());
308byId("reload-reconnect").addEventListener("click", () => location.reload());
309
310window.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.
319window.setInterval(() => {
320 if (currentUser) gateway.ping();
321}, 25_000);
322
323gateway.connect();
diff --git a/web/play/src/net.ts b/web/play/src/net.ts
new file mode 100644
index 0000000..0a963ba
--- /dev/null
+++ b/web/play/src/net.ts
@@ -0,0 +1,120 @@
1import type { Handlers } from "./types";
2
3const MAX_ATTEMPTS = 6;
4
5/**
6 * Browser side of the gateway socket.
7 *
8 * The cookie is HttpOnly and handled by the browser; this class never reads or
9 * stores a credential. A transient close reconnects inside the gateway's grace
10 * window; a deliberate takeover stops for good.
11 */
12export class Gateway {
13 private socket: WebSocket | null = null;
14 private stopped = false;
15 private replaced = false;
16 private attempts = 0;
17 private timer: number | undefined;
18
19 constructor(
20 private readonly url: string,
21 private readonly handlers: Handlers,
22 ) {}
23
24 connect(): void {
25 if (this.stopped) return;
26 this.clearTimer();
27
28 let socket: WebSocket;
29 try {
30 socket = new WebSocket(this.url);
31 } catch {
32 const retrying = this.scheduleReconnect();
33 if (!retrying) this.handlers.onClosed(false);
34 return;
35 }
36 this.socket = socket;
37
38 socket.addEventListener("open", () => {
39 this.attempts = 0;
40 this.handlers.onOpen();
41 });
42
43 socket.addEventListener("message", (event) => {
44 let message: any;
45 try {
46 message = JSON.parse(String(event.data));
47 } catch {
48 return;
49 }
50 switch (message.type) {
51 case "hello":
52 this.handlers.onHello(message);
53 break;
54 case "view":
55 this.handlers.onView(message.view);
56 break;
57 case "notice":
58 this.handlers.onNotice(String(message.text ?? ""));
59 break;
60 case "error":
61 this.handlers.onError(String(message.text ?? ""));
62 break;
63 case "replaced":
64 this.replaced = true;
65 this.handlers.onReplaced();
66 break;
67 case "pong":
68 this.handlers.onPong();
69 break;
70 }
71 });
72
73 socket.addEventListener("close", () => {
74 const wasThisSocket = this.socket === socket;
75 if (wasThisSocket) this.socket = null;
76 if (this.replaced || this.stopped) {
77 this.handlers.onClosed(false);
78 return;
79 }
80 const retrying = this.scheduleReconnect();
81 this.handlers.onClosed(retrying);
82 });
83
84 socket.addEventListener("error", () => {
85 // The close event follows; reconnect is scheduled there.
86 });
87 }
88
89 send(command: unknown): void {
90 if (this.socket?.readyState !== WebSocket.OPEN) return;
91 this.socket.send(JSON.stringify({ type: "command", command }));
92 }
93
94 ping(): void {
95 if (this.socket?.readyState === WebSocket.OPEN) {
96 this.socket.send(JSON.stringify({ type: "ping" }));
97 }
98 }
99
100 close(): void {
101 this.stopped = true;
102 this.clearTimer();
103 this.socket?.close();
104 }
105
106 private scheduleReconnect(): boolean {
107 if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false;
108 this.attempts += 1;
109 const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000);
110 this.timer = window.setTimeout(() => this.connect(), delay);
111 return true;
112 }
113
114 private clearTimer(): void {
115 if (this.timer !== undefined) {
116 window.clearTimeout(this.timer);
117 this.timer = undefined;
118 }
119 }
120}
diff --git a/web/play/src/roulette.ts b/web/play/src/roulette.ts
new file mode 100644
index 0000000..cbb7c6e
--- /dev/null
+++ b/web/play/src/roulette.ts
@@ -0,0 +1,474 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4type Zone = {
5 x: number;
6 y: number;
7 w: number;
8 h: number;
9 target: any;
10 label: string;
11 kind: "number" | "zero" | "dozen" | "column" | "outside";
12 number?: number;
13};
14
15type Hotspot = {
16 x: number;
17 y: number;
18 radius: number;
19 target: any;
20 rank: number;
21};
22
23type Layout = {
24 width: number;
25 height: number;
26 cellW: number;
27 cellH: number;
28 zeroX: number;
29 zeroW: number;
30 gridX: number;
31 gridY: number;
32 gridW: number;
33 columnX: number;
34 dozensY: number;
35 outsideY: number;
36 outsideH: number;
37 zones: Zone[];
38 hotspots: Hotspot[];
39};
40
41const RED = "#b0313a";
42const BLACK = "#151b21";
43const ZERO = "#106b45";
44const FELT = "#0d5c39";
45const FELT_DARK = "#09472c";
46const FELT_LINE = "#1f7d52";
47const TEXT = "#ecf7f0";
48const GOLD = "#e0b64a";
49
50function colorOf(number: number): string {
51 if (number === 0) return "green";
52 const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];
53 return reds.includes(number) ? "red" : "black";
54}
55
56function layout(width: number, height: number): Layout {
57 const pad = 14;
58 const zeroW = Math.max(38, width * 0.055);
59 const columnW = Math.max(34, width * 0.05);
60 const outsideH = Math.max(30, height * 0.07);
61 const gap = 6;
62 const gridW = width - pad * 2 - zeroW - columnW;
63 const cellW = gridW / 12;
64 const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3;
65 const gridX = pad + zeroW;
66 const gridY = pad;
67 const dozensY = gridY + cellH * 3 + gap;
68 const outsideY = dozensY + outsideH + gap;
69 const zones: Zone[] = [];
70
71 for (let column = 0; column < 12; column += 1) {
72 for (let row = 0; row < 3; row += 1) {
73 const number = column * 3 + (3 - row);
74 zones.push({
75 x: gridX + column * cellW,
76 y: gridY + row * cellH,
77 w: cellW,
78 h: cellH,
79 target: { bet: "straight", number },
80 label: String(number),
81 kind: "number",
82 number,
83 });
84 }
85 }
86
87 zones.push({
88 x: pad,
89 y: gridY,
90 w: zeroW,
91 h: cellH * 3,
92 target: { bet: "straight", number: 0 },
93 label: "0",
94 kind: "zero",
95 number: 0,
96 });
97
98 for (let top = 0; top < 3; top += 1) {
99 const index = 2 - top;
100 zones.push({
101 x: gridX + gridW,
102 y: gridY + top * cellH,
103 w: columnW,
104 h: cellH,
105 target: { bet: "column", index },
106 label: "2:1",
107 kind: "column",
108 });
109 }
110
111 for (let index = 0; index < 3; index += 1) {
112 zones.push({
113 x: gridX + (gridW / 3) * index,
114 y: dozensY,
115 w: gridW / 3,
116 h: outsideH,
117 target: { bet: "dozen", index },
118 label: ["1st 12", "2nd 12", "3rd 12"][index],
119 kind: "dozen",
120 });
121 }
122
123 const outside = [
124 { kind: "low", label: "1–18" },
125 { kind: "even", label: "EVEN" },
126 { kind: "red", label: "RED" },
127 { kind: "black", label: "BLACK" },
128 { kind: "odd", label: "ODD" },
129 { kind: "high", label: "19–36" },
130 ];
131 outside.forEach((item, index) => {
132 zones.push({
133 x: gridX + (gridW / 6) * index,
134 y: outsideY,
135 w: gridW / 6,
136 h: outsideH,
137 target: { bet: "outside", kind: item.kind },
138 label: item.label,
139 kind: "outside",
140 });
141 });
142
143 // Split and corner hotspots sit on the lines between cells, like the real
144 // felt (and the desktop fine lattice). Corners rank ahead of splits.
145 const hotspots: Hotspot[] = [];
146 const cellCenter = (number: number) => {
147 const cell = zones.find((zone) => zone.kind === "number" && zone.number === number);
148 return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null;
149 };
150
151 for (let column = 0; column < 11; column += 1) {
152 for (let row = 0; row < 2; row += 1) {
153 const top = column * 3 + (3 - row);
154 hotspots.push({
155 x: gridX + (column + 1) * cellW,
156 y: gridY + (row + 1) * cellH,
157 radius: 12,
158 rank: 0,
159 target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] },
160 });
161 }
162 }
163
164 for (let column = 0; column < 12; column += 1) {
165 for (let row = 0; row < 2; row += 1) {
166 const top = column * 3 + (3 - row);
167 const center = cellCenter(top);
168 if (!center) continue;
169 hotspots.push({
170 x: center.x,
171 y: gridY + (row + 1) * cellH,
172 radius: 10,
173 rank: 1,
174 target: { bet: "split", a: top, b: top - 1 },
175 });
176 }
177 }
178
179 for (let column = 0; column < 11; column += 1) {
180 for (let row = 0; row < 3; row += 1) {
181 const left = column * 3 + (3 - row);
182 hotspots.push({
183 x: gridX + (column + 1) * cellW,
184 y: gridY + row * cellH + cellH / 2,
185 radius: 10,
186 rank: 1,
187 target: { bet: "split", a: left, b: left + 3 },
188 });
189 }
190 }
191
192 for (let row = 0; row < 3; row += 1) {
193 const number = 3 - row;
194 hotspots.push({
195 x: gridX,
196 y: gridY + row * cellH + cellH / 2,
197 radius: 10,
198 rank: 1,
199 target: { bet: "split", a: 0, b: number },
200 });
201 }
202
203 return {
204 width,
205 height,
206 cellW,
207 cellH,
208 zeroX: pad,
209 zeroW,
210 gridX,
211 gridY,
212 gridW,
213 columnX: gridX + gridW,
214 dozensY,
215 outsideY,
216 outsideH,
217 zones,
218 hotspots,
219 };
220}
221
222function roundRect(
223 ctx: CanvasRenderingContext2D,
224 x: number,
225 y: number,
226 w: number,
227 h: number,
228 radius: number,
229): void {
230 const r = Math.min(radius, w / 2, h / 2);
231 ctx.beginPath();
232 ctx.moveTo(x + r, y);
233 ctx.arcTo(x + w, y, x + w, y + h, r);
234 ctx.arcTo(x + w, y + h, x, y + h, r);
235 ctx.arcTo(x, y + h, x, y, r);
236 ctx.arcTo(x, y, x + w, y, r);
237 ctx.closePath();
238}
239
240function draw(canvas: HTMLCanvasElement, view: View): Layout {
241 const width = canvas.clientWidth || 800;
242 const height = canvas.clientHeight || 540;
243 const dpr = window.devicePixelRatio || 1;
244 if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
245 canvas.width = Math.round(width * dpr);
246 canvas.height = Math.round(height * dpr);
247 }
248 const ctx = canvas.getContext("2d");
249 const map = layout(width, height);
250 if (!ctx) return map;
251 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
252 ctx.clearRect(0, 0, width, height);
253 ctx.fillStyle = FELT_DARK;
254 roundRect(ctx, 0, 0, width, height, 10);
255 ctx.fill();
256
257 for (const zone of map.zones) {
258 let fill = FELT;
259 if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK;
260 else if (zone.kind === "zero") fill = ZERO;
261 ctx.fillStyle = fill;
262 ctx.strokeStyle = FELT_LINE;
263 ctx.lineWidth = 1;
264 roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3);
265 ctx.fill();
266 ctx.stroke();
267
268 ctx.fillStyle = TEXT;
269 ctx.textAlign = "center";
270 ctx.textBaseline = "middle";
271 if (zone.kind === "number" || zone.kind === "zero") {
272 ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`;
273 } else {
274 ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`;
275 }
276 ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2);
277 }
278
279 drawChips(ctx, map, view);
280 return map;
281}
282
283function targetKey(target: any): string {
284 return JSON.stringify(target);
285}
286
287function centerFor(layout: Layout, target: any): { x: number; y: number } | null {
288 const key = targetKey(target);
289 const exact = layout.zones.find((zone) => targetKey(zone.target) === key);
290 if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 };
291
292 // Split and corner bets are reachable from the desktop's fine lattice. A
293 // browser session can still show them: average the covered number cells.
294 if (target?.bet === "split" || target?.bet === "corner") {
295 const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers;
296 const cells = layout.zones.filter(
297 (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!),
298 );
299 if (cells.length > 0) {
300 return {
301 x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length,
302 y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length,
303 };
304 }
305 }
306 return null;
307}
308
309function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void {
310 const chips: any[] = view.bets?.chips ?? [];
311 for (const chip of chips) {
312 const center = centerFor(map, chip.target);
313 if (!center) continue;
314 const radius = Math.max(12, Math.min(18, map.cellW * 0.26));
315 ctx.beginPath();
316 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
317 ctx.fillStyle = GOLD;
318 ctx.fill();
319 ctx.strokeStyle = "#7a5a12";
320 ctx.lineWidth = 2;
321 ctx.stroke();
322 ctx.fillStyle = "#221a05";
323 ctx.textAlign = "center";
324 ctx.textBaseline = "middle";
325 ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`;
326 ctx.fillText(String(chip.amount), center.x, center.y + 0.5);
327 }
328}
329
330export function renderRoulette(
331 board: HTMLElement,
332 side: HTMLElement,
333 view: View,
334 send: (command: unknown) => void,
335): void {
336 clear(board);
337 clear(side);
338
339 const spinning = view.phase === "spinning";
340 board.append(node("h2", undefined, "Roulette"));
341 board.append(
342 node(
343 "p",
344 "note",
345 "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.",
346 ),
347 );
348
349 const wrap = node("div", "roulette-wrap");
350 const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement;
351 wrap.append(canvas);
352
353 if (view.phase === "result" && view.number !== null && view.number !== undefined) {
354 const result = node("div", `wheel-result ${view.color ?? ""}`);
355 result.append(node("div", "label", "Result"));
356 result.append(node("div", "num", String(view.number)));
357 const winnings = Number(view.winnings ?? 0);
358 result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win"));
359 wrap.append(result);
360 }
361 board.append(wrap);
362
363 // The canvas must be in the document before its client size is read.
364 const map = draw(canvas, view);
365 let current = map;
366
367 const hit = (clientX: number, clientY: number): Zone | null => {
368 const rect = canvas.getBoundingClientRect();
369 const x = clientX - rect.left;
370 const y = clientY - rect.top;
371
372 let best: Hotspot | null = null;
373 for (const hotspot of current.hotspots) {
374 const distance = Math.hypot(x - hotspot.x, y - hotspot.y);
375 if (distance > hotspot.radius) continue;
376 if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) {
377 best = hotspot;
378 }
379 }
380 if (best) {
381 return {
382 x: best.x - best.radius,
383 y: best.y - best.radius,
384 w: best.radius * 2,
385 h: best.radius * 2,
386 target: best.target,
387 label: "",
388 kind: "number",
389 };
390 }
391
392 for (let index = current.zones.length - 1; index >= 0; index -= 1) {
393 const zone = current.zones[index];
394 if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) {
395 return zone;
396 }
397 }
398 return null;
399 };
400
401 canvas.addEventListener("click", (event) => {
402 if (spinning) return;
403 const zone = hit(event.clientX, event.clientY);
404 if (zone) send({ PlaceBetAt: zone.target });
405 });
406 canvas.addEventListener("contextmenu", (event) => {
407 event.preventDefault();
408 if (spinning) return;
409 const zone = hit(event.clientX, event.clientY);
410 if (zone) send({ RemoveBetAt: zone.target });
411 });
412 canvas.addEventListener("mousemove", (event) => {
413 const zone = hit(event.clientX, event.clientY);
414 canvas.style.cursor = zone && !spinning ? "pointer" : "default";
415 });
416 new ResizeObserver(() => {
417 current = draw(canvas, view);
418 }).observe(canvas);
419
420 // Sidebar
421 side.append(node("div", "label", "Roulette"));
422 side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin."));
423
424 side.append(node("div", "label", "Chip"));
425 const chipRow = node("div", "chip-row");
426 for (const value of [10, 25, 50, 100, 500]) {
427 const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value));
428 button.disabled = spinning;
429 button.addEventListener("click", () => send({ SetChip: value }));
430 chipRow.append(button);
431 }
432 side.append(chipRow);
433
434 const total = (view.bets?.chips ?? []).reduce(
435 (sum: number, chip: any) => sum + Number(chip.amount ?? 0),
436 0,
437 );
438 const totalRow = node("div", "stat");
439 totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total)));
440 side.append(totalRow);
441
442 if (view.balance !== null && view.balance !== undefined) {
443 const balanceRow = node("div", "stat");
444 balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance))));
445 side.append(balanceRow);
446 }
447
448 if (view.error) side.append(node("p", "error-text", String(view.error)));
449
450 const status = node("p", "note");
451 if (spinning) status.textContent = "Spinning…";
452 else if (view.phase === "result") {
453 const winnings = Number(view.winnings ?? 0);
454 status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`;
455 status.className = winnings > 0 ? "note win-text" : "note";
456 } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip.";
457 side.append(status);
458
459 const actions = node("div", "actions");
460 const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL");
461 spin.disabled = spinning || total === 0;
462 spin.addEventListener("click", () => send("Spin"));
463 const clearBets = node("button", "btn wide", "Clear bets");
464 clearBets.disabled = spinning || total === 0;
465 clearBets.addEventListener("click", () => send("ClearBets"));
466 const leave = node("button", "btn ghost wide", "Leave table");
467 leave.disabled = spinning;
468 leave.addEventListener("click", () => {
469 if (spinning) return;
470 send("Leave");
471 });
472 actions.append(spin, clearBets, leave);
473 side.append(actions);
474}
diff --git a/web/play/src/slots.ts b/web/play/src/slots.ts
new file mode 100644
index 0000000..dba1e25
--- /dev/null
+++ b/web/play/src/slots.ts
@@ -0,0 +1,144 @@
1import type { View } from "./types";
2import { clear, money, node } from "./dom";
3
4const REELS = 5;
5const ROWS = 3;
6const SYMBOLS = "CLOGDS";
7const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"];
8
9let spinUntil = 0;
10let animation: number | undefined;
11let latest: View | null = null;
12let latestBoard: HTMLElement | null = null;
13
14export function requestSlotsSpin(): void {
15 spinUntil = Date.now() + 900;
16}
17
18function spinning(view: View): boolean {
19 return view.phase === "spinning" || Date.now() < spinUntil;
20}
21
22export function renderSlots(
23 board: HTMLElement,
24 side: HTMLElement,
25 view: View,
26 send: (command: unknown) => void,
27): void {
28 latest = view;
29 latestBoard = board;
30 if (animation !== undefined) {
31 window.clearInterval(animation);
32 animation = undefined;
33 }
34
35 const active = spinning(view);
36 clear(board);
37 clear(side);
38
39 const title = node("h2", undefined, "Slots");
40 const note = node("p", "note", "Match three or more symbols from the left. Five paylines.");
41 board.append(title, note);
42
43 const reels = node("div", "reels");
44 const tiles: HTMLElement[] = [];
45 const flat = String(view.reels ?? "");
46 for (let column = 0; column < REELS; column += 1) {
47 const reel = node("div", "reel");
48 for (let row = 0; row < ROWS; row += 1) {
49 const index = column * ROWS + row;
50 const symbol = active
51 ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length]
52 : flat.length === REELS * ROWS
53 ? flat[index]
54 : flat.length === REELS
55 ? flat[column]
56 : SYMBOLS[(index * 5 + column) % SYMBOLS.length];
57 const tile = node("div", "tile", symbol);
58 tile.classList.add(`sym-${symbol}`);
59 if (active) tile.classList.add("spinning");
60 if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win");
61 tiles.push(tile);
62 reel.append(tile);
63 }
64 reels.append(reel);
65 }
66 board.append(reels);
67
68 const lines = node("div", "win-lines");
69 if (!active && view.phase === "result") {
70 const winnings = Number(view.winnings ?? 0);
71 if (winnings > 0) {
72 const names = (view.win_lines ?? [])
73 .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`)
74 .join(" · ");
75 lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`;
76 lines.classList.add("win-text");
77 } else {
78 lines.textContent = "No win — spin again.";
79 }
80 } else if (active) {
81 lines.textContent = "Spinning…";
82 } else {
83 lines.textContent = "Pick a bet and spin.";
84 }
85 board.append(lines);
86
87 if (active) {
88 let tick = 0;
89 animation = window.setInterval(() => {
90 tick += 1;
91 for (let index = 0; index < tiles.length; index += 1) {
92 const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length];
93 tiles[index].textContent = symbol;
94 tiles[index].className = `tile spinning sym-${symbol}`;
95 }
96 if (Date.now() >= spinUntil && latest && latestBoard) {
97 window.clearInterval(animation);
98 animation = undefined;
99 renderSlots(latestBoard, side, latest, send);
100 }
101 }, 70);
102 }
103
104 // Sidebar
105 side.append(node("div", "label", "Slots"));
106 side.append(node("p", "note", "5 reels × 3 rows, 5 paylines."));
107
108 const betRow = node("div", "row");
109 const minus = node("button", "btn small", "−10");
110 const bet = node("strong", "mono", money(Number(view.bet ?? 0)));
111 const plus = node("button", "btn small", "+10");
112 minus.disabled = active;
113 plus.disabled = active;
114 minus.addEventListener("click", () => send({ AdjustBet: -10 }));
115 plus.addEventListener("click", () => send({ AdjustBet: 10 }));
116 betRow.append(minus, bet, plus);
117 side.append(node("div", "label", "Bet"), betRow);
118
119 if (view.error) {
120 side.append(node("p", "error-text", String(view.error)));
121 }
122
123 const status = node("p", "note");
124 if (active) status.textContent = "Spinning…";
125 else if (view.phase === "result") {
126 const winnings = Number(view.winnings ?? 0);
127 status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin.";
128 if (winnings > 0) status.classList.add("win-text");
129 } else status.textContent = "Ready when you are.";
130 side.append(status);
131
132 const actions = node("div", "actions");
133 const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN");
134 spin.disabled = active;
135 spin.addEventListener("click", () => {
136 requestSlotsSpin();
137 send("Spin");
138 });
139 const leave = node("button", "btn ghost wide", "Leave machine");
140 leave.disabled = active;
141 leave.addEventListener("click", () => send("Leave"));
142 actions.append(spin, leave);
143 side.append(actions);
144}
diff --git a/web/play/src/types.ts b/web/play/src/types.ts
new file mode 100644
index 0000000..e594a0b
--- /dev/null
+++ b/web/play/src/types.ts
@@ -0,0 +1,27 @@
1export type View = {
2 screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash";
3 [key: string]: any;
4};
5
6export type Hello = {
7 type: "hello";
8 version: string;
9 grace_seconds: number;
10 /** Signed-in user restored after a refresh, if any. */
11 user?: string | null;
12 /** Estimated game-server Unix time in milliseconds. */
13 time: number;
14};
15
16export type Send = (command: unknown) => void;
17
18export type Handlers = {
19 onHello: (hello: Hello) => void;
20 onView: (view: View) => void;
21 onNotice: (text: string) => void;
22 onError: (text: string) => void;
23 onReplaced: () => void;
24 onPong: () => void;
25 onOpen: () => void;
26 onClosed: (willRetry: boolean) => void;
27};