diff options
Diffstat (limited to 'web/play/src/crash.ts')
| -rw-r--r-- | web/play/src/crash.ts | 365 |
1 files changed, 365 insertions, 0 deletions
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 @@ | |||
| 1 | import type { View } from "./types"; | ||
| 2 | import { clear, money, node, shortHash } from "./dom"; | ||
| 3 | |||
| 4 | const GROWTH = Math.LN2 / 5; | ||
| 5 | |||
| 6 | type Clock = { now: () => number }; | ||
| 7 | |||
| 8 | type Parts = { | ||
| 9 | board: HTMLElement; | ||
| 10 | side: HTMLElement; | ||
| 11 | number: HTMLElement; | ||
| 12 | meta: HTMLElement; | ||
| 13 | verify: HTMLElement; | ||
| 14 | canvas: HTMLCanvasElement; | ||
| 15 | bet: HTMLElement; | ||
| 16 | potential: HTMLElement; | ||
| 17 | balance: HTMLElement; | ||
| 18 | status: HTMLElement; | ||
| 19 | lastCrash: HTMLElement; | ||
| 20 | error: HTMLElement; | ||
| 21 | primary: HTMLButtonElement; | ||
| 22 | leave: HTMLButtonElement; | ||
| 23 | minus: HTMLButtonElement; | ||
| 24 | plus: HTMLButtonElement; | ||
| 25 | }; | ||
| 26 | |||
| 27 | let parts: Parts | null = null; | ||
| 28 | let current: View | null = null; | ||
| 29 | let clock: Clock | null = null; | ||
| 30 | let sendCommand: ((command: unknown) => void) | null = null; | ||
| 31 | let frameHandle: number | undefined; | ||
| 32 | let verifyToken = 0; | ||
| 33 | let lastStatus = ""; | ||
| 34 | |||
| 35 | function build( | ||
| 36 | board: HTMLElement, | ||
| 37 | side: HTMLElement, | ||
| 38 | send: (command: unknown) => void, | ||
| 39 | now: Clock, | ||
| 40 | ): Parts { | ||
| 41 | clear(board); | ||
| 42 | clear(side); | ||
| 43 | sendCommand = send; | ||
| 44 | clock = now; | ||
| 45 | |||
| 46 | board.append(node("h2", undefined, "Crash")); | ||
| 47 | board.append( | ||
| 48 | node("p", "note", "The multiplier rises from 1.00×. Cash out before it crashes."), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const wrap = node("div", "crash-wrap"); | ||
| 52 | const number = node("div", "crash-number", "1.00×"); | ||
| 53 | const meta = node("div", "crash-meta"); | ||
| 54 | const canvas = node("canvas", "crash-canvas") as HTMLCanvasElement; | ||
| 55 | const verify = node("div", "verify", "Waiting for a round commitment."); | ||
| 56 | wrap.append(number, meta, canvas, verify); | ||
| 57 | board.append(wrap); | ||
| 58 | |||
| 59 | side.append(node("div", "label", "Crash")); | ||
| 60 | side.append(node("p", "note", "The server commits to a seed before the round. The seed is revealed when it ends.")); | ||
| 61 | |||
| 62 | const betRow = node("div", "row"); | ||
| 63 | const minus = node("button", "btn small", "−10") as HTMLButtonElement; | ||
| 64 | const bet = node("strong", "mono", "—"); | ||
| 65 | const plus = node("button", "btn small", "+10") as HTMLButtonElement; | ||
| 66 | minus.addEventListener("click", () => sendCommand?.({ AdjustBet: -10 })); | ||
| 67 | plus.addEventListener("click", () => sendCommand?.({ AdjustBet: 10 })); | ||
| 68 | betRow.append(minus, bet, plus); | ||
| 69 | side.append(node("div", "label", "Bet"), betRow); | ||
| 70 | |||
| 71 | const potential = node("strong", "mono", "—"); | ||
| 72 | const balance = node("strong", "mono", "—"); | ||
| 73 | for (const [label, value] of [ | ||
| 74 | ["Potential", potential], | ||
| 75 | ["Balance", balance], | ||
| 76 | ] as const) { | ||
| 77 | const row = node("div", "stat"); | ||
| 78 | row.append(node("span", "muted", label), value); | ||
| 79 | side.append(row); | ||
| 80 | } | ||
| 81 | |||
| 82 | const status = node("p", "note", "Ready."); | ||
| 83 | const lastCrash = node("p", "note", ""); | ||
| 84 | const error = node("p", "error-text"); | ||
| 85 | side.append(status, lastCrash, error); | ||
| 86 | |||
| 87 | const actions = node("div", "actions"); | ||
| 88 | const primary = node("button", "btn primary wide big", "START") as HTMLButtonElement; | ||
| 89 | const leave = node("button", "btn ghost wide", "Leave table") as HTMLButtonElement; | ||
| 90 | primary.addEventListener("click", () => { | ||
| 91 | if (current?.status === "running") sendCommand?.("CashOut"); | ||
| 92 | else sendCommand?.("Start"); | ||
| 93 | }); | ||
| 94 | leave.addEventListener("click", () => { | ||
| 95 | if (current?.status === "running" && !window.confirm("Forfeit this round? The bet is lost.")) return; | ||
| 96 | sendCommand?.("Leave"); | ||
| 97 | }); | ||
| 98 | actions.append(primary, leave); | ||
| 99 | side.append(actions); | ||
| 100 | |||
| 101 | return { | ||
| 102 | board, | ||
| 103 | side, | ||
| 104 | number, | ||
| 105 | meta, | ||
| 106 | verify, | ||
| 107 | canvas, | ||
| 108 | bet, | ||
| 109 | potential, | ||
| 110 | balance, | ||
| 111 | status, | ||
| 112 | lastCrash, | ||
| 113 | error, | ||
| 114 | primary, | ||
| 115 | leave, | ||
| 116 | minus, | ||
| 117 | plus, | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | |||
| 121 | export function renderCrash( | ||
| 122 | board: HTMLElement, | ||
| 123 | side: HTMLElement, | ||
| 124 | view: View, | ||
| 125 | send: (command: unknown) => void, | ||
| 126 | now: Clock, | ||
| 127 | ): void { | ||
| 128 | if (!parts || parts.board !== board) { | ||
| 129 | parts = build(board, side, send, now); | ||
| 130 | lastStatus = ""; | ||
| 131 | } | ||
| 132 | const p = parts; | ||
| 133 | sendCommand = send; | ||
| 134 | clock = now; | ||
| 135 | current = view; | ||
| 136 | |||
| 137 | if (view.status !== lastStatus) { | ||
| 138 | if (view.status === "starting" || view.status === "running") { | ||
| 139 | if (lastStatus !== "running") { | ||
| 140 | p.verify.className = "verify"; | ||
| 141 | p.verify.textContent = "Round committed; seed hidden until the round ends."; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | lastStatus = String(view.status); | ||
| 145 | } | ||
| 146 | |||
| 147 | const running = view.status === "running"; | ||
| 148 | const starting = view.status === "starting"; | ||
| 149 | const live = running || starting; | ||
| 150 | |||
| 151 | p.bet.textContent = money(Number(view.bet ?? 0)); | ||
| 152 | p.potential.textContent = money(Number(view.potential ?? view.bet ?? 0)); | ||
| 153 | p.balance.textContent = view.balance === null || view.balance === undefined ? "—" : money(Number(view.balance)); | ||
| 154 | p.error.textContent = view.error ? String(view.error) : ""; | ||
| 155 | |||
| 156 | p.minus.disabled = live; | ||
| 157 | p.plus.disabled = live; | ||
| 158 | p.primary.textContent = running ? "CASH OUT" : starting ? "STARTING…" : "START"; | ||
| 159 | p.primary.disabled = starting; | ||
| 160 | p.primary.className = running ? "btn primary wide big" : "btn primary wide big"; | ||
| 161 | p.leave.textContent = running ? "Forfeit round" : "Leave table"; | ||
| 162 | |||
| 163 | if (running) { | ||
| 164 | p.status.textContent = "Rising — cash out before the crash."; | ||
| 165 | p.status.className = "note win-text"; | ||
| 166 | } else if (starting) { | ||
| 167 | p.status.textContent = "Starting the round…"; | ||
| 168 | p.status.className = "note"; | ||
| 169 | } else if (view.status === "cashed") { | ||
| 170 | p.status.textContent = `Cashed out at ${Number(view.multiplier ?? 0).toFixed(2)}× for ${money(Number(view.payout ?? 0))}.`; | ||
| 171 | p.status.className = "note win-text"; | ||
| 172 | } else if (view.status === "crashed") { | ||
| 173 | p.status.textContent = `Crashed at ${Number(view.crash_point ?? view.multiplier ?? 0).toFixed(2)}×. The bet is lost.`; | ||
| 174 | p.status.className = "note error-text"; | ||
| 175 | } else if (view.status === "forfeit") { | ||
| 176 | p.status.textContent = "Round forfeited."; | ||
| 177 | p.status.className = "note error-text"; | ||
| 178 | } else { | ||
| 179 | p.status.textContent = "Ready. Start a round when you want."; | ||
| 180 | p.status.className = "note"; | ||
| 181 | } | ||
| 182 | |||
| 183 | if (view.crash_point !== null && view.crash_point !== undefined && view.status === "crashed") { | ||
| 184 | p.lastCrash.textContent = `Last crash: ${Number(view.crash_point).toFixed(2)}×`; | ||
| 185 | } | ||
| 186 | |||
| 187 | const commit = view.commit ? String(view.commit) : ""; | ||
| 188 | p.meta.textContent = commit ? `commit ${shortHash(commit, 12)}` : "no commitment yet"; | ||
| 189 | |||
| 190 | if (view.seed && commit) { | ||
| 191 | const token = ++verifyToken; | ||
| 192 | const seed = String(view.seed); | ||
| 193 | const crashPoint = Number(view.crash_point ?? 0); | ||
| 194 | void verifyReveal(seed, commit, crashPoint).then(([commitOk, pointOk]) => { | ||
| 195 | if (token !== verifyToken || !parts) return; | ||
| 196 | const ok = commitOk && pointOk; | ||
| 197 | parts.verify.className = `verify ${ok ? "ok" : "bad"}`; | ||
| 198 | parts.verify.textContent = ok | ||
| 199 | ? `Seed ${shortHash(seed, 12)} · sha256 matches commit · crash point recomputed` | ||
| 200 | : `VERIFICATION FAILED · seed ${shortHash(seed, 12)} · commit ${shortHash(commit, 12)}`; | ||
| 201 | }); | ||
| 202 | } else if (!live && !commit) { | ||
| 203 | p.verify.className = "verify"; | ||
| 204 | p.verify.textContent = "Waiting for a round commitment."; | ||
| 205 | } | ||
| 206 | |||
| 207 | if (frameHandle !== undefined) { | ||
| 208 | cancelAnimationFrame(frameHandle); | ||
| 209 | frameHandle = undefined; | ||
| 210 | } | ||
| 211 | if (running) { | ||
| 212 | const loop = () => { | ||
| 213 | if (!parts || current?.status !== "running") { | ||
| 214 | frameHandle = undefined; | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | update(parts, current, now); | ||
| 218 | frameHandle = requestAnimationFrame(loop); | ||
| 219 | }; | ||
| 220 | frameHandle = requestAnimationFrame(loop); | ||
| 221 | } else { | ||
| 222 | update(p, view, now); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | function currentMultiplier(view: View, now: Clock): number { | ||
| 227 | if (view.status === "running") { | ||
| 228 | const elapsed = Math.max(0, (now.now() - Number(view.started_at ?? 0)) / 1000); | ||
| 229 | const local = Math.min(1000, Math.exp(GROWTH * elapsed)); | ||
| 230 | return Math.max(local, Number(view.multiplier ?? 1)); | ||
| 231 | } | ||
| 232 | if (view.status === "cashed") return Number(view.multiplier ?? 1); | ||
| 233 | if (view.status === "crashed") return Number(view.crash_point ?? view.multiplier ?? 1); | ||
| 234 | return 1; | ||
| 235 | } | ||
| 236 | |||
| 237 | function update(p: Parts, view: View, now: Clock): void { | ||
| 238 | const multiplier = currentMultiplier(view, now); | ||
| 239 | p.number.textContent = `${multiplier.toFixed(2)}×`; | ||
| 240 | p.number.className = `crash-number ${ | ||
| 241 | view.status === "running" | ||
| 242 | ? "running" | ||
| 243 | : view.status === "cashed" | ||
| 244 | ? "cashed" | ||
| 245 | : view.status === "crashed" | ||
| 246 | ? "crashed" | ||
| 247 | : "" | ||
| 248 | }`; | ||
| 249 | p.potential.textContent = money(Math.floor(Number(view.bet ?? 0) * multiplier)); | ||
| 250 | drawChart(p.canvas, view, multiplier, now); | ||
| 251 | } | ||
| 252 | |||
| 253 | function drawChart(canvas: HTMLCanvasElement, view: View, multiplier: number, now: Clock): void { | ||
| 254 | const width = canvas.clientWidth || 600; | ||
| 255 | const height = canvas.clientHeight || 360; | ||
| 256 | const dpr = window.devicePixelRatio || 1; | ||
| 257 | if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { | ||
| 258 | canvas.width = Math.round(width * dpr); | ||
| 259 | canvas.height = Math.round(height * dpr); | ||
| 260 | } | ||
| 261 | const ctx = canvas.getContext("2d"); | ||
| 262 | if (!ctx) return; | ||
| 263 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | ||
| 264 | ctx.clearRect(0, 0, width, height); | ||
| 265 | |||
| 266 | const padL = 54; | ||
| 267 | const padR = 16; | ||
| 268 | const padT = 18; | ||
| 269 | const padB = 34; | ||
| 270 | const plotW = Math.max(1, width - padL - padR); | ||
| 271 | const plotH = Math.max(1, height - padT - padB); | ||
| 272 | |||
| 273 | const elapsed = | ||
| 274 | view.status === "running" | ||
| 275 | ? Math.max(0.25, (now.now() - Number(view.started_at ?? 0)) / 1000) | ||
| 276 | : multiplier > 1 | ||
| 277 | ? Math.log(multiplier) / GROWTH | ||
| 278 | : 1; | ||
| 279 | const xMax = Math.max(5, elapsed * 1.08); | ||
| 280 | const yMax = Math.max(2, Math.ceil(multiplier * 1.15 * 2) / 2); | ||
| 281 | const x = (seconds: number) => padL + (seconds / xMax) * plotW; | ||
| 282 | const y = (value: number) => padT + plotH - ((value - 1) / (yMax - 1)) * plotH; | ||
| 283 | |||
| 284 | ctx.strokeStyle = "#242e38"; | ||
| 285 | ctx.fillStyle = "#8a98a6"; | ||
| 286 | ctx.font = "11px ui-monospace, monospace"; | ||
| 287 | ctx.lineWidth = 1; | ||
| 288 | for (let step = 0; step <= 4; step += 1) { | ||
| 289 | const value = 1 + ((yMax - 1) * step) / 4; | ||
| 290 | const py = y(value); | ||
| 291 | ctx.beginPath(); | ||
| 292 | ctx.moveTo(padL, py); | ||
| 293 | ctx.lineTo(width - padR, py); | ||
| 294 | ctx.stroke(); | ||
| 295 | ctx.fillText(`${value.toFixed(1)}×`, 6, py + 4); | ||
| 296 | } | ||
| 297 | for (let step = 0; step <= 4; step += 1) { | ||
| 298 | const seconds = (xMax * step) / 4; | ||
| 299 | const px = x(seconds); | ||
| 300 | ctx.beginPath(); | ||
| 301 | ctx.moveTo(px, padT); | ||
| 302 | ctx.lineTo(px, padT + plotH); | ||
| 303 | ctx.strokeStyle = "#1b232c"; | ||
| 304 | ctx.stroke(); | ||
| 305 | ctx.fillStyle = "#5b6773"; | ||
| 306 | ctx.fillText(`${seconds.toFixed(0)}s`, px - 10, height - 12); | ||
| 307 | } | ||
| 308 | |||
| 309 | const line = | ||
| 310 | view.status === "crashed" | ||
| 311 | ? "#e05a5a" | ||
| 312 | : view.status === "cashed" | ||
| 313 | ? "#4ec27b" | ||
| 314 | : "#e0b64a"; | ||
| 315 | |||
| 316 | ctx.beginPath(); | ||
| 317 | const samples = 180; | ||
| 318 | for (let index = 0; index <= samples; index += 1) { | ||
| 319 | const seconds = (elapsed * index) / samples; | ||
| 320 | const value = Math.min(yMax, Math.exp(GROWTH * seconds)); | ||
| 321 | const px = x(seconds); | ||
| 322 | const py = y(value); | ||
| 323 | if (index === 0) ctx.moveTo(px, py); | ||
| 324 | else ctx.lineTo(px, py); | ||
| 325 | } | ||
| 326 | ctx.strokeStyle = line; | ||
| 327 | ctx.lineWidth = 2.4; | ||
| 328 | ctx.stroke(); | ||
| 329 | |||
| 330 | const endX = x(elapsed); | ||
| 331 | const endY = y(Math.min(yMax, multiplier)); | ||
| 332 | ctx.beginPath(); | ||
| 333 | ctx.arc(endX, endY, 4.5, 0, Math.PI * 2); | ||
| 334 | ctx.fillStyle = line; | ||
| 335 | ctx.fill(); | ||
| 336 | |||
| 337 | ctx.fillStyle = line; | ||
| 338 | ctx.font = "600 12px ui-monospace, monospace"; | ||
| 339 | ctx.fillText(`${multiplier.toFixed(2)}×`, Math.min(endX + 8, width - 70), Math.max(padT + 12, endY - 10)); | ||
| 340 | } | ||
| 341 | |||
| 342 | async function verifyReveal( | ||
| 343 | seed: string, | ||
| 344 | commit: string, | ||
| 345 | crashPoint: number, | ||
| 346 | ): Promise<[boolean, boolean]> { | ||
| 347 | const bytes = new Uint8Array(seed.length / 2); | ||
| 348 | for (let index = 0; index < bytes.length; index += 1) { | ||
| 349 | bytes[index] = Number.parseInt(seed.slice(index * 2, index * 2 + 2), 16); | ||
| 350 | } | ||
| 351 | const digest = await crypto.subtle.digest("SHA-256", bytes); | ||
| 352 | const actual = [...new Uint8Array(digest)] | ||
| 353 | .map((byte) => byte.toString(16).padStart(2, "0")) | ||
| 354 | .join(""); | ||
| 355 | const commitOk = actual === commit.toLowerCase(); | ||
| 356 | |||
| 357 | let uniform = 0; | ||
| 358 | for (let index = 0; index < 8; index += 1) { | ||
| 359 | uniform = uniform * 256 + bytes[index]; | ||
| 360 | } | ||
| 361 | uniform /= 2 ** 64; | ||
| 362 | const point = Math.max(1, Math.min(1000, (1 - 0.01) / (1 - uniform))); | ||
| 363 | const pointOk = Math.abs(point - crashPoint) < 1e-6; | ||
| 364 | return [commitOk, pointOk]; | ||
| 365 | } | ||
