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/crash.ts | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 web/play/src/crash.ts (limited to 'web/play/src/crash.ts') 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]; +} -- cgit v1.2.3