"use client"; /** * The Vault — Spinza Original (ladder). A colossal five-layer vault door: * every layer reveals three digits one by one; after each opened layer the * player SECUREs the current multiplier or OPENs the next lock. Outcomes come * from the server; this component only animates the returned `log` tail. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Atom, Clock, Fingerprint, Gauge, Lock, LockOpen, RotateCcw, ShieldCheck, Siren } from "lucide-react"; import { formatMultiplier, formatSC } from "@spinza/shared"; import type { LadderEvent, LadderOffer } from "@spinza/game-core/client"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui"; import { useLadder, type ArcadeGameProps, type LadderView } from "./contract"; interface VaultConfig { layers: number[]; digitsPerLayer: number; layerNames: string[]; } /** Reveal animation in progress for one layer (drives the digit wheels). */ interface Reveal { layer: number; digits: number[]; shown: number; outcome: "ok" | "bust" | null; } type Phase = "idle" | "reveal" | "running" | "cashed" | "busted" | "completed"; type WheelState = { kind: "idle" } | { kind: "spin" } | { kind: "fail" } | { kind: "locked"; digit: number }; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const EASE = [0.16, 1, 0.3, 1] as const; const LAYER_SIZES = [100, 82, 65, 49, 34]; const LAYER_ICONS = [Lock, Clock, Fingerprint, Gauge, Atom]; const DANGER = "#ff5c7a"; const WHEEL_H = 56; /** Same arithmetic as the server: m₀ = rtp, m_k = floor₂(m_{k−1} / p_k). */ export function vaultLadder(rtp: number, layers: number[]): number[] { const out: number[] = []; let m = rtp; for (const p of layers) { m = m / p; // full precision like the server; displayed values are floored to 2 decimals out.push(Math.floor(m * 100) / 100); } return out; } function lastReveal(log: LadderEvent[]): LadderEvent | undefined { for (let i = log.length - 1; i >= 0; i--) { const k = log[i].kind; if (k === "layer" || k === "alarm") return log[i]; } return undefined; } function digitsOf(ev: LadderEvent | undefined): number[] { const d = ev?.data?.digits; return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : []; } export function VaultGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { const cfg = definition.config as unknown as VaultConfig; const palette = definition.presentation.palette; const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug); const [anim, setAnim] = useState(null); const [celebrate, setCelebrate] = useState(false); const ladder = useMemo(() => vaultLadder(definition.rtp, cfg.layers), [definition.rtp, cfg.layers]); const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]); const sec = (ms: number) => dur(ms) / 1000; /* ------------------------------------------------------------ derived */ const phase: Phase = anim ? "reveal" : !session ? "idle" : session.status; const layersOpen = anim ? anim.layer : session ? Number(session.extra.layersOpen ?? 0) : 0; const runBet = session?.bet ?? bet; // While a layer is being revealed, keep showing the multiplier secured before it. 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); const offer: LadderOffer | undefined = session?.offers[0]; const running = session?.status === "running"; const locked = busy || anim !== null; const revealedDigits: number[] = useMemo(() => { const d = session?.extra.digits; return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : []; }, [session]); const failedEvent = phase === "busted" && session ? lastReveal(session.log) : undefined; const failedDigits = digitsOf(failedEvent); const activeLayer = Math.min(layersOpen, cfg.layers.length - 1); const wheels: WheelState[] = Array.from({ length: cfg.digitsPerLayer }, (_, i) => { if (anim) { if (i < anim.shown) return { kind: "locked", digit: anim.digits[i] ?? 0 }; if (anim.outcome === "bust") return i === anim.digits.length ? { kind: "fail" } : { kind: "idle" }; return { kind: "spin" }; } if (failedEvent) { if (i < failedDigits.length) return { kind: "locked", digit: failedDigits[i] }; if (i === failedDigits.length) return { kind: "fail" }; } return { kind: "idle" }; }); // Resume: a running session locks the shell's bet controls. useEffect(() => { if (running) onBusy(true); }, [running, onBusy]); /* ------------------------------------------------------------ sequence */ const playTail = useCallback( async (s: LadderView) => { const ev = lastReveal(s.log); if (ev) { const digits = digitsOf(ev); setAnim({ layer: Math.max(0, ev.stage - 1), digits, shown: 0, outcome: null }); await wait(dur(320)); for (let i = 0; i < digits.length; i++) { await wait(dur(350)); sound("tick"); setAnim((a) => (a ? { ...a, shown: i + 1 } : a)); } if (ev.outcome === "bust") { await wait(dur(420)); sound("lose"); setAnim((a) => (a ? { ...a, outcome: "bust" } : a)); await wait(dur(1300)); } else { await wait(dur(260)); sound("bonus"); setAnim((a) => (a ? { ...a, outcome: "ok" } : a)); await wait(dur(760)); } setAnim(null); } if (s.status === "completed") { await wait(dur(600)); setCelebrate(true); sound("bigWin"); } if (s.status !== "running") { onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 }); onBusy(false); } }, [dur, sound, onResult, onBusy], ); const onStart = async () => { if (locked) return; sound("click"); setCelebrate(false); if (session && session.status !== "running") reset(); onBusy(true); const res = await start(bet); if (!res) { onBusy(false); return; } await playTail(res.session); }; const onOpen = async () => { if (!session || locked) return; sound("click"); const res = await act({ type: "continue" }); if (res) await playTail(res.session); }; const onSecure = async () => { if (!session || locked) return; sound("click"); const res = await act({ type: "cashout" }); if (!res) return; sound("win"); onResult({ win: res.session.win, multiplier: res.session.bet ? res.session.win / res.session.bet : 0 }); onBusy(false); }; const onNewRun = () => { sound("click"); setCelebrate(false); reset(); }; /* -------------------------------------------------------------- render */ const ended = phase === "cashed" || phase === "busted" || phase === "completed"; const strobe = phase === "busted" || anim?.outcome === "bust"; const ActiveIcon = LAYER_ICONS[activeLayer] ?? Lock; const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff"; return (
{/* Ambient light */}
{/* Alarm strobe */} {strobe ? ( ) : null}
{/* Stats */}
{phase === "busted" ? "Alarm" : phase === "cashed" ? "Secured" : phase === "completed" ? "Jackpot" : "Secured multiplier"}
{phase === "idle" ? "—" : formatMultiplier(current)}
{phase === "busted" ? "Lost" : ended ? "Win" : phase === "idle" ? "After layer 1" : "Potential win"}
{phase === "idle" ? formatSC(Math.round(bet * (ladder[0] ?? 1))) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}
Bet {formatSC(runBet)}
{/* Door */}
{LAYER_SIZES.map((size, i) => ( ))} {/* Core light (visible once the last disc opens) */}
{[...LAYER_SIZES].map((_, k) => LAYER_SIZES.length - 1 - k).map((i) => ( ))} {/* Jackpot burst */} {celebrate ? ( {[0, 1, 2].map((r) => ( ))} ) : null} {/* Center overlay */}
{phase === "idle" ? ( Bet {formatSC(bet)} ) : phase === "cashed" ? (
Secured
+{formatSC(session?.win ?? 0)}
) : phase === "completed" ? (
Core open
Fictional jackpot
+{formatSC(session?.win ?? 0)}
) : (
{cfg.layerNames[activeLayer]}
{wheels.map((w, i) => ( ))}
{phase === "busted" ? ( Sealed ) : anim ? ( {anim.outcome === "ok" ? "Lock yields" : anim.outcome === "bust" ? "Alarm" : "Decoding…"} ) : offer ? ( {Math.round(offer.survival * 100)}% · {formatMultiplier(offer.next)} ) : null}
)}
{/* Layer map */} {/* Controls */}
{phase === "running" || phase === "reveal" ? ( ) : ended ? ( ) : ( {definition.tagline} · First layer opens automatically. )}
{error ? (
{error}
) : null}
); } /* ------------------------------------------------------------------ parts */ function Frame({ size, open, primary, glow, sec }: { size: number; open: boolean; primary: string; glow: string; sec: (ms: number) => number }) { const inset = (100 - size) / 2; return ( ); } function 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 }) { const inset = (100 - size) / 2; const Icon = LAYER_ICONS[index] ?? Lock; const rim = sealed || outcome === "bust" ? DANGER : palette.primary; const bolts = 12 - index * 2; const glowShadow = outcome === "ok" ? `0 0 70px 10px ${palette.glow}aa` : active ? `0 0 44px -8px ${palette.glow}88` : "0 18px 50px -20px #000"; return ( {/* brushed metal sheen */}
{/* tick ring */}
{/* bolts */} {Array.from({ length: bolts }, (_, b) => { const a = (b / bolts) * Math.PI * 2 - Math.PI / 2; // Rounded so the SSR string matches the browser's normalised value (hydration). const bx = Math.round((50 + 45.5 * Math.cos(a) - 2.25) * 1000) / 1000; const by = Math.round((50 + 45.5 * Math.sin(a) - 2.25) * 1000) / 1000; return ; })} {/* engraved layer mark (12 o'clock) */}
{sealed ?
: null} ); } function DigitWheel({ w, primary, glow, reduceMotion }: { w: WheelState; primary: string; glow: string; reduceMotion: boolean }) { const column = Array.from({ length: 30 }, (_, i) => i % 10); const ring = w.kind === "fail" ? DANGER : w.kind === "locked" ? glow : `${primary}66`; return (
{w.kind === "idle" ?
•
: null} {w.kind === "fail" ? ( ✕ ) : null} {w.kind === "spin" ? ( {column.map((d, i) => (
{d}
))}
) : null} {w.kind === "locked" ? ( {column.map((d, i) => (
{d}
))}
) : null}
); } function 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"] }) { const n = cfg.layers.length; return (
    {cfg.layers.map((p, i) => { const open = i < layersOpen; const active = i === layersOpen && phase !== "idle"; const sealed = active && phase === "busted"; const Icon = LAYER_ICONS[i] ?? Lock; const code = open ? digits.slice(i * cfg.digitsPerLayer, (i + 1) * cfg.digitsPerLayer) : active && phase !== "busted" ? digits.slice(i * cfg.digitsPerLayer) : sealed ? failed : []; const colour = sealed ? DANGER : open ? palette.glow : active ? palette.primary : "rgba(255,255,255,0.28)"; return (
  1. {formatMultiplier(ladder[i])}
    {Math.round(p * 100)}%
    {code.join("")} {sealed ? "✕" : ""}
  2. ); })}
); }