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 = ""; let lastAction = 0; 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 (Date.now() - lastAction < 500) return; lastAction = Date.now(); 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, }; } /// Stop the chart loop when another screen takes over. export function stopCrashAnimation(): void { if (frameHandle !== undefined) { cancelAnimationFrame(frameHandle); frameHandle = undefined; } current = null; } export function renderCrash( board: HTMLElement, side: HTMLElement, view: View, send: (command: unknown) => void, now: Clock, ): void { if (!parts || parts.board !== board || !parts.canvas.isConnected) { 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]; }