SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
29.8 KB · 599 lines tsx
Raw Blame History
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(&quot;seed:multiplier&quot;) with the multiplier formatted to two decimals must equal the commitment.</p>595      </div>596    </Sheet>597  );598}599