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.5 KB · 486 lines tsx
Raw Blame History
1"use client";23/**4 * The Vault — Spinza Original (ladder). A colossal five-layer vault door:5 * every layer reveals three digits one by one; after each opened layer the6 * player SECUREs the current multiplier or OPENs the next lock. Outcomes come7 * from the server; this component only animates the returned `log` tail.8 */9import { useCallback, useEffect, useMemo, useState } from "react";10import { AnimatePresence, motion } from "framer-motion";11import { Atom, Clock, Fingerprint, Gauge, Lock, LockOpen, RotateCcw, ShieldCheck, Siren } from "lucide-react";12import { formatMultiplier, formatSC } from "@spinza/shared";13import type { LadderEvent, LadderOffer } from "@spinza/game-core/client";14import { cn } from "@/lib/utils";15import { Button } from "@/components/ui";16import { useLadder, type ArcadeGameProps, type LadderView } from "./contract";1718interface VaultConfig {19  layers: number[];20  digitsPerLayer: number;21  layerNames: string[];22}2324/** Reveal animation in progress for one layer (drives the digit wheels). */25interface Reveal {26  layer: number;27  digits: number[];28  shown: number;29  outcome: "ok" | "bust" | null;30}3132type Phase = "idle" | "reveal" | "running" | "cashed" | "busted" | "completed";33type WheelState = { kind: "idle" } | { kind: "spin" } | { kind: "fail" } | { kind: "locked"; digit: number };3435const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));36const EASE = [0.16, 1, 0.3, 1] as const;37const LAYER_SIZES = [100, 82, 65, 49, 34];38const LAYER_ICONS = [Lock, Clock, Fingerprint, Gauge, Atom];39const DANGER = "#ff5c7a";40const WHEEL_H = 56;4142/** Same arithmetic as the server: m₀ = rtp, m_k = floor₂(m_{k−1} / p_k). */43export function vaultLadder(rtp: number, layers: number[]): number[] {44  const out: number[] = [];45  let m = rtp;46  for (const p of layers) {47    m = m / p; // full precision like the server; displayed values are floored to 2 decimals48    out.push(Math.floor(m * 100) / 100);49  }50  return out;51}5253function lastReveal(log: LadderEvent[]): LadderEvent | undefined {54  for (let i = log.length - 1; i >= 0; i--) {55    const k = log[i].kind;56    if (k === "layer" || k === "alarm") return log[i];57  }58  return undefined;59}6061function digitsOf(ev: LadderEvent | undefined): number[] {62  const d = ev?.data?.digits;63  return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : [];64}6566export function VaultGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {67  const cfg = definition.config as unknown as VaultConfig;68  const palette = definition.presentation.palette;69  const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug);70  const [anim, setAnim] = useState<Reveal | null>(null);71  const [celebrate, setCelebrate] = useState(false);72  const ladder = useMemo(() => vaultLadder(definition.rtp, cfg.layers), [definition.rtp, cfg.layers]);73  const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]);74  const sec = (ms: number) => dur(ms) / 1000;7576  /* ------------------------------------------------------------ derived */77  const phase: Phase = anim ? "reveal" : !session ? "idle" : session.status;78  const layersOpen = anim ? anim.layer : session ? Number(session.extra.layersOpen ?? 0) : 0;79  const runBet = session?.bet ?? bet;80  // While a layer is being revealed, keep showing the multiplier secured before it.81  const current = anim ? (session?.log.find((e) => e.kind === "layer" && e.stage === anim.layer)?.multiplierAfter ?? (anim.layer > 0 ? ladder[anim.layer - 1] : definition.rtp)) : (session?.current ?? 0);82  const offer: LadderOffer | undefined = session?.offers[0];83  const running = session?.status === "running";84  const locked = busy || anim !== null;85  const revealedDigits: number[] = useMemo(() => {86    const d = session?.extra.digits;87    return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : [];88  }, [session]);89  const failedEvent = phase === "busted" && session ? lastReveal(session.log) : undefined;90  const failedDigits = digitsOf(failedEvent);91  const activeLayer = Math.min(layersOpen, cfg.layers.length - 1);9293  const wheels: WheelState[] = Array.from({ length: cfg.digitsPerLayer }, (_, i) => {94    if (anim) {95      if (i < anim.shown) return { kind: "locked", digit: anim.digits[i] ?? 0 };96      if (anim.outcome === "bust") return i === anim.digits.length ? { kind: "fail" } : { kind: "idle" };97      return { kind: "spin" };98    }99    if (failedEvent) {100      if (i < failedDigits.length) return { kind: "locked", digit: failedDigits[i] };101      if (i === failedDigits.length) return { kind: "fail" };102    }103    return { kind: "idle" };104  });105106  // Resume: a running session locks the shell's bet controls.107  useEffect(() => {108    if (running) onBusy(true);109  }, [running, onBusy]);110111  /* ------------------------------------------------------------ sequence */112  const playTail = useCallback(113    async (s: LadderView) => {114      const ev = lastReveal(s.log);115      if (ev) {116        const digits = digitsOf(ev);117        setAnim({ layer: Math.max(0, ev.stage - 1), digits, shown: 0, outcome: null });118        await wait(dur(320));119        for (let i = 0; i < digits.length; i++) {120          await wait(dur(350));121          sound("tick");122          setAnim((a) => (a ? { ...a, shown: i + 1 } : a));123        }124        if (ev.outcome === "bust") {125          await wait(dur(420));126          sound("lose");127          setAnim((a) => (a ? { ...a, outcome: "bust" } : a));128          await wait(dur(1300));129        } else {130          await wait(dur(260));131          sound("bonus");132          setAnim((a) => (a ? { ...a, outcome: "ok" } : a));133          await wait(dur(760));134        }135        setAnim(null);136      }137      if (s.status === "completed") {138        await wait(dur(600));139        setCelebrate(true);140        sound("bigWin");141      }142      if (s.status !== "running") {143        onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 });144        onBusy(false);145      }146    },147    [dur, sound, onResult, onBusy],148  );149150  const onStart = async () => {151    if (locked) return;152    sound("click");153    setCelebrate(false);154    if (session && session.status !== "running") reset();155    onBusy(true);156    const res = await start(bet);157    if (!res) {158      onBusy(false);159      return;160    }161    await playTail(res.session);162  };163164  const onOpen = async () => {165    if (!session || locked) return;166    sound("click");167    const res = await act({ type: "continue" });168    if (res) await playTail(res.session);169  };170171  const onSecure = async () => {172    if (!session || locked) return;173    sound("click");174    const res = await act({ type: "cashout" });175    if (!res) return;176    sound("win");177    onResult({ win: res.session.win, multiplier: res.session.bet ? res.session.win / res.session.bet : 0 });178    onBusy(false);179  };180181  const onNewRun = () => {182    sound("click");183    setCelebrate(false);184    reset();185  };186187  /* -------------------------------------------------------------- render */188  const ended = phase === "cashed" || phase === "busted" || phase === "completed";189  const strobe = phase === "busted" || anim?.outcome === "bust";190  const ActiveIcon = LAYER_ICONS[activeLayer] ?? Lock;191  const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff";192193  return (194    <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="vault">195      {/* Ambient light */}196      <div197        className="pointer-events-none absolute inset-0"198        style={{199          background: `radial-gradient(60% 45% at 50% 42%, ${palette.primary}${phase === "completed" ? "55" : "22"} 0%, transparent 70%), radial-gradient(80% 40% at 50% 100%, ${palette.surface} 0%, transparent 70%)`,200          transition: "background 900ms",201        }}202      />203      {/* Alarm strobe */}204      <AnimatePresence>205        {strobe ? (206          <motion.div key="strobe" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}55 0%, ${DANGER}22 60%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0, 0.8, 0, 0.5, 0, 0.3, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1800), ease: "linear" }} />207        ) : null}208      </AnimatePresence>209210      <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1">211        {/* Stats */}212        <div className="flex items-end justify-between gap-3">213          <div>214            <div className="eyebrow">{phase === "busted" ? "Alarm" : phase === "cashed" ? "Secured" : phase === "completed" ? "Jackpot" : "Secured multiplier"}</div>215            <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "reveal" ? `0 0 28px ${palette.glow}88` : undefined }}>216              {phase === "idle" ? "—" : formatMultiplier(current)}217            </motion.div>218          </div>219          <div className="text-right">220            <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : phase === "idle" ? "After layer 1" : "Potential win"}</div>221            <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(Math.round(bet * (ladder[0] ?? 1))) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div>222            <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div>223          </div>224        </div>225226        {/* Door */}227        <div className="relative grid min-h-0 flex-1 place-items-center py-2">228          <div className="relative aspect-square select-none" style={{ width: "min(86vw, 44dvh, 460px)" }}>229            <div className="pointer-events-none absolute -inset-[6%] rounded-full" style={{ background: `radial-gradient(circle, ${palette.glow}33 0%, transparent 62%)`, filter: "blur(10px)" }} />230            {LAYER_SIZES.map((size, i) => (231              <Frame key={`f${i}`} size={size} open={i < layersOpen} primary={palette.primary} glow={palette.glow} sec={sec} />232            ))}233            {/* Core light (visible once the last disc opens) */}234            <div className="absolute rounded-full" style={{ width: `${LAYER_SIZES[4]}%`, height: `${LAYER_SIZES[4]}%`, left: `${(100 - LAYER_SIZES[4]) / 2}%`, top: `${(100 - LAYER_SIZES[4]) / 2}%`, background: `radial-gradient(circle, #fff7dc 0%, ${palette.glow} 30%, ${palette.primary} 65%, transparent 100%)`, zIndex: 5, boxShadow: `0 0 80px 20px ${palette.glow}88` }} />235            {[...LAYER_SIZES].map((_, k) => LAYER_SIZES.length - 1 - k).map((i) => (236              <Disc key={`d${i}`} index={i} size={LAYER_SIZES[i]} open={i < layersOpen} active={i === layersOpen && phase !== "idle"} outcome={anim && anim.layer === i ? anim.outcome : null} sealed={phase === "busted" && i === layersOpen} palette={palette} sec={sec} />237            ))}238239            {/* Jackpot burst */}240            <AnimatePresence>241              {celebrate ? (242                <motion.div key="burst" className="pointer-events-none absolute inset-0 grid place-items-center" style={{ zIndex: 30 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>243                  {[0, 1, 2].map((r) => (244                    <motion.div key={r} className="absolute rounded-full" style={{ width: "34%", height: "34%", border: `2px solid ${palette.glow}` }} initial={{ scale: 0.6, opacity: 0.9 }} animate={{ scale: 3.2, opacity: 0 }} transition={{ duration: sec(1600), delay: r * sec(260), ease: "easeOut", repeat: Infinity, repeatDelay: sec(400) }} />245                  ))}246                  <motion.div className="absolute inset-0 rounded-full" style={{ background: `conic-gradient(from 0deg, transparent 0deg, ${palette.glow}44 8deg, transparent 16deg, transparent 60deg, ${palette.glow}33 68deg, transparent 76deg, transparent 120deg, ${palette.glow}44 128deg, transparent 136deg, transparent 180deg, ${palette.glow}33 188deg, transparent 196deg, transparent 240deg, ${palette.glow}44 248deg, transparent 256deg, transparent 300deg, ${palette.glow}33 308deg, transparent 316deg)` }} animate={{ rotate: 360 }} transition={{ duration: reduceMotion ? 40 : 14, ease: "linear", repeat: Infinity }} />247                </motion.div>248              ) : null}249            </AnimatePresence>250251            {/* Center overlay */}252            <div className="absolute inset-0 grid place-items-center" style={{ zIndex: 40 }}>253              <AnimatePresence mode="wait">254                {phase === "idle" ? (255                  <motion.div key="idle" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="flex flex-col items-center gap-2">256                    <button257                      onClick={() => void onStart()}258                      disabled={locked}259                      className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60"260                      style={{ background: `radial-gradient(circle at 50% 30%, #fff3cf 0%, ${palette.glow} 18%, ${palette.primary} 60%, #8a6d2f 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#1a1406" }}261                      aria-label="Start run"262                    >263                      START264                    </button>265                    <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span>266                  </motion.div>267                ) : phase === "cashed" ? (268                  <motion.div key="cashed" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center text-center">269                    <ShieldCheck className="h-9 w-9" style={{ color: palette.glow }} />270                    <div className="mt-1 text-[13px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}>271                      Secured272                    </div>273                    <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>274                  </motion.div>275                ) : phase === "completed" ? (276                  <motion.div key="jackpot" initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="flex flex-col items-center text-center">277                    <div className="text-[clamp(22px,6vw,34px)] font-extrabold uppercase tracking-tight shimmer-text">Core open</div>278                    <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">Fictional jackpot</div>279                    <div className="mt-1 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>280                  </motion.div>281                ) : (282                  <motion.div key="wheels" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center gap-2">283                    <div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-[0.2em]" style={{ color: phase === "busted" ? DANGER : palette.glow }}>284                      <ActiveIcon className="h-3.5 w-3.5" />285                      {cfg.layerNames[activeLayer]}286                    </div>287                    <div className="flex gap-2">288                      {wheels.map((w, i) => (289                        <DigitWheel key={i} w={w} primary={palette.primary} glow={palette.glow} reduceMotion={reduceMotion} />290                      ))}291                    </div>292                    <div className="h-5 text-[11px] font-semibold uppercase tracking-wider">293                      {phase === "busted" ? (294                        <motion.span initial={{ scale: 1.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="inline-flex items-center gap-1 rounded-sm border-2 px-2 py-0.5 text-[12px] font-extrabold tracking-[0.3em]" style={{ borderColor: DANGER, color: DANGER, transform: "rotate(-6deg)" }}>295                          <Siren className="h-3.5 w-3.5" /> Sealed296                        </motion.span>297                      ) : anim ? (298                        <span className="text-fg-2">{anim.outcome === "ok" ? "Lock yields" : anim.outcome === "bust" ? "Alarm" : "Decoding…"}</span>299                      ) : offer ? (300                        <span className="text-fg-3 tabular">301                          {Math.round(offer.survival * 100)}% · <span style={{ color: palette.glow }}>{formatMultiplier(offer.next)}</span>302                        </span>303                      ) : null}304                    </div>305                  </motion.div>306                )}307              </AnimatePresence>308            </div>309          </div>310        </div>311312        {/* Layer map */}313        <LayerMap cfg={cfg} ladder={ladder} layersOpen={layersOpen} phase={phase} digits={anim ? [...revealedDigits.slice(0, anim.layer * cfg.digitsPerLayer), ...anim.digits.slice(0, anim.shown)] : revealedDigits} failed={failedDigits} palette={palette} />314315        {/* Controls */}316        <div className="mt-2 min-h-[72px]">317          <AnimatePresence mode="wait">318            {phase === "running" || phase === "reveal" ? (319              <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-2 gap-2">320                <button onClick={() => void onSecure()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}>321                  {definition.presentation.secondaryVerb ?? "SECURE"}322                  <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span>323                </button>324                <button onClick={() => void onOpen()} disabled={locked || !offer} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${palette.primary}` }}>325                  {definition.presentation.verb}326                  <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#3a2d0c" }}>327                    {anim ? "Decoding…" : offer ? `${Math.round(offer.survival * 100)}% yields · ${formatMultiplier(offer.next)}` : ""}328                  </span>329                </button>330              </motion.div>331            ) : ended ? (332              <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2">333                <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}>334                  <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Play again"}335                  <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span>336                </button>337                <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}>338                  New run339                </Button>340              </motion.div>341            ) : (342              <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-2 text-center text-[12px] text-fg-3">343                <LockOpen className="h-3.5 w-3.5" /> {definition.tagline} · First layer opens automatically.344              </motion.div>345            )}346          </AnimatePresence>347        </div>348      </div>349350      <AnimatePresence>351        {error ? (352          <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm">353            <div className="text-fg-2">{error}</div>354            <div className="mt-2 flex justify-center gap-2">355              <Button size="sm" variant="secondary" onClick={clearError}>356                Dismiss357              </Button>358              <Button size="sm" variant="accent" href="/rewards">359                Get rewards360              </Button>361            </div>362          </motion.div>363        ) : null}364      </AnimatePresence>365    </div>366  );367}368369/* ------------------------------------------------------------------ parts */370371function Frame({ size, open, primary, glow, sec }: { size: number; open: boolean; primary: string; glow: string; sec: (ms: number) => number }) {372  const inset = (100 - size) / 2;373  return (374    <motion.div className="absolute rounded-full" style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, border: `2px solid ${primary}77`, boxShadow: `0 0 26px -6px ${glow}77, inset 0 0 22px -8px ${glow}55`, zIndex: 4 }} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={{ duration: sec(700), delay: open ? sec(300) : 0 }} />375  );376}377378function Disc({ index, size, open, active, outcome, sealed, palette, sec }: { index: number; size: number; open: boolean; active: boolean; outcome: "ok" | "bust" | null; sealed: boolean; palette: ArcadeGameProps["definition"]["presentation"]["palette"]; sec: (ms: number) => number }) {379  const inset = (100 - size) / 2;380  const Icon = LAYER_ICONS[index] ?? Lock;381  const rim = sealed || outcome === "bust" ? DANGER : palette.primary;382  const bolts = 12 - index * 2;383  const glowShadow = outcome === "ok" ? `0 0 70px 10px ${palette.glow}aa` : active ? `0 0 44px -8px ${palette.glow}88` : "0 18px 50px -20px #000";384  return (385    <motion.div386      className="absolute rounded-full"387      style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, zIndex: 10 + (LAYER_SIZES.length - index) }}388      initial={false}389      animate={open ? { opacity: 0, scale: 1.14, rotate: 32 } : { opacity: 1, scale: 1, rotate: 0 }}390      transition={{ duration: sec(950), ease: EASE }}391    >392      <motion.div393        className="absolute inset-0 rounded-full"394        style={{ background: `radial-gradient(circle at 35% 28%, #3b3441 0%, #221d27 40%, #110f14 100%)` }}395        animate={{ boxShadow: `inset 0 0 0 2px ${rim}88, inset 0 0 0 7px #0b0a0d, inset 0 0 0 8px ${rim}44, inset 0 -18px 40px -20px #000, ${glowShadow}` }}396        transition={{ duration: sec(500) }}397      />398      {/* brushed metal sheen */}399      <div className="absolute inset-0 rounded-full opacity-60" style={{ background: `conic-gradient(from 210deg, transparent 0deg, rgba(255,255,255,0.07) 40deg, transparent 90deg, rgba(255,255,255,0.05) 200deg, transparent 260deg)` }} />400      {/* tick ring */}401      <div className="absolute inset-[9%] rounded-full" style={{ border: `1.5px dashed ${rim}${active ? "aa" : "40"}` }} />402      {/* bolts */}403      {Array.from({ length: bolts }, (_, b) => {404        const a = (b / bolts) * Math.PI * 2 - Math.PI / 2;405        // Rounded so the SSR string matches the browser's normalised value (hydration).406        const bx = Math.round((50 + 45.5 * Math.cos(a) - 2.25) * 1000) / 1000;407        const by = Math.round((50 + 45.5 * Math.sin(a) - 2.25) * 1000) / 1000;408        return <span key={b} className="absolute h-[4.5%] w-[4.5%] rounded-full" style={{ left: `${bx}%`, top: `${by}%`, background: `radial-gradient(circle at 35% 35%, ${palette.glow}, ${palette.primary} 55%, #5b4a22)`, boxShadow: "0 1px 2px #000" }} />;409      })}410      {/* engraved layer mark (12 o'clock) */}411      <div className="absolute left-1/2 top-[16%] -translate-x-1/2 opacity-70" style={{ color: rim }}>412        <Icon style={{ width: `${Math.max(14, 30 - index * 3)}px`, height: `${Math.max(14, 30 - index * 3)}px` }} />413      </div>414      {sealed ? <div className="absolute inset-0 rounded-full" style={{ background: `radial-gradient(circle, ${DANGER}22 0%, ${DANGER}0f 70%, transparent 100%)` }} /> : null}415    </motion.div>416  );417}418419function DigitWheel({ w, primary, glow, reduceMotion }: { w: WheelState; primary: string; glow: string; reduceMotion: boolean }) {420  const column = Array.from({ length: 30 }, (_, i) => i % 10);421  const ring = w.kind === "fail" ? DANGER : w.kind === "locked" ? glow : `${primary}66`;422  return (423    <div className="relative overflow-hidden rounded-md" style={{ height: WHEEL_H, width: 42, background: "linear-gradient(180deg,#07060a 0%,#1b1720 50%,#07060a 100%)", boxShadow: `inset 0 0 0 1px ${ring}, inset 0 10px 12px -8px #000, inset 0 -10px 12px -8px #000` }}>424      {w.kind === "idle" ? <div className="grid h-full place-items-center text-lg text-fg-4">•</div> : null}425      {w.kind === "fail" ? (426        <motion.div initial={{ x: 0 }} animate={{ x: reduceMotion ? 0 : [0, -5, 5, -4, 4, 0] }} transition={{ duration: 0.5 }} className="grid h-full place-items-center text-2xl font-extrabold" style={{ color: DANGER, textShadow: `0 0 16px ${DANGER}` }}>427          ✕428        </motion.div>429      ) : null}430      {w.kind === "spin" ? (431        <motion.div key="spin" className="absolute inset-x-0 top-0" animate={{ y: [0, -10 * WHEEL_H] }} transition={{ duration: reduceMotion ? 1.2 : 0.42, ease: "linear", repeat: Infinity }} style={{ filter: reduceMotion ? undefined : "blur(0.6px)" }}>432          {column.map((d, i) => (433            <div key={i} className="grid place-items-center text-2xl font-extrabold tabular text-fg-3" style={{ height: WHEEL_H }}>434              {d}435            </div>436          ))}437        </motion.div>438      ) : null}439      {w.kind === "locked" ? (440        <motion.div key="locked" className="absolute inset-x-0 top-0" initial={{ y: -w.digit * WHEEL_H }} animate={{ y: -(10 + w.digit) * WHEEL_H }} transition={reduceMotion ? { duration: 0.01 } : { type: "spring", stiffness: 120, damping: 17, mass: 0.8 }}>441          {column.map((d, i) => (442            <div key={i} className="grid place-items-center text-2xl font-extrabold tabular" style={{ height: WHEEL_H, color: glow, textShadow: `0 0 14px ${glow}99` }}>443              {d}444            </div>445          ))}446        </motion.div>447      ) : null}448    </div>449  );450}451452function LayerMap({ cfg, ladder, layersOpen, phase, digits, failed, palette }: { cfg: VaultConfig; ladder: number[]; layersOpen: number; phase: Phase; digits: number[]; failed: number[]; palette: ArcadeGameProps["definition"]["presentation"]["palette"] }) {453  const n = cfg.layers.length;454  return (455    <div className="relative mt-1">456      <div className="absolute left-[10%] right-[10%] top-[15px] h-px" style={{ background: `linear-gradient(90deg, ${palette.primary}66, ${palette.primary}22)` }} />457      <div className="absolute left-[10%] top-[15px] h-px" style={{ width: `${(Math.max(0, Math.min(n - 1, layersOpen)) / (n - 1)) * 80}%`, background: palette.glow, boxShadow: `0 0 8px ${palette.glow}`, transition: "width 900ms cubic-bezier(0.16,1,0.3,1)" }} />458      <ol className="relative grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>459        {cfg.layers.map((p, i) => {460          const open = i < layersOpen;461          const active = i === layersOpen && phase !== "idle";462          const sealed = active && phase === "busted";463          const Icon = LAYER_ICONS[i] ?? Lock;464          const code = open ? digits.slice(i * cfg.digitsPerLayer, (i + 1) * cfg.digitsPerLayer) : active && phase !== "busted" ? digits.slice(i * cfg.digitsPerLayer) : sealed ? failed : [];465          const colour = sealed ? DANGER : open ? palette.glow : active ? palette.primary : "rgba(255,255,255,0.28)";466          return (467            <li key={i} className="flex flex-col items-center text-center">468              <motion.div className="grid h-[30px] w-[30px] place-items-center rounded-full" animate={{ scale: active && phase !== "busted" ? [1, 1.08, 1] : 1 }} transition={{ duration: 1.6, repeat: active && phase !== "busted" ? Infinity : 0 }} style={{ background: open ? `radial-gradient(circle at 40% 35%, ${palette.glow}, ${palette.primary})` : "#0f0d12", border: `1.5px solid ${colour}`, color: open ? "#1a1406" : colour, boxShadow: open || active ? `0 0 14px -2px ${colour}` : undefined }}>469                <Icon className="h-3.5 w-3.5" />470              </motion.div>471              <div className="mt-1 text-[11px] font-bold tabular" style={{ color: open ? palette.glow : sealed ? DANGER : "var(--color-fg-3)" }}>472                {formatMultiplier(ladder[i])}473              </div>474              <div className="text-[9px] uppercase tracking-wider text-fg-4">{Math.round(p * 100)}%</div>475              <div className="h-[14px] font-mono text-[11px] tabular" style={{ color: sealed ? DANGER : palette.glow, letterSpacing: "0.15em" }}>476                {code.join("")}477                {sealed ? "✕" : ""}478              </div>479            </li>480          );481        })}482      </ol>483    </div>484  );485}486