TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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