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/blackjack.ts | 152 +++++++++++++++ web/play/src/crash.ts | 365 +++++++++++++++++++++++++++++++++++ web/play/src/dom.ts | 104 ++++++++++ web/play/src/history.ts | 60 ++++++ web/play/src/main.ts | 323 +++++++++++++++++++++++++++++++ web/play/src/net.ts | 120 ++++++++++++ web/play/src/roulette.ts | 474 ++++++++++++++++++++++++++++++++++++++++++++++ web/play/src/slots.ts | 144 ++++++++++++++ web/play/src/types.ts | 27 +++ 9 files changed, 1769 insertions(+) create mode 100644 web/play/src/blackjack.ts create mode 100644 web/play/src/crash.ts create mode 100644 web/play/src/dom.ts create mode 100644 web/play/src/history.ts create mode 100644 web/play/src/main.ts create mode 100644 web/play/src/net.ts create mode 100644 web/play/src/roulette.ts create mode 100644 web/play/src/slots.ts create mode 100644 web/play/src/types.ts (limited to 'web/play/src') 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 @@ +import type { View } from "./types"; +import { cardFace, clear, money, node } from "./dom"; + +function cardElement(card: string): HTMLElement { + if (card === "?") { + const back = node("div", "card back"); + back.append(node("span", "rank", "?"), node("span", "suit", "")); + return back; + } + const face = cardFace(card); + const element = node("div", "card"); + if (face.red) element.classList.add("red"); + element.append(node("span", "rank", face.rank), node("span", "suit", face.suit)); + return element; +} + +function handBlock(label: string, cards: string[], total: number): HTMLElement { + const block = node("div", "hand"); + block.append(node("div", "label", label)); + const row = node("div", "cards"); + for (const card of cards) row.append(cardElement(card)); + const sum = node("div", "total"); + sum.append(document.createTextNode("Total "), node("strong", undefined, String(total))); + block.append(row, sum); + return block; +} + +function outcome(view: View, hand: any): string { + const result = String(hand?.result ?? ""); + const winnings = Number(hand?.winnings ?? 0); + switch (result) { + case "blackjack": + return `Blackjack — won ${money(winnings)}`; + case "win": + return `You win — ${money(winnings)}`; + case "push": + return `Push — ${money(winnings)} returned`; + case "bust": + return "Bust — the bet is lost"; + case "lose": + return "Dealer wins — the bet is lost"; + case "forfeit": + return "Hand forfeited — the bet is lost"; + case "playing": + return view.status === "player" ? "Your move." : "Dealing…"; + default: + return "Ready to deal."; + } +} + +export function renderBlackjack( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + clear(board); + clear(side); + + const hand = view.hand; + const playerPhase = hand?.phase === "player" || view.status === "player"; + const done = hand?.phase === "done" || view.status === "done"; + const canDeal = !hand || done; + + board.append(node("h2", undefined, "Blackjack")); + board.append( + node("p", "note", "Six decks. Dealer stands on all 17. Blackjack pays 3:2."), + ); + + if (hand) { + board.append(handBlock("Dealer", hand.dealer ?? [], Number(hand.dealer_total ?? 0))); + board.append(handBlock("You", hand.player ?? [], Number(hand.player_total ?? 0))); + } else { + const empty = node("div", "outcome"); + empty.textContent = "Press DEAL to start a hand."; + board.append(empty); + } + + if (done || (hand && !playerPhase)) { + const result = node("div", "outcome"); + result.textContent = outcome(view, hand); + if (String(hand?.result) === "win" || String(hand?.result) === "blackjack" || String(hand?.result) === "push") { + result.classList.add("win-text"); + } + board.append(result); + } + + if (view.error) board.append(node("p", "error-text", String(view.error))); + + // Sidebar + side.append(node("div", "label", "Blackjack")); + const bet = Number(hand?.bet ?? view.bet ?? 0); + const balance = view.balance ?? view.hand?.balance; + side.append(node("p", "note", "Hit, stand or double. Forfeiting loses the reserved bet.")); + + if (canDeal) { + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const amount = node("strong", "mono", money(bet)); + const plus = node("button", "btn small", "+10"); + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, amount, plus); + side.append(node("div", "label", "Bet"), betRow); + } else { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Bet"), node("strong", undefined, money(bet))); + side.append(stat); + } + + if (balance !== undefined) { + const stat = node("div", "stat"); + stat.append(node("span", "muted", "Balance"), node("strong", undefined, money(Number(balance)))); + side.append(stat); + } + + const actions = node("div", "actions"); + if (playerPhase) { + const actionsRow = node("div", "row wrap"); + for (const action of hand?.actions ?? []) { + const label = action === "hit" ? "HIT" : action === "stand" ? "STAND" : "DOUBLE"; + const button = + action === "stand" + ? node("button", "btn primary", label) + : node("button", "btn", label); + button.addEventListener("click", () => { + if (action === "hit") send("Hit"); + else if (action === "stand") send("Stand"); + else send("Double"); + }); + actionsRow.append(button); + } + actions.append(actionsRow); + const forfeit = node("button", "btn danger wide", "Forfeit hand"); + forfeit.addEventListener("click", () => { + if (window.confirm("Forfeit this hand? The reserved bet is lost.")) send("Forfeit"); + }); + actions.append(forfeit); + } else if (canDeal) { + const deal = node("button", "btn primary wide big", "DEAL"); + deal.addEventListener("click", () => send("Deal")); + actions.append(deal); + } + + const leave = node("button", "btn ghost wide", playerPhase ? "Leave (forfeits)" : "Leave table"); + leave.addEventListener("click", () => { + if (playerPhase && !window.confirm("Leaving forfeits this hand. Continue?")) return; + send("Leave"); + }); + actions.append(leave); + side.append(actions); +} 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 @@ +import type { View } from "./types"; +import { clear, money, node, shortHash } from "./dom"; + +const GROWTH = Math.LN2 / 5; + +type Clock = { now: () => number }; + +type Parts = { + board: HTMLElement; + side: HTMLElement; + number: HTMLElement; + meta: HTMLElement; + verify: HTMLElement; + canvas: HTMLCanvasElement; + bet: HTMLElement; + potential: HTMLElement; + balance: HTMLElement; + status: HTMLElement; + lastCrash: HTMLElement; + error: HTMLElement; + primary: HTMLButtonElement; + leave: HTMLButtonElement; + minus: HTMLButtonElement; + plus: HTMLButtonElement; +}; + +let parts: Parts | null = null; +let current: View | null = null; +let clock: Clock | null = null; +let sendCommand: ((command: unknown) => void) | null = null; +let frameHandle: number | undefined; +let verifyToken = 0; +let lastStatus = ""; + +function build( + board: HTMLElement, + side: HTMLElement, + send: (command: unknown) => void, + now: Clock, +): Parts { + clear(board); + clear(side); + sendCommand = send; + clock = now; + + board.append(node("h2", undefined, "Crash")); + board.append( + node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), + ); + + const wrap = node("div", "crash-wrap"); + const number = node("div", "crash-number", "1.00×"); + const meta = node("div", "crash-meta"); + const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; + const verify = node("div", "verify", "Waiting for a round commitment."); + wrap.append(number, meta, canvas, verify); + board.append(wrap); + + side.append(node("div", "label", "Crash")); + side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10") as HTMLButtonElement; + const bet = node("strong", "mono", "—"); + const plus = node("button", "btn small", "+10") as HTMLButtonElement; + minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); + plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + const potential = node("strong", "mono", "—"); + const balance = node("strong", "mono", "—"); + for (const [label, value] of [ + ["Potential", potential], + ["Balance", balance], + ] as const) { + const row = node("div", "stat"); + row.append(node("span", "muted", label), value); + side.append(row); + } + + const status = node("p", "note", "Ready."); + const lastCrash = node("p", "note", ""); + const error = node("p", "error-text"); + side.append(status, lastCrash, error); + + const actions = node("div", "actions"); + const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; + const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; + primary.addEventListener("click", () => { + if (current?.status === "running") sendCommand?.("CashOut"); + else sendCommand?.("Start"); + }); + leave.addEventListener("click", () => { + if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return; + sendCommand?.("Leave"); + }); + actions.append(primary, leave); + side.append(actions); + + return { + board, + side, + number, + meta, + verify, + canvas, + bet, + potential, + balance, + status, + lastCrash, + error, + primary, + leave, + minus, + plus, + }; +} + +export function renderCrash( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, + now: Clock, +): void { + if (!parts || parts.board !== board) { + parts = build(board, side, send, now); + lastStatus = ""; + } + const p = parts; + sendCommand = send; + clock = now; + current = view; + + if (view.status !== lastStatus) { + if (view.status === "starting" || view.status === "running") { + if (lastStatus !== "running") { + p.verify.className = "verify"; + p.verify.textContent = "Round committed; seed hidden until the round ends."; + } + } + lastStatus = String(view.status); + } + + const running = view.status === "running"; + const starting = view.status === "starting"; + const live = running || starting; + + p.bet.textContent = money(Number(view.bet ?? 0)); + p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0)); + p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance)); + p.error.textContent = view.error ? String(view.error) : ""; + + p.minus.disabled = live; + p.plus.disabled = live; + p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START"; + p.primary.disabled = starting; + p.primary.className = running ? "btn primary wide big" : "btn primary wide big"; + p.leave.textContent = running ? "Forfeit round" : "Leave table"; + + if (running) { + p.status.textContent = "Rising — cash out before the crash."; + p.status.className = "note win-text"; + } else if (starting) { + p.status.textContent = "Starting the round…"; + p.status.className = "note"; + } else if (view.status === "cashed") { + p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`; + p.status.className = "note win-text"; + } else if (view.status === "crashed") { + p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`; + p.status.className = "note error-text"; + } else if (view.status === "forfeit") { + p.status.textContent = "Round forfeited."; + p.status.className = "note error-text"; + } else { + p.status.textContent = "Ready. Start a round when you want."; + p.status.className = "note"; + } + + if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") { + p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`; + } + + const commit = view.commit ? String(view.commit) : ""; + p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; + + if (view.seed && commit) { + const token = ++verifyToken; + const seed = String(view.seed); + const crashPoint = Number(view.crash_point ?? 0); + void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => { + if (token !== verifyToken || !parts) return; + const ok = commitOk && pointOk; + parts.verify.className = `verify ${ok ? "ok" : "bad"}`; + parts.verify.textContent = ok + ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed` + : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`; + }); + } else if (!live && !commit) { + p.verify.className = "verify"; + p.verify.textContent = "Waiting for a round commitment."; + } + + if (frameHandle !== undefined) { + cancelAnimationFrame(frameHandle); + frameHandle = undefined; + } + if (running) { + const loop = () => { + if (!parts || current?.status !== "running") { + frameHandle = undefined; + return; + } + update(parts, current, now); + frameHandle = requestAnimationFrame(loop); + }; + frameHandle = requestAnimationFrame(loop); + } else { + update(p, view, now); + } +} + +function currentMultiplier(view: View, now: Clock): number { + if (view.status === "running") { + const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000); + const local = Math.min(1000, Math.exp(GROWTH * elapsed)); + return Math.max(local, Number(view.multiplier ?? 1)); + } + if (view.status === "cashed") return Number(view.multiplier ?? 1); + if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1); + return 1; +} + +function update(p: Parts, view: View, now: Clock): void { + const multiplier = currentMultiplier(view, now); + p.number.textContent = `${multiplier.toFixed(2)}×`; + p.number.className = `crash-number ${ + view.status === "running" + ? "running" + : view.status === "cashed" + ? "cashed" + : view.status === "crashed" + ? "crashed" + : "" + }`; + p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier)); + drawChart(p.canvas, view, multiplier, now); +} + +function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void { + const width = canvas.clientWidth || 600; + const height = canvas.clientHeight || 360; + 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"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const padL = 54; + const padR = 16; + const padT = 18; + const padB = 34; + const plotW = Math.max(1, width - padL - padR); + const plotH = Math.max(1, height - padT - padB); + + const elapsed = + view.status === "running" + ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000) + : multiplier > 1 + ? Math.log(multiplier) / GROWTH + : 1; + const xMax = Math.max(5, elapsed * 1.08); + const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2); + const x = (seconds: number) => padL + (seconds / xMax) * plotW; + const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH; + + ctx.strokeStyle = "#242e38"; + ctx.fillStyle = "#8a98a6"; + ctx.font = "11px ui-monospace, monospace"; + ctx.lineWidth = 1; + for (let step = 0; step <= 4; step += 1) { + const value = 1 + ((yMax - 1) * step) / 4; + const py = y(value); + ctx.beginPath(); + ctx.moveTo(padL, py); + ctx.lineTo(width - padR, py); + ctx.stroke(); + ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); + } + for (let step = 0; step <= 4; step += 1) { + const seconds = (xMax * step) / 4; + const px = x(seconds); + ctx.beginPath(); + ctx.moveTo(px, padT); + ctx.lineTo(px, padT + plotH); + ctx.strokeStyle = "#1b232c"; + ctx.stroke(); + ctx.fillStyle = "#5b6773"; + ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); + } + + const line = + view.status === "crashed" + ? "#e05a5a" + : view.status === "cashed" + ? "#4ec27b" + : "#e0b64a"; + + ctx.beginPath(); + const samples = 180; + for (let index = 0; index <= samples; index += 1) { + const seconds = (elapsed * index) / samples; + const value = Math.min(yMax, Math.exp(GROWTH * seconds)); + const px = x(seconds); + const py = y(value); + if (index === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.strokeStyle = line; + ctx.lineWidth = 2.4; + ctx.stroke(); + + const endX = x(elapsed); + const endY = y(Math.min(yMax, multiplier)); + ctx.beginPath(); + ctx.arc(endX, endY, 4.5, 0, Math.PI * 2); + ctx.fillStyle = line; + ctx.fill(); + + ctx.fillStyle = line; + ctx.font = "600 12px ui-monospace, monospace"; + ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10)); +} + +async function verifyReveal( + seed: string, + commit: string, + crashPoint: number, +): Promise<[boolean, boolean]> { + const bytes = new Uint8Array(seed.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16); + } + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const commitOk = actual === commit.toLowerCase(); + + let uniform = 0; + for (let index = 0; index < 8; index += 1) { + uniform = uniform * 256 + bytes[index]; + } + uniform /= 2 ** 64; + const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform))); + const pointOk = Math.abs(point - crashPoint) < 1e-6; + return [commitOk, pointOk]; +} 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 @@ +export function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; +} + +export function node( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +export function clear(element: HTMLElement): void { + element.replaceChildren(); +} + +export function groupDigits(amount: number): string { + const negative = amount < 0; + const digits = String(Math.abs(Math.trunc(amount))); + let grouped = ""; + for (let index = 0; index < digits.length; index += 1) { + if (index > 0 && (digits.length - index) % 3 === 0) grouped += "\u2009"; + grouped += digits[index]; + } + return negative ? `-${grouped}` : grouped; +} + +export function money(amount: number | null | undefined): string { + if (amount === null || amount === undefined || Number.isNaN(amount)) return "— Kr"; + return `${groupDigits(amount)} Kr`; +} + +export function signedMoney(amount: number): string { + return `${amount < 0 ? "-" : "+"}${money(Math.abs(amount))}`; +} + +export function toast(text: string, tone: "info" | "good" | "bad" = "info"): void { + const root = document.getElementById("toasts"); + if (!root || !text) return; + const item = node("div", `toast ${tone}`, text); + root.append(item); + window.setTimeout(() => { + item.style.opacity = "0"; + item.style.transition = "opacity 220ms ease"; + window.setTimeout(() => item.remove(), 240); + }, 4200); + while (root.children.length > 4) root.firstElementChild?.remove(); +} + +export function toneFor(text: string): "info" | "good" | "bad" { + const lowered = text.toLowerCase(); + if ( + text.startsWith("ERROR") || + lowered.includes("failed") || + lowered.includes("lost") || + lowered.includes("invalid") || + lowered.includes("forfeit") + ) { + return "bad"; + } + if ( + text.startsWith("Logged in") || + text.startsWith("Account created") || + lowered.includes("won") || + lowered.includes("cashed") + ) { + return "good"; + } + return "info"; +} + +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function shortHash(hash: string, keep = 10): string { + if (hash.length <= keep * 2) return hash; + return `${hash.slice(0, keep)}…${hash.slice(-keep)}`; +} + +export function formatTime(raw: string): string { + // SQLite stores `YYYY-MM-DD HH:MM:SS` in UTC. + const parsed = Date.parse(raw.includes("T") ? raw : raw.replace(" ", "T") + "Z"); + if (Number.isNaN(parsed)) return raw; + return new Date(parsed).toLocaleString(); +} + +export function cardFace(card: string): { rank: string; suit: string; red: boolean } { + if (card === "?" || !card) return { rank: "?", suit: "", red: false }; + const suit = card.slice(-1); + const rank = card.slice(0, -1); + const glyph = + suit === "C" ? "♣" : suit === "D" ? "♦" : suit === "H" ? "♥" : suit === "S" ? "♠" : suit; + return { rank, suit: glyph, red: suit === "D" || suit === "H" }; +} 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 @@ +import type { View } from "./types"; +import { clear, formatTime, money, node, signedMoney } from "./dom"; + +function summary(record: any): string { + const details = record.details ?? {}; + switch (record.game) { + case "slots": + return String(details.win_type ?? "spin"); + case "roulette": + return `${details.number ?? "?"} ${details.color ?? ""}`.trim(); + case "blackjack": + return String(details.result ?? ""); + case "crash": { + const result = String(details.result ?? ""); + const point = details.crash_point; + return point === undefined || point === null ? result : `${result} @ ${Number(point).toFixed(2)}×`; + } + default: + return ""; + } +} + +export function renderHistory(root: HTMLElement, view: View): void { + clear(root); + const records: any[] = view.records ?? []; + if (records.length === 0) { + const empty = node("div", "board"); + empty.append( + node("h2", undefined, "No plays yet"), + node("p", "muted", "Your settled plays will appear here."), + ); + root.append(empty); + return; + } + + const wrap = node("div", "table-wrap"); + const table = node("table"); + const head = node("thead"); + const headRow = node("tr"); + for (const label of ["Time", "Game", "Bet", "Result", "Net", "Balance"]) { + headRow.append(node("th", undefined, label)); + } + head.append(headRow); + const body = node("tbody"); + for (const record of records) { + const row = node("tr"); + row.append(node("td", "mono", formatTime(String(record.at ?? "")))); + row.append(node("td", undefined, String(record.game ?? ""))); + row.append(node("td", "num", money(Number(record.bet ?? 0)))); + row.append(node("td", "muted", summary(record))); + const net = Number(record.winnings ?? 0) - Number(record.bet ?? 0); + const netCell = node("td", `num ${net > 0 ? "pos" : net < 0 ? "neg" : ""}`, net === 0 ? "0 Kr" : signedMoney(net)); + row.append(netCell); + row.append(node("td", "num", money(Number(record.balance_after ?? 0)))); + body.append(row); + } + table.append(head, body); + wrap.append(table); + root.append(wrap); +} 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 @@ +import { byId, money, node, sha256Hex, toast, toneFor } from "./dom"; +import { Gateway } from "./net"; +import { renderBlackjack } from "./blackjack"; +import { renderCrash } from "./crash"; +import { renderHistory } from "./history"; +import { renderRoulette } from "./roulette"; +import { renderSlots } from "./slots"; +import type { View } from "./types"; + +const authScreen = byId("auth-screen"); +const lobbyScreen = byId("lobby-screen"); +const gameScreen = byId("game-screen"); +const historyScreen = byId("history-screen"); +const sessionBar = byId("session-bar"); +const accountName = byId("account-name"); +const accountBalance = byId("account-balance"); +const gameGrid = byId("game-grid"); +const gameBoard = byId("game-board"); +const gameSide = byId("game-side"); +const gameTitle = byId("game-title"); +const gameBadge = byId("game-badge"); +const historyBody = byId("history-body"); +const authForm = byId("auth-form"); +const authUser = byId("auth-user"); +const authPass = byId("auth-pass"); +const authError = byId("auth-error"); +const authSubmit = byId("auth-submit"); +const tabLogin = byId("tab-login"); +const tabRegister = byId("tab-register"); +const reconnectOverlay = byId("reconnect-overlay"); +const reconnectMessage = byId("reconnect-message"); +const replacedOverlay = byId("replaced-overlay"); + +let view: View | null = null; +let currentUser: string | null = null; +let lastBalance: number | null = null; +let authMode: "login" | "register" = "login"; +let infoFor: string | null = null; +let serverOffset = 0; +let reconnectAttempts = 0; +const bets = new Map([ + ["slots", 10], + ["roulette", 10], + ["blackjack", 10], + ["crash", 10], +]); + +const titles: Record = { + slots: "Slots", + roulette: "Roulette", + blackjack: "Blackjack", + crash: "Crash", +}; + +const descriptions: Record = { + slots: "Five reels, five paylines. Match symbols from the left.", + roulette: "European single-zero wheel with inside and outside bets.", + blackjack: "Six-deck shoe. Blackjack pays 3:2.", + crash: "A rising multiplier. Cash out before it crashes.", +}; + +const basePath = location.pathname.includes("/play/") + ? location.pathname.slice(0, location.pathname.indexOf("/play/")) + : ""; +const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${basePath}/ws`; + +const gateway = new Gateway(wsUrl, { + onHello(hello) { + serverOffset = hello.time - Date.now(); + currentUser = hello.user ?? null; + gameBadge.textContent = `v${hello.version}`; + }, + onView(next) { + view = next; + // Game and history views carry no account field; the lobby is the source + // of truth for who is signed in. A null lobby user is a logout or an + // expired session. + if (typeof next.balance === "number") lastBalance = next.balance; + if (next.screen === "lobby" && "user" in next) { + currentUser = (next.user as string | null) ?? null; + if (currentUser) authPass.value = ""; + else lastBalance = null; + } + reconnectAttempts = 0; + reconnectOverlay.hidden = true; + render(); + if (currentUser && view.balance === null && infoFor !== currentUser) { + infoFor = currentUser; + gateway.send("Info"); + } + if (!currentUser) infoFor = null; + }, + onNotice(text) { + if ( + text.startsWith("Connected to:") || + text.startsWith("Fingerprint:") || + text.startsWith("User:") || + text.startsWith("Account:") || + text.startsWith("Balance:") + ) { + return; + } + toast(text, toneFor(text)); + if (text.startsWith("Account created")) { + setAuthMode("login"); + authPass.value = ""; + authUser.focus(); + } + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onError(text) { + toast(text, "bad"); + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + }, + onReplaced() { + replacedOverlay.hidden = false; + }, + onPong() { + /* The gateway answered; the socket is alive. */ + }, + onOpen() { + reconnectOverlay.hidden = true; + }, + onClosed(willRetry) { + if (replacedOverlay.hidden === false) return; + if (!willRetry) { + reconnectMessage.textContent = "The connection could not be restored. Reload the page to try again."; + reconnectOverlay.hidden = false; + return; + } + reconnectAttempts += 1; + reconnectMessage.textContent = + reconnectAttempts > 1 + ? "Still trying to reach the game server…" + : "Trying to reach the game server…"; + reconnectOverlay.hidden = false; + }, +}); + +function send(command: unknown): void { + gateway.send(command); +} + +function serverNow(): number { + return Date.now() + serverOffset; +} + +function setAuthMode(mode: "login" | "register"): void { + authMode = mode; + tabLogin.classList.toggle("active", mode === "login"); + tabRegister.classList.toggle("active", mode === "register"); + authSubmit.textContent = mode === "login" ? "Log in" : "Create account"; + authPass.autocomplete = mode === "login" ? "current-password" : "new-password"; + authError.textContent = ""; +} + +function show(screen: "auth" | "lobby" | "game" | "history"): void { + authScreen.hidden = screen !== "auth"; + lobbyScreen.hidden = screen !== "lobby"; + gameScreen.hidden = screen !== "game"; + historyScreen.hidden = screen !== "history"; +} + +function setBalance(balance: number | null | undefined): void { + accountBalance.textContent = money(balance ?? null); +} + +function render(): void { + if (!view) return; + const user = currentUser; + if (user) { + sessionBar.hidden = false; + accountName.textContent = String(user); + } else { + sessionBar.hidden = true; + } + + setBalance(lastBalance); + + if (!user) { + show("auth"); + authError.textContent = view.login_error ? String(view.login_error) : ""; + if (view.screen === "lobby" && view.connected === false) { + authError.textContent = "The game server is not reachable yet. Retrying…"; + } + return; + } + + switch (view.screen) { + case "lobby": + show("lobby"); + renderLobby(view); + break; + case "history": + show("history"); + renderHistory(historyBody, view); + break; + case "slots": + case "roulette": + case "blackjack": + case "crash": + show("game"); + gameTitle.textContent = titles[view.screen] ?? view.screen; + gameBadge.textContent = badge(view); + if (view.screen === "slots") renderSlots(gameBoard, gameSide, view, send); + else if (view.screen === "roulette") renderRoulette(gameBoard, gameSide, view, send); + else if (view.screen === "blackjack") renderBlackjack(gameBoard, gameSide, view, send); + else renderCrash(gameBoard, gameSide, view, send, { now: serverNow }); + break; + default: + show("auth"); + } +} + +function badge(view: View): string { + if (view.screen === "crash") return String(view.status ?? ""); + if (view.screen === "blackjack") return String(view.status ?? ""); + if (view.screen === "slots") return String(view.phase ?? ""); + if (view.screen === "roulette") return String(view.phase ?? ""); + return ""; +} + +function renderLobby(lobby: View): void { + gameGrid.replaceChildren(); + const list: [string, string][] = lobby.games ?? []; + for (const [id, name] of list) { + const card = node("article", `game-card ${id}`); + card.append(node("h3", undefined, name || titles[id] || id)); + card.append(node("p", undefined, descriptions[id] ?? "Play a round.")); + + const row = node("div", "row wrap"); + const label = node("label", "field"); + label.style.flex = "1"; + label.append(node("span", "label", "Bet")); + const input = node("input", "input bet-input") as HTMLInputElement; + input.type = "number"; + input.min = "1"; + input.step = "10"; + input.value = String(bets.get(id) ?? 10); + input.addEventListener("change", () => { + const value = Math.max(1, Math.floor(Number(input.value) || 1)); + input.value = String(value); + bets.set(id, value); + }); + label.append(input); + + const play = node("button", "btn primary", id === "roulette" ? "Open table" : "Play"); + play.addEventListener("click", () => { + const bet = Math.max(1, Math.floor(Number(input.value) || 1)); + bets.set(id, bet); + send({ Play: { game: id, bet } }); + }); + row.append(label, play); + card.append(row); + gameGrid.append(card); + } + + if (list.length === 0) { + gameGrid.append(node("p", "muted", "The server did not advertise any games.")); + } +} + +// Auth tabs and form ------------------------------------------------------- +tabLogin.addEventListener("click", () => setAuthMode("login")); +tabRegister.addEventListener("click", () => setAuthMode("register")); + +authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const user = authUser.value.trim(); + const password = authPass.value; + if (!user || !password) { + authError.textContent = "Enter a username and a password."; + return; + } + authSubmit.disabled = true; + authSubmit.textContent = "Working…"; + authError.textContent = ""; + try { + const credential = await sha256Hex(password); + if (authMode === "login") { + send({ Login: { user, credential } }); + } else { + send({ Register: { user, credential } }); + } + } catch { + authError.textContent = "This browser cannot hash the password (needs a secure context)."; + authSubmit.disabled = false; + authSubmit.textContent = authMode === "login" ? "Log in" : "Create account"; + } +}); + +// Chrome and navigation ---------------------------------------------------- +byId("logout").addEventListener("click", () => send("Logout")); +byId("show-history").addEventListener("click", () => send({ History: { limit: 50 } })); +byId("refresh-lobby").addEventListener("click", () => send("Info")); +byId("history-back").addEventListener("click", () => send("ShowLobby")); +byId("leave-game").addEventListener("click", () => { + if (!view) return; + const live = + (view.screen === "blackjack" && view.status === "player") || + (view.screen === "crash" && view.status === "running"); + if (live && !window.confirm("Leaving now forfeits the active bet. Continue?")) return; + send("Leave"); +}); +byId("reload-replaced").addEventListener("click", () => location.reload()); +byId("reload-reconnect").addEventListener("click", () => location.reload()); + +window.addEventListener("resize", () => { + window.clearTimeout((window as any).__resizeTimer); + (window as any).__resizeTimer = window.setTimeout(() => { + if (view) render(); + }, 140); +}); + +// A periodic ping keeps the gateway session visibly alive and refreshes the +// server clock estimate without changing game state. +window.setInterval(() => { + if (currentUser) gateway.ping(); +}, 25_000); + +gateway.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 @@ +import type { Handlers } from "./types"; + +const MAX_ATTEMPTS = 6; + +/** + * Browser side of the gateway socket. + * + * The cookie is HttpOnly and handled by the browser; this class never reads or + * stores a credential. A transient close reconnects inside the gateway's grace + * window; a deliberate takeover stops for good. + */ +export class Gateway { + private socket: WebSocket | null = null; + private stopped = false; + private replaced = false; + private attempts = 0; + private timer: number | undefined; + + constructor( + private readonly url: string, + private readonly handlers: Handlers, + ) {} + + connect(): void { + if (this.stopped) return; + this.clearTimer(); + + let socket: WebSocket; + try { + socket = new WebSocket(this.url); + } catch { + const retrying = this.scheduleReconnect(); + if (!retrying) this.handlers.onClosed(false); + return; + } + this.socket = socket; + + socket.addEventListener("open", () => { + this.attempts = 0; + this.handlers.onOpen(); + }); + + socket.addEventListener("message", (event) => { + let message: any; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + switch (message.type) { + case "hello": + this.handlers.onHello(message); + break; + case "view": + this.handlers.onView(message.view); + break; + case "notice": + this.handlers.onNotice(String(message.text ?? "")); + break; + case "error": + this.handlers.onError(String(message.text ?? "")); + break; + case "replaced": + this.replaced = true; + this.handlers.onReplaced(); + break; + case "pong": + this.handlers.onPong(); + break; + } + }); + + socket.addEventListener("close", () => { + const wasThisSocket = this.socket === socket; + if (wasThisSocket) this.socket = null; + if (this.replaced || this.stopped) { + this.handlers.onClosed(false); + return; + } + const retrying = this.scheduleReconnect(); + this.handlers.onClosed(retrying); + }); + + socket.addEventListener("error", () => { + // The close event follows; reconnect is scheduled there. + }); + } + + send(command: unknown): void { + if (this.socket?.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ type: "command", command })); + } + + ping(): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: "ping" })); + } + } + + close(): void { + this.stopped = true; + this.clearTimer(); + this.socket?.close(); + } + + private scheduleReconnect(): boolean { + if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false; + this.attempts += 1; + const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000); + this.timer = window.setTimeout(() => this.connect(), delay); + return true; + } + + private clearTimer(): void { + if (this.timer !== undefined) { + window.clearTimeout(this.timer); + this.timer = undefined; + } + } +} 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); +} 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 @@ +import type { View } from "./types"; +import { clear, money, node } from "./dom"; + +const REELS = 5; +const ROWS = 3; +const SYMBOLS = "CLOGDS"; +const LINE_NAMES = ["top", "middle", "bottom", "V", "inverted V"]; + +let spinUntil = 0; +let animation: number | undefined; +let latest: View | null = null; +let latestBoard: HTMLElement | null = null; + +export function requestSlotsSpin(): void { + spinUntil = Date.now() + 900; +} + +function spinning(view: View): boolean { + return view.phase === "spinning" || Date.now() < spinUntil; +} + +export function renderSlots( + board: HTMLElement, + side: HTMLElement, + view: View, + send: (command: unknown) => void, +): void { + latest = view; + latestBoard = board; + if (animation !== undefined) { + window.clearInterval(animation); + animation = undefined; + } + + const active = spinning(view); + clear(board); + clear(side); + + const title = node("h2", undefined, "Slots"); + const note = node("p", "note", "Match three or more symbols from the left. Five paylines."); + board.append(title, note); + + const reels = node("div", "reels"); + const tiles: HTMLElement[] = []; + const flat = String(view.reels ?? ""); + for (let column = 0; column < REELS; column += 1) { + const reel = node("div", "reel"); + for (let row = 0; row < ROWS; row += 1) { + const index = column * ROWS + row; + const symbol = active + ? SYMBOLS[(index * 3 + Math.floor(Date.now() / 70)) % SYMBOLS.length] + : flat.length === REELS * ROWS + ? flat[index] + : flat.length === REELS + ? flat[column] + : SYMBOLS[(index * 5 + column) % SYMBOLS.length]; + const tile = node("div", "tile", symbol); + tile.classList.add(`sym-${symbol}`); + if (active) tile.classList.add("spinning"); + if (!active && (view.win_cells ?? []).includes(index)) tile.classList.add("win"); + tiles.push(tile); + reel.append(tile); + } + reels.append(reel); + } + board.append(reels); + + const lines = node("div", "win-lines"); + if (!active && view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + if (winnings > 0) { + const names = (view.win_lines ?? []) + .map((line: number) => `${line} (${LINE_NAMES[line - 1] ?? "line"})`) + .join(" · "); + lines.textContent = `${view.win_type === "mega" ? "MEGA WIN" : view.win_type === "big" ? "BIG WIN" : "WIN"} ${money(winnings)}${names ? ` · ${names}` : ""}`; + lines.classList.add("win-text"); + } else { + lines.textContent = "No win — spin again."; + } + } else if (active) { + lines.textContent = "Spinning…"; + } else { + lines.textContent = "Pick a bet and spin."; + } + board.append(lines); + + if (active) { + let tick = 0; + animation = window.setInterval(() => { + tick += 1; + for (let index = 0; index < tiles.length; index += 1) { + const symbol = SYMBOLS[(index * 3 + tick) % SYMBOLS.length]; + tiles[index].textContent = symbol; + tiles[index].className = `tile spinning sym-${symbol}`; + } + if (Date.now() >= spinUntil && latest && latestBoard) { + window.clearInterval(animation); + animation = undefined; + renderSlots(latestBoard, side, latest, send); + } + }, 70); + } + + // Sidebar + side.append(node("div", "label", "Slots")); + side.append(node("p", "note", "5 reels × 3 rows, 5 paylines.")); + + const betRow = node("div", "row"); + const minus = node("button", "btn small", "−10"); + const bet = node("strong", "mono", money(Number(view.bet ?? 0))); + const plus = node("button", "btn small", "+10"); + minus.disabled = active; + plus.disabled = active; + minus.addEventListener("click", () => send({ AdjustBet: -10 })); + plus.addEventListener("click", () => send({ AdjustBet: 10 })); + betRow.append(minus, bet, plus); + side.append(node("div", "label", "Bet"), betRow); + + if (view.error) { + side.append(node("p", "error-text", String(view.error))); + } + + const status = node("p", "note"); + if (active) status.textContent = "Spinning…"; + else if (view.phase === "result") { + const winnings = Number(view.winnings ?? 0); + status.textContent = winnings > 0 ? `Won ${money(winnings)}` : "No win this spin."; + if (winnings > 0) status.classList.add("win-text"); + } else status.textContent = "Ready when you are."; + side.append(status); + + const actions = node("div", "actions"); + const spin = node("button", "btn primary wide big", active ? "SPINNING…" : "SPIN"); + spin.disabled = active; + spin.addEventListener("click", () => { + requestSlotsSpin(); + send("Spin"); + }); + const leave = node("button", "btn ghost wide", "Leave machine"); + leave.disabled = active; + leave.addEventListener("click", () => send("Leave")); + actions.append(spin, leave); + side.append(actions); +} 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 @@ +export type View = { + screen: "lobby" | "history" | "slots" | "roulette" | "blackjack" | "crash"; + [key: string]: any; +}; + +export type Hello = { + type: "hello"; + version: string; + grace_seconds: number; + /** Signed-in user restored after a refresh, if any. */ + user?: string | null; + /** Estimated game-server Unix time in milliseconds. */ + time: number; +}; + +export type Send = (command: unknown) => void; + +export type Handlers = { + onHello: (hello: Hello) => void; + onView: (view: View) => void; + onNotice: (text: string) => void; + onError: (text: string) => void; + onReplaced: () => void; + onPong: () => void; + onOpen: () => void; + onClosed: (willRetry: boolean) => void; +}; -- cgit v1.2.3