summaryrefslogtreecommitdiffstats
path: root/web/play/src/crash.ts
diff options
context:
space:
mode:
Diffstat (limited to 'web/play/src/crash.ts')
-rw-r--r--web/play/src/crash.ts365
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 @@
1import type { View } from "./types";
2import { clear, money, node, shortHash } from "./dom";
3
4const GROWTH = Math.LN2 / 5;
5
6type Clock = { now: () => number };
7
8type 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
27let parts: Parts | null = null;
28let current: View | null = null;
29let clock: Clock | null = null;
30let sendCommand: ((command: unknown) => void) | null = null;
31let frameHandle: number | undefined;
32let verifyToken = 0;
33let lastStatus = "";
34
35function 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
121export 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
226function 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
237function 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
253function 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
342async 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}