TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { AnimatePresence, motion } from "framer-motion";7import { ArrowLeft, Heart, History, Info, Minus, Plus, ShieldCheck, Volume2, VolumeX } from "lucide-react";8import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";9import { floorAt, multiplierAt, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core/client";10import { api, ApiClientError } from "@/lib/api";11import { toast, useSession } from "@/lib/store";12import { cn } from "@/lib/utils";13import { Button, Credits, Sheet, Tabs } from "@/components/ui";14import { SpinzaMark } from "@/components/brand/logo";15import { getSound } from "@/components/game/sound";16import { SCENES, progressFor } from "./scenes";1718interface RoundView {19 roundId: string;20 game: string;21 version: string;22 bet: number;23 status: "running" | "cashed" | "crashed";24 startedAt: string;25 serverNow: number;26 elapsedMs: number;27 commitment: string;28 events: CrashEvent[];29 autoCashout: number | null;30 crashMultiplier: number | null;31 seed: string | null;32 cashoutMultiplier: number | null;33 win: number | null;34}3536interface Progression {37 xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number };38 unlocked: { achievements: string[]; missions: string[] };39}4041type Phase = "idle" | "starting" | "running" | "cashed" | "crashed";4243const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));4445export function CrashClient({ game, definition }: { game: GameInfo; definition: CrashGameDefinition }) {46 const router = useRouter();47 const { status, wallet, settings, setBalance, setUserXp } = useSession();48 const canvasRef = useRef<HTMLCanvasElement>(null);49 const soundRef = useRef(getSound());50 const [bet, setBet] = useState(100);51 const [autoOn, setAutoOn] = useState(false);52 const [autoTarget, setAutoTarget] = useState("2.00");53 const [phase, setPhase] = useState<Phase>("idle");54 const [round, setRound] = useState<RoundView | null>(null);55 const [display, setDisplay] = useState(1);56 const [history, setHistory] = useState<{ crash: number; at: string }[]>([]);57 const [lastResult, setLastResult] = useState<{ status: "cashed" | "crashed"; multiplier: number; win: number; crash: number } | null>(null);58 const [error, setError] = useState<string | null>(null);59 const [infoSheet, setInfoSheet] = useState(false);60 const [historySheet, setHistorySheet] = useState(false);61 const [fairSheet, setFairSheet] = useState(false);62 const [favorite, setFavorite] = useState(!!game.favorite);63 const [activeEvent, setActiveEvent] = useState<string | null>(null);64 const [milestone, setMilestone] = useState<string | null>(null);65 const offsetRef = useRef(0); // serverNow - clientNow66 const phaseRef = useRef<Phase>("idle");67 const phaseAtRef = useRef(0);68 const roundRef = useRef<RoundView | null>(null);69 const displayRef = useRef(1);70 const cashingRef = useRef(false);71 const shownMilestones = useRef(new Set<number>());72 const soundOn = settings?.soundEnabled ?? true;73 const palette = definition.presentation.palette;74 const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);75 const balance = wallet?.balance ?? 0;7677 const setPhaseBoth = useCallback((p: Phase) => {78 phaseRef.current = p;79 phaseAtRef.current = performance.now();80 setPhase(p);81 }, []);8283 useEffect(() => {84 if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);85 }, [status, router, game.slug]);8687 const adoptRound = useCallback((r: RoundView) => {88 offsetRef.current = r.serverNow - Date.now();89 roundRef.current = r;90 setRound(r);91 shownMilestones.current.clear();92 setLastResult(null);93 setPhaseBoth("running");94 }, [setPhaseBoth]);9596 const finish = useCallback((r: RoundView, bal: number | null, prog: Progression | null) => {97 if (phaseRef.current !== "running") return;98 roundRef.current = r;99 setRound(r);100 const cashed = r.status === "cashed";101 setPhaseBoth(cashed ? "cashed" : "crashed");102 const m = cashed ? (r.cashoutMultiplier ?? 0) : (r.crashMultiplier ?? 0);103 displayRef.current = m;104 setDisplay(m);105 setLastResult({ status: cashed ? "cashed" : "crashed", multiplier: m, win: r.win ?? 0, crash: r.crashMultiplier ?? 0 });106 if (bal !== null) setBalance(bal);107 if (cashed) {108 const cls = classifyWin(m);109 if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") soundRef.current.bigWin();110 else soundRef.current.win(2);111 } else soundRef.current.error();112 if (prog) {113 setUserXp(prog.xp.total, prog.xp.level);114 if (prog.xp.leveledUp) toast({ title: `Level ${prog.xp.level} reached`, description: `+${formatSC(prog.xp.levelReward)} level reward`, tone: "credit" });115 for (const a of prog.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });116 }117 setHistory((h) => [{ crash: r.crashMultiplier ?? 0, at: new Date().toISOString() }, ...h].slice(0, 24));118 cashingRef.current = false;119 }, [setBalance, setUserXp, setPhaseBoth]);120121122 // Load history + resume a running round.123 useEffect(() => {124 if (status !== "authenticated") return;125 api<{ history: { crash: number; at: string }[] }>(`/api/crash/${game.slug}/history`).then((r) => setHistory(r.history)).catch(() => {});126 api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});127 api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`)128 .then((r) => {129 if (r.round && r.round.status === "running") adoptRound(r.round);130 })131 .catch(() => {});132 // eslint-disable-next-line react-hooks/exhaustive-deps133 }, [status, game.slug]);134135 useEffect(() => {136 const s = soundRef.current;137 s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });138 s.setAmbience(definition.presentation.ambience);139 return () => s.destroy();140 }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]);141142143 /* ------------------------------------------------------- animation loop */144 useEffect(() => {145 const canvas = canvasRef.current;146 if (!canvas) return;147 const ctx = canvas.getContext("2d");148 if (!ctx) return;149 let raf = 0;150 let lastTick = 0;151 const start = performance.now();152 const loop = () => {153 const dpr = Math.min(2, window.devicePixelRatio || 1);154 const rect = canvas.getBoundingClientRect();155 if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) {156 canvas.width = Math.round(rect.width * dpr);157 canvas.height = Math.round(rect.height * dpr);158 }159 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);160 const r = roundRef.current;161 const ph = phaseRef.current;162 let m = displayRef.current;163 let t = (performance.now() - start) / 1000;164 let event: string | null = null;165 if (r && ph === "running") {166 const elapsed = Date.now() + offsetRef.current - new Date(r.startedAt).getTime();167 m = multiplierAt(definition, r.events, elapsed);168 t = elapsed / 1000;169 const ev = r.events.find((e) => elapsed >= e.startMs && elapsed < e.endMs);170 event = ev?.label ?? null;171 if (m !== displayRef.current) {172 displayRef.current = m;173 setDisplay(m);174 if (performance.now() - lastTick > 90) {175 lastTick = performance.now();176 soundRef.current.tick();177 }178 }179 for (const ms of definition.milestones) {180 if (m >= ms.multiplier && !shownMilestones.current.has(ms.multiplier)) {181 shownMilestones.current.add(ms.multiplier);182 setMilestone(ms.label);183 setTimeout(() => setMilestone(null), 1800);184 }185 }186 } else if (r && (ph === "cashed" || ph === "crashed")) {187 m = ph === "cashed" ? (r.cashoutMultiplier ?? displayRef.current) : (r.crashMultiplier ?? displayRef.current);188 t = r.status !== "running" ? (r.elapsedMs ?? 0) / 1000 : t;189 }190 setActiveEvent((prev) => (prev === event ? prev : event));191 const painter = SCENES[definition.presentation.scene];192 ctx.save();193 painter({194 ctx,195 w: rect.width,196 h: rect.height,197 p: progressFor(m),198 t,199 multiplier: m,200 phase: ph === "starting" || ph === "idle" ? "idle" : ph,201 phaseT: (performance.now() - phaseAtRef.current) / 1000,202 palette,203 event,204 floor: floorAt(definition, m),205 reduceMotion: settings?.reduceMotion ?? false,206 });207 ctx.restore();208 raf = requestAnimationFrame(loop);209 };210 raf = requestAnimationFrame(loop);211 return () => cancelAnimationFrame(raf);212 }, [definition, palette, settings?.reduceMotion]);213214 /* --------------------------------------------------------------- polling */215 const roundId = round?.roundId;216 useEffect(() => {217 if (phase !== "running" || !roundId) return;218 let alive = true;219 const poll = async () => {220 while (alive && phaseRef.current === "running") {221 try {222 const r = await api<{ round: RoundView; balance: number | null; progression: Progression | null }>(`/api/crash/${game.slug}/rounds/${roundId}`);223 offsetRef.current = r.round.serverNow - Date.now();224 if (r.round.status !== "running") {225 finish(r.round, r.balance, r.progression);226 break;227 }228 } catch {229 /* keep polling */230 }231 await wait(350);232 }233 };234 void poll();235 return () => {236 alive = false;237 };238 }, [phase, roundId, finish, game.slug]);239240 /* ---------------------------------------------------------------- actions */241 const start = useCallback(async () => {242 if (phaseRef.current === "running" || phaseRef.current === "starting") return;243 if (balance < bet) {244 setError("Not enough Spinza Credits for this bet.");245 soundRef.current.error();246 return;247 }248 setError(null);249 soundRef.current.unlock();250 soundRef.current.spinStart();251 setPhaseBoth("starting");252 setLastResult(null);253 displayRef.current = 1;254 setDisplay(1);255 const auto = autoOn ? Number(autoTarget) : null;256 try {257 const r = await api<{ round: RoundView; balance: number | null }>(`/api/crash/${game.slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID(), autoCashout: auto && auto >= 1.01 ? Math.round(auto * 100) / 100 : undefined } });258 if (r.balance !== null) setBalance(r.balance);259 adoptRound(r.round);260 } catch (e) {261 setPhaseBoth("idle");262 if (e instanceof ApiClientError) {263 if (e.status === 401) router.replace(`/login?next=/games/${game.slug}`);264 else if (e.code === "ROUND_IN_PROGRESS") api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`).then((c) => c.round && adoptRound(c.round)).catch(() => {});265 else setError(e.message);266 } else setError("Connection lost. Try again.");267 soundRef.current.error();268 }269 }, [balance, bet, autoOn, autoTarget, game.slug, router, setBalance, adoptRound, setPhaseBoth]);270271 const cashout = useCallback(async () => {272 const r = roundRef.current;273 if (!r || phaseRef.current !== "running" || cashingRef.current) return;274 cashingRef.current = true;275 soundRef.current.click();276 try {277 const res = await api<{ round: RoundView; balance: number; progression: Progression | null }>(`/api/crash/${game.slug}/cashout`, { json: { roundId: r.roundId, claimedMultiplier: displayRef.current } });278 finish(res.round, res.balance, res.progression);279 } catch {280 cashingRef.current = false;281 }282 }, [game.slug, finish]);283284 useEffect(() => {285 const onKey = (e: KeyboardEvent) => {286 if (e.code === "Space" && !e.repeat && !infoSheet && !historySheet && !fairSheet) {287 e.preventDefault();288 if (phaseRef.current === "running") void cashout();289 else void start();290 }291 };292 window.addEventListener("keydown", onKey);293 return () => window.removeEventListener("keydown", onKey);294 }, [start, cashout, infoSheet, historySheet, fairSheet]);295296 const toggleFavorite = async () => {297 setFavorite((f) => !f);298 try {299 const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });300 setFavorite(r.favorite);301 } catch {302 setFavorite((f) => !f);303 }304 };305306 if (status === "guest") return null;307 const running = phase === "running";308 const betIndex = bets.indexOf(bet);309 const unitValue = definition.curve.type === "steps" ? floorAt(definition, display) : Math.round((display - 1) * definition.presentation.unitScale);310 const potential = Math.round(bet * display);311 const cls = lastResult?.status === "cashed" ? classifyWin(lastResult.multiplier) : "none";312 const winLabel = WIN_CLASSES.find((c) => c.id === cls)?.label;313314 return (315 <div className="fixed inset-0 flex flex-col" style={{ background: palette.bg }}>316 {/* Top bar */}317 <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>318 <div className="flex items-center gap-2">319 <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">320 <ArrowLeft className="h-5 w-5" />321 </Link>322 <div className="leading-tight">323 <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>324 <div className="text-[11px] text-fg-3">Risk game · cash out anytime</div>325 </div>326 </div>327 <div className="flex items-center gap-1">328 <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">329 <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />330 </button>331 <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="History">332 <History className="h-5 w-5" />333 </button>334 <button onClick={() => setFairSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Provably fair">335 <ShieldCheck className="h-5 w-5" />336 </button>337 <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info">338 <Info className="h-5 w-5" />339 </button>340 <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound">341 {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}342 </button>343 </div>344 </div>345346 {/* History strip */}347 <div className="flex gap-1.5 overflow-x-auto px-3 pb-2 scrollbar-none">348 {history.length === 0 ? <span className="text-[11px] text-fg-4">No rounds yet — be the first.</span> : null}349 {history.map((h, i) => (350 <span key={i} className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}>351 {formatMultiplier(h.crash)}352 </span>353 ))}354 </div>355356 {/* Scene */}357 <div className="relative flex-1 min-h-0">358 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />359 {/* Multiplier */}360 <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-start pt-[9%]">361 <AnimatePresence mode="wait">362 {phase === "idle" || phase === "starting" ? (363 <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-center">364 {lastResult ? (365 <div className={cn("mb-3 rounded-full px-4 py-1.5 text-sm font-semibold", lastResult.status === "cashed" ? "bg-success/15 text-success" : "bg-danger/15 text-danger")}>366 {lastResult.status === "cashed" ? `Cashed out at ${formatMultiplier(lastResult.multiplier)} · +${formatSC(lastResult.win)}` : `Crashed at ${formatMultiplier(lastResult.crash)}`}367 </div>368 ) : null}369 <div className="text-[clamp(56px,14vw,120px)] font-extrabold leading-none tabular tracking-tight text-white/90" style={{ textShadow: `0 0 40px ${palette.glow}66` }}>370 {phase === "starting" ? "…" : "1.00×"}371 </div>372 <div className="mt-2 text-sm text-fg-2">{phase === "starting" ? "Starting round" : `Press ${definition.presentation.verb} before it ends`}</div>373 </motion.div>374 ) : (375 <motion.div key="live" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="text-center">376 <div377 className={cn("text-[clamp(64px,16vw,140px)] font-extrabold leading-none tabular tracking-tight", phase === "crashed" ? "text-danger" : phase === "cashed" ? "text-credit" : "text-white")}378 style={{ textShadow: phase === "running" ? `0 0 ${20 + progressFor(display) * 60}px ${palette.glow}` : undefined }}379 >380 {formatMultiplier(display)}381 </div>382 <div className="mt-2 text-sm text-fg-2 tabular">383 {definition.curve.type === "steps" ? `Floor ${unitValue}` : `${Math.abs(unitValue).toLocaleString("en-US")} ${definition.presentation.unit}`} · {running ? `${formatSC(potential)} on the line` : ""}384 </div>385 {phase === "cashed" && lastResult ? (386 <motion.div initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="mt-4">387 {winLabel ? <div className="text-2xl font-extrabold uppercase tracking-tight shimmer-text">{winLabel}</div> : null}388 <div className="text-xl font-bold text-credit tabular">+{formatSC(lastResult.win)}</div>389 <div className="text-[12px] text-fg-3">The round would have ended at {formatMultiplier(lastResult.crash)}</div>390 </motion.div>391 ) : null}392 {phase === "crashed" ? (393 <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} className="mt-4 text-2xl font-extrabold uppercase tracking-tight text-danger">394 {crashWord(definition.presentation.scene)}395 </motion.div>396 ) : null}397 </motion.div>398 )}399 </AnimatePresence>400 <AnimatePresence>401 {activeEvent && running ? (402 <motion.div key={activeEvent} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-4 rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: `${palette.secondary}33`, color: palette.secondary }}>403 {activeEvent}404 </motion.div>405 ) : null}406 {milestone ? (407 <motion.div key={milestone} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-6 rounded-full bg-black/50 px-3 py-1 text-[12px] font-semibold text-fg-2">408 {milestone}409 </motion.div>410 ) : null}411 </AnimatePresence>412 </div>413 <AnimatePresence>414 {error ? (415 <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">416 <div className="text-fg-2">{error}</div>417 <div className="mt-2 flex justify-center gap-2">418 <Button size="sm" variant="secondary" onClick={() => setError(null)}>419 Dismiss420 </Button>421 <Button size="sm" variant="accent" href="/rewards">422 Get rewards423 </Button>424 </div>425 </motion.div>426 ) : null}427 </AnimatePresence>428 </div>429430 {/* Controls */}431 <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}>432 <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">433 <div className="flex flex-col gap-2">434 <div>435 <div className="eyebrow">Balance</div>436 <Credits amount={balance} size="md" />437 </div>438 <div className="flex items-center gap-1">439 <button disabled={running || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">440 <Minus className="h-4 w-4" />441 </button>442 <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">443 <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>444 <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>445 </div>446 <button disabled={running || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">447 <Plus className="h-4 w-4" />448 </button>449 </div>450 </div>451452 <div className="flex flex-col items-center gap-2">453 {running ? (454 <button455 onClick={() => void cashout()}456 className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[13px] font-extrabold leading-tight tracking-wide text-[#1a1406] transition-transform active:scale-95 focus-ring"457 style={{ background: `linear-gradient(180deg, #f3e2ad, #c9a961 60%, #9a7b3a)`, boxShadow: `0 0 0 6px rgba(201,169,97,0.25), 0 0 60px -10px rgba(201,169,97,0.9)` }}458 aria-label={definition.presentation.verb}459 >460 <span className="px-2">{definition.presentation.verb}</span>461 </button>462 ) : (463 <button464 onClick={() => void start()}465 disabled={phase === "starting"}466 className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[15px] font-extrabold tracking-wide transition-transform active:scale-95 focus-ring disabled:opacity-70"467 style={{ background: `linear-gradient(180deg, ${palette.primary}, ${palette.secondary})`, boxShadow: `0 0 0 6px ${palette.primary}2e, 0 12px 50px -12px ${palette.primary}`, color: "#0b0a06" }}468 aria-label="Start round"469 >470 {phase === "cashed" || phase === "crashed" ? "AGAIN" : "START"}471 </button>472 )}473 <span className="text-[11px] font-semibold uppercase tracking-wider text-fg-3">{running ? `${formatSC(potential)}` : "space = start / cash out"}</span>474 </div>475476 <div className="flex flex-col items-end gap-2 text-right">477 <div>478 <div className="eyebrow">Auto cash-out</div>479 <div className="flex items-center gap-1.5">480 <button role="switch" aria-checked={autoOn} disabled={running} onClick={() => setAutoOn((v) => !v)} className={cn("relative h-6 w-10 rounded-full border transition-colors", autoOn ? "bg-accent border-accent" : "bg-surface-3 border-line-2")}>481 <span className={cn("absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform", autoOn ? "translate-x-[18px]" : "translate-x-0.5")} />482 </button>483 <input484 type="number"485 step="0.1"486 min="1.01"487 max={definition.maxMultiplier}488 value={autoTarget}489 disabled={running || !autoOn}490 onChange={(e) => setAutoTarget(e.target.value)}491 onBlur={() => setAutoTarget(String(Math.max(1.01, Math.min(definition.maxMultiplier, Number(autoTarget) || 2)).toFixed(2)))}492 className="h-9 w-[76px] rounded-md border border-line-2 bg-bg-1 px-2 text-right text-sm font-bold tabular text-fg disabled:opacity-50 focus-ring"493 aria-label="Auto cash-out multiplier"494 />495 <span className="text-sm text-fg-3">×</span>496 </div>497 </div>498 <div className="text-[11px] text-fg-4">Max {formatMultiplier(definition.maxMultiplier)}</div>499 </div>500 </div>501 </div>502503 <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />504 <FairSheet open={fairSheet} onClose={() => setFairSheet(false)} round={round} />505 <Sheet open={historySheet} onClose={() => setHistorySheet(false)} title="Recent rounds" side="right">506 {history.length === 0 ? <p className="text-sm text-fg-3">No rounds yet.</p> : (507 <ul className="grid grid-cols-4 gap-2">508 {history.map((h, i) => (509 <li key={i} className={cn("rounded-md px-2 py-2 text-center text-sm font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}>510 {formatMultiplier(h.crash)}511 </li>512 ))}513 </ul>514 )}515 </Sheet>516 <span className="sr-only">517 <SpinzaMark />518 </span>519 </div>520 );521}522523function crashWord(scene: string): string {524 return (525 {526 sky: "Vanished",527 ocean: "Hull breach",528 rocket: "Engine failure",529 bank: "Busted",530 volcano: "Eruption",531 blackhole: "Pulled in",532 freefall: "Too late",533 reactor: "Meltdown",534 storm: "Swallowed",535 elevator: "Cable snapped",536 }[scene] ?? "Crashed"537 );538}539540function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: CrashGameDefinition }) {541 const [tab, setTab] = useState<"rules" | "about">("rules");542 return (543 <Sheet open={open} onClose={onClose} title={game.name} side="right">544 <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />545 {tab === "rules" ? (546 <ul className="space-y-3 text-sm text-fg-2">547 {game.rules.map((r, i) => (548 <li key={i} className="flex gap-3">549 <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>550 <span>{r}</span>551 </li>552 ))}553 </ul>554 ) : (555 <div className="space-y-4 text-sm text-fg-2">556 <p>{game.description}</p>557 <dl className="grid grid-cols-2 gap-3">558 {[559 ["Volatility", game.volatility],560 ["RTP", `${(game.rtp * 100).toFixed(2)}% for any strategy`],561 ["Max multiplier", formatMultiplier(definition.maxMultiplier)],562 ["Instant end", `${((1 - game.rtp) * 100).toFixed(0)}% of rounds`],563 ["Reach 2× odds", `${((game.rtp / 2) * 100).toFixed(0)}%`],564 ["Reach 10× odds", `${((game.rtp / 10) * 100).toFixed(1)}%`],565 ].map(([k, v]) => (566 <div key={k} className="surface rounded-md p-3">567 <dt className="eyebrow">{k}</dt>568 <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>569 </div>570 ))}571 </dl>572 <p className="text-[12px] text-fg-3">P(end ≥ x) = {game.rtp} / x. Milestones, boosts and cooling windows change the pace, never the odds. Spinza Credits are fictional and have no cash value.</p>573 </div>574 )}575 </Sheet>576 );577}578579function FairSheet({ open, onClose, round }: { open: boolean; onClose: () => void; round: RoundView | null }) {580 return (581 <Sheet open={open} onClose={onClose} title="Provably fair" side="right">582 <div className="space-y-4 text-sm text-fg-2">583 <p>Before each round starts, the server draws the end multiplier and publishes a SHA-256 commitment of <code className="font-mono text-[12px]">seed:multiplier</code>. When the round ends, the seed is revealed so you can verify the commitment yourself.</p>584 {round ? (585 <div className="space-y-2 surface rounded-md p-3 text-[12px]">586 <div><span className="text-fg-3">Round</span> <span className="font-mono">{round.roundId}</span></div>587 <div><span className="text-fg-3">Commitment</span> <span className="break-all font-mono">{round.commitment}</span></div>588 <div><span className="text-fg-3">Seed</span> <span className="break-all font-mono">{round.seed ?? "revealed when the round ends"}</span></div>589 <div><span className="text-fg-3">End multiplier</span> <span className="font-mono">{round.crashMultiplier ? round.crashMultiplier.toFixed(2) : "hidden while running"}</span></div>590 </div>591 ) : (592 <p className="text-fg-3">Start a round to see its commitment.</p>593 )}594 <p className="text-[12px] text-fg-3">Verify: sha256("seed:multiplier") with the multiplier formatted to two decimals must equal the commitment.</p>595 </div>596 </Sheet>597 );598}599