From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: 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. --- web/play/src/roulette.ts | 474 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 web/play/src/roulette.ts (limited to 'web/play/src/roulette.ts') 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 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +type Zone = { + x: number; + y: number; + w: number; + h: number; + target: any; + label: string; + kind: "number" | "zero" | "dozen" | "column" | "outside"; + number?: number; +}; + +type Hotspot = { + x: number; + y: number; + radius: number; + target: any; + rank: number; +}; + +type Layout = { + width: number; + height: number; + cellW: number; + cellH: number; + zeroX: number; + zeroW: number; + gridX: number; + gridY: number; + gridW: number; + columnX: number; + dozensY: number; + outsideY: number; + outsideH: number; + zones: Zone[]; + hotspots: Hotspot[]; +}; + +const RED = "#b0313a"; +const BLACK = "#151b21"; +const ZERO = "#106b45"; +const FELT = "#0d5c39"; +const FELT_DARK = "#09472c"; +const FELT_LINE = "#1f7d52"; +const TEXT = "#ecf7f0"; +const GOLD = "#e0b64a"; + +function colorOf(number: number): string { + if (number === 0) return "green"; + const reds = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]; + return reds.includes(number) ? "red" : "black"; +} + +function layout(width: number, height: number): Layout { + const pad = 14; + const zeroW = Math.max(38, width * 0.055); + const columnW = Math.max(34, width * 0.05); + const outsideH = Math.max(30, height * 0.07); + const gap = 6; + const gridW = width - pad * 2 - zeroW - columnW; + const cellW = gridW / 12; + const cellH = (height - pad * 2 - outsideH * 2 - gap * 2) / 3; + const gridX = pad + zeroW; + const gridY = pad; + const dozensY = gridY + cellH * 3 + gap; + const outsideY = dozensY + outsideH + gap; + const zones: Zone[] = []; + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 3; row += 1) { + const number = column * 3 + (3 - row); + zones.push({ + x: gridX + column * cellW, + y: gridY + row * cellH, + w: cellW, + h: cellH, + target: { bet: "straight", number }, + label: String(number), + kind: "number", + number, + }); + } + } + + zones.push({ + x: pad, + y: gridY, + w: zeroW, + h: cellH * 3, + target: { bet: "straight", number: 0 }, + label: "0", + kind: "zero", + number: 0, + }); + + for (let top = 0; top < 3; top += 1) { + const index = 2 - top; + zones.push({ + x: gridX + gridW, + y: gridY + top * cellH, + w: columnW, + h: cellH, + target: { bet: "column", index }, + label: "2:1", + kind: "column", + }); + } + + for (let index = 0; index < 3; index += 1) { + zones.push({ + x: gridX + (gridW / 3) * index, + y: dozensY, + w: gridW / 3, + h: outsideH, + target: { bet: "dozen", index }, + label: ["1st 12", "2nd 12", "3rd 12"][index], + kind: "dozen", + }); + } + + const outside = [ + { kind: "low", label: "1–18" }, + { kind: "even", label: "EVEN" }, + { kind: "red", label: "RED" }, + { kind: "black", label: "BLACK" }, + { kind: "odd", label: "ODD" }, + { kind: "high", label: "19–36" }, + ]; + outside.forEach((item, index) => { + zones.push({ + x: gridX + (gridW / 6) * index, + y: outsideY, + w: gridW / 6, + h: outsideH, + target: { bet: "outside", kind: item.kind }, + label: item.label, + kind: "outside", + }); + }); + + // Split and corner hotspots sit on the lines between cells, like the real + // felt (and the desktop fine lattice). Corners rank ahead of splits. + const hotspots: Hotspot[] = []; + const cellCenter = (number: number) => { + const cell = zones.find((zone) => zone.kind === "number" && zone.number === number); + return cell ? { x: cell.x + cell.w / 2, y: cell.y + cell.h / 2 } : null; + }; + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + (row + 1) * cellH, + radius: 12, + rank: 0, + target: { bet: "corner", numbers: [top, top - 1, top + 3, top + 2] }, + }); + } + } + + for (let column = 0; column < 12; column += 1) { + for (let row = 0; row < 2; row += 1) { + const top = column * 3 + (3 - row); + const center = cellCenter(top); + if (!center) continue; + hotspots.push({ + x: center.x, + y: gridY + (row + 1) * cellH, + radius: 10, + rank: 1, + target: { bet: "split", a: top, b: top - 1 }, + }); + } + } + + for (let column = 0; column < 11; column += 1) { + for (let row = 0; row < 3; row += 1) { + const left = column * 3 + (3 - row); + hotspots.push({ + x: gridX + (column + 1) * cellW, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: left, b: left + 3 }, + }); + } + } + + for (let row = 0; row < 3; row += 1) { + const number = 3 - row; + hotspots.push({ + x: gridX, + y: gridY + row * cellH + cellH / 2, + radius: 10, + rank: 1, + target: { bet: "split", a: 0, b: number }, + }); + } + + return { + width, + height, + cellW, + cellH, + zeroX: pad, + zeroW, + gridX, + gridY, + gridW, + columnX: gridX + gridW, + dozensY, + outsideY, + outsideH, + zones, + hotspots, + }; +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radius: number, +): void { + const r = Math.min(radius, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function draw(canvas: HTMLCanvasElement, view: View): Layout { + const width = canvas.clientWidth || 800; + const height = canvas.clientHeight || 540; + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + const ctx = canvas.getContext("2d"); + const map = layout(width, height); + if (!ctx) return map; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = FELT_DARK; + roundRect(ctx, 0, 0, width, height, 10); + ctx.fill(); + + for (const zone of map.zones) { + let fill = FELT; + if (zone.kind === "number") fill = zone.number === 0 ? ZERO : colorOf(zone.number!) === "red" ? RED : BLACK; + else if (zone.kind === "zero") fill = ZERO; + ctx.fillStyle = fill; + ctx.strokeStyle = FELT_LINE; + ctx.lineWidth = 1; + roundRect(ctx, zone.x + 1, zone.y + 1, zone.w - 2, zone.h - 2, 3); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = TEXT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + if (zone.kind === "number" || zone.kind === "zero") { + ctx.font = `700 ${Math.min(24, map.cellH * 0.3)}px ui-monospace, monospace`; + } else { + ctx.font = `600 ${Math.min(12, map.outsideH * 0.4)}px ui-monospace, monospace`; + } + ctx.fillText(zone.label, zone.x + zone.w / 2, zone.y + zone.h / 2); + } + + drawChips(ctx, map, view); + return map; +} + +function targetKey(target: any): string { + return JSON.stringify(target); +} + +function centerFor(layout: Layout, target: any): { x: number; y: number } | null { + const key = targetKey(target); + const exact = layout.zones.find((zone) => targetKey(zone.target) === key); + if (exact) return { x: exact.x + exact.w / 2, y: exact.y + exact.h / 2 }; + + // Split and corner bets are reachable from the desktop's fine lattice. A + // browser session can still show them: average the covered number cells. + if (target?.bet === "split" || target?.bet === "corner") { + const numbers: number[] = target.bet === "split" ? [target.a, target.b] : target.numbers; + const cells = layout.zones.filter( + (zone) => (zone.kind === "number" || zone.kind === "zero") && numbers.includes(zone.number!), + ); + if (cells.length > 0) { + return { + x: cells.reduce((sum, cell) => sum + cell.x + cell.w / 2, 0) / cells.length, + y: cells.reduce((sum, cell) => sum + cell.y + cell.h / 2, 0) / cells.length, + }; + } + } + return null; +} + +function drawChips(ctx: CanvasRenderingContext2D, map: Layout, view: View): void { + const chips: any[] = view.bets?.chips ?? []; + for (const chip of chips) { + const center = centerFor(map, chip.target); + if (!center) continue; + const radius = Math.max(12, Math.min(18, map.cellW * 0.26)); + ctx.beginPath(); + ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); + ctx.fillStyle = GOLD; + ctx.fill(); + ctx.strokeStyle = "#7a5a12"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = "#221a05"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `700 ${radius > 15 ? 11 : 10}px ui-monospace, monospace`; + ctx.fillText(String(chip.amount), center.x, center.y + 0.5); + } +} + +export function renderRoulette( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const spinning = view.phase === "spinning"; + board.append(node("h2", undefined, "Roulette")); + board.append( + node( + "p", + "note", + "European single-zero wheel. Click a number, line or corner to add a chip; right-click takes one back.", + ), + ); + + const wrap = node("div", "roulette-wrap"); + const canvas = node("canvas", "roulette-canvas") as HTMLCanvasElement; + wrap.append(canvas); + + if (view.phase === "result" && view.number !== null && view.number !== undefined) { + const result = node("div", `wheel-result ${view.color ?? ""}`); + result.append(node("div", "label", "Result")); + result.append(node("div", "num", String(view.number))); + const winnings = Number(view.winnings ?? 0); + result.append(node("div", "note", winnings > 0 ? `Won ${money(winnings)}` : "No win")); + wrap.append(result); + } + board.append(wrap); + + // The canvas must be in the document before its client size is read. + const map = draw(canvas, view); + let current = map; + + const hit = (clientX: number, clientY: number): Zone | null => { + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + let best: Hotspot | null = null; + for (const hotspot of current.hotspots) { + const distance = Math.hypot(x - hotspot.x, y - hotspot.y); + if (distance > hotspot.radius) continue; + if (!best || hotspot.rank < best.rank || (hotspot.rank === best.rank && distance < Math.hypot(x - best.x, y - best.y))) { + best = hotspot; + } + } + if (best) { + return { + x: best.x - best.radius, + y: best.y - best.radius, + w: best.radius * 2, + h: best.radius * 2, + target: best.target, + label: "", + kind: "number", + }; + } + + for (let index = current.zones.length - 1; index >= 0; index -= 1) { + const zone = current.zones[index]; + if (x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) { + return zone; + } + } + return null; + }; + + canvas.addEventListener("click", (event) => { + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ PlaceBetAt: zone.target }); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + if (spinning) return; + const zone = hit(event.clientX, event.clientY); + if (zone) send({ RemoveBetAt: zone.target }); + }); + canvas.addEventListener("mousemove", (event) => { + const zone = hit(event.clientX, event.clientY); + canvas.style.cursor = zone && !spinning ? "pointer" : "default"; + }); + new ResizeObserver(() => { + current = draw(canvas, view); + }).observe(canvas); + + // Sidebar + side.append(node("div", "label", "Roulette")); + side.append(node("p", "note", "Chips sit on the felt. The total is reserved when you spin.")); + + side.append(node("div", "label", "Chip")); + const chipRow = node("div", "chip-row"); + for (const value of [10, 25, 50, 100, 500]) { + const button = node("button", `chip-btn ${Number(view.chip) === value ? "active" : ""}`, String(value)); + button.disabled = spinning; + button.addEventListener("click", () => send({ SetChip: value })); + chipRow.append(button); + } + side.append(chipRow); + + const total = (view.bets?.chips ?? []).reduce( + (sum: number, chip: any) => sum + Number(chip.amount ?? 0), + 0, + ); + const totalRow = node("div", "stat"); + totalRow.append(node("span", "muted", "On the felt"), node("strong", "mono", money(total))); + side.append(totalRow); + + if (view.balance !== null && view.balance !== undefined) { + const balanceRow = node("div", "stat"); + balanceRow.append(node("span", "muted", "Balance"), node("strong", "mono", money(Number(view.balance)))); + side.append(balanceRow); + } + + if (view.error) side.append(node("p", "error-text", String(view.error))); + + const status = node("p", "note"); + if (spinning) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = `${view.number} ${view.color} · ${winnings > 0 ? `won ${money(winnings)}` : "no win"}`; + status.className = winnings > 0 ? "note win-text" : "note"; + } else status.textContent = total > 0 ? "Ready to spin." : "Place at least one chip."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", spinning ? "SPINNING…" : "SPIN WHEEL"); + spin.disabled = spinning || total === 0; + spin.addEventListener("click", () => send("Spin")); + const clearBets = node("button", "btn wide", "Clear bets"); + clearBets.disabled = spinning || total === 0; + clearBets.addEventListener("click", () => send("ClearBets")); + const leave = node("button", "btn ghost wide", "Leave table"); + leave.disabled = spinning; + leave.addEventListener("click", () => { + if (spinning) return; + send("Leave"); + }); + actions.append(spin, clearBets, leave); + side.append(actions); +} -- cgit v1.2.3