summaryrefslogtreecommitdiffstats
path: root/web/play/src/roulette.ts
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /web/play/src/roulette.ts
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-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 '')
-rw-r--r--web/play/src/roulette.ts474
1 files changed, 474 insertions, 0 deletions
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}