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%
31.2 KB · 539 lines tsx
Raw Blame History
1"use client";23/**4 * Escape 99 — Spinza Original (ladder). A fast roguelike tower: 99 floors,5 * room by room, cash out on any of them. The tower strip scrolls as the player6 * climbs; every CLIMB animates the room resolution returned by the server.7 */8import { useCallback, useEffect, useRef, useState } from "react";9import { AnimatePresence, motion } from "framer-motion";10import { ArrowUp, Crown, DoorOpen, Flag, Flame, Gem, Orbit, RotateCcw, Shield, ShieldCheck, Skull, Sparkles, Sword, TriangleAlert, Zap, type LucideIcon } from "lucide-react";11import { formatMultiplier, formatSC } from "@spinza/shared";12import type { LadderEvent, LadderOffer } from "@spinza/game-core/client";13import { cn } from "@/lib/utils";14import { Button } from "@/components/ui";15import { useLadder, type ArcadeGameProps, type LadderView } from "./contract";1617interface EscapeConfig {18  floors: number;19  bands: [number, number][];20  checkpoints: number[];21  rooms: { kind: string; label: string; weight: number; detail: string }[];22  forkChance: number;23  portalChance: number;24}2526/** Room resolution being animated from the returned log tail. */27interface Resolution {28  ev: LadderEvent;29  from: number;30  step: "resolve" | "result";31}3233type Phase = "idle" | "resolve" | "running" | "cashed" | "busted" | "completed";3435const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));36const ROW = 22;37const DANGER = "#ff5c7a";3839const ICONS: Record<string, LucideIcon> = { chest: Gem, enemy: Sword, trap: TriangleAlert, multiplier: Sparkles, room: DoorOpen, portal: Orbit, "fork-safe": Shield, "fork-risky": Flame, start: DoorOpen, summit: Crown };40const SUCCESS: Record<string, string> = { chest: "Chest opened", enemy: "Guardian defeated", trap: "Trap disarmed", multiplier: "Rune charged", room: "Stairs climbed", portal: "Warped three floors", "fork-safe": "Corridor cleared", "fork-risky": "Corridor cleared", start: "You are in" };4142function lastStep(log: LadderEvent[]): LadderEvent | undefined {43  for (let i = log.length - 1; i >= 0; i--) if (log[i].outcome === "ok" || log[i].outcome === "bust") return log[i];44  return undefined;45}4647function pickSafe(offers: LadderOffer[]): LadderOffer | undefined {48  return offers.find((o) => o.kind === "fork-safe") ?? offers[0];49}5051export function EscapeGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) {52  const cfg = definition.config as unknown as EscapeConfig;53  const palette = definition.presentation.palette;54  const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug);55  const [anim, setAnim] = useState<Resolution | null>(null);56  const [selected, setSelected] = useState<string | null>(null);57  const [flourish, setFlourish] = useState<number | null>(null);58  const [summit, setSummit] = useState(false);59  const [auto, setAuto] = useState(false);60  const autoRef = useRef(false);61  const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]);62  const sec = (ms: number) => dur(ms) / 1000;6364  /* ------------------------------------------------------------ derived */65  const phase: Phase = anim ? "resolve" : !session ? "idle" : session.status;66  const running = session?.status === "running";67  const locked = busy || anim !== null;68  const floor = anim ? (anim.step === "resolve" || anim.ev.outcome === "bust" ? anim.from : anim.ev.stage) : (session?.stage ?? 0);69  const runBet = session?.bet ?? bet;70  const current = session?.current ?? 0;71  const offers = session?.offers ?? [];72  const selectedId = offers.some((o) => o.id === selected) ? selected : offers[0]?.id;73  const checkpointsHit: number[] = Array.isArray(session?.extra.checkpointsHit) ? (session.extra.checkpointsHit as unknown[]).filter((x): x is number => typeof x === "number") : [];74  const bustEvent = phase === "busted" && session ? lastStep(session.log) : undefined;75  const ended = phase === "cashed" || phase === "busted" || phase === "completed";7677  useEffect(() => {78    if (running) onBusy(true);79  }, [running, onBusy]);8081  /* ------------------------------------------------------------ sequence */82  const stopAuto = useCallback(() => {83    autoRef.current = false;84    setAuto(false);85  }, []);8687  const finish = useCallback(88    (s: LadderView) => {89      stopAuto();90      onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 });91      onBusy(false);92    },93    [stopAuto, onResult, onBusy],94  );9596  /** Animate the last step of `s` (climbed from floor `from`); settle end states. Returns true when auto-climb should continue. */97  const animateStep = useCallback(98    async (s: LadderView, from: number): Promise<boolean> => {99      const ev = lastStep(s.log);100      if (ev) {101        setAnim({ ev, from, step: "resolve" });102        sound("tick");103        await wait(dur(ev.outcome === "bust" ? 520 : 420));104        const checkpoint = ev.data?.checkpoint === true;105        if (ev.outcome === "bust") {106          sound("lose");107          setAnim({ ev, from, step: "result" });108          await wait(dur(1100));109        } else {110          setAnim({ ev, from, step: "result" });111          if (checkpoint) {112            sound("bonus");113            setFlourish(ev.stage);114            setTimeout(() => setFlourish(null), dur(1500));115          } else sound("tick");116          await wait(dur(checkpoint ? 700 : 380));117        }118        setAnim(null);119        if (checkpoint && s.status === "running") stopAuto();120      }121      if (s.status === "completed") {122        await wait(dur(300));123        setSummit(true);124        sound("bigWin");125      }126      if (s.status !== "running") {127        finish(s);128        return false;129      }130      if (!autoRef.current) return false;131      await wait(dur(650));132      return autoRef.current;133    },134    [dur, sound, stopAuto, finish],135  );136137  /** Animate `first`, then keep climbing the safe path while auto-climb is on (stops at checkpoints / end states). */138  const runFrom = useCallback(139    async (first: LadderView, from: number) => {140      let s = first;141      let prev = from;142      for (;;) {143        const cont = await animateStep(s, prev);144        if (!cont) return;145        prev = s.stage;146        const res = await act({ type: "continue", offerId: pickSafe(s.offers)?.id });147        if (!res) {148          stopAuto();149          return;150        }151        s = res.session;152      }153    },154    [animateStep, act, stopAuto],155  );156157  const climb = async (offerId: string | undefined) => {158    if (!session) return;159    const res = await act({ type: "continue", offerId });160    if (!res) {161      stopAuto();162      return;163    }164    await runFrom(res.session, session.stage);165  };166167  const onStart = async () => {168    if (locked) return;169    sound("click");170    setSummit(false);171    if (session && session.status !== "running") reset();172    onBusy(true);173    const res = await start(bet);174    if (!res) {175      onBusy(false);176      return;177    }178    await runFrom(res.session, 0);179  };180181  const onClimb = async () => {182    if (!session || locked) return;183    sound("click");184    await climb(selectedId ?? undefined);185  };186187  const onCashout = async () => {188    if (!session || locked) return;189    sound("click");190    stopAuto();191    const res = await act({ type: "cashout" });192    if (!res) return;193    sound("win");194    finish(res.session);195  };196197  const onToggleAuto = () => {198    sound("click");199    const next = !auto;200    autoRef.current = next;201    setAuto(next);202    if (next && session && !locked) void climb(pickSafe(offers)?.id);203  };204205  const onNewRun = () => {206    sound("click");207    setSummit(false);208    reset();209  };210211  /* -------------------------------------------------------------- render */212  const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff";213  const isFork = offers.length > 1;214215  return (216    <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="escape">217      <div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(70% 50% at 50% 100%, ${palette.surface} 0%, transparent 70%), radial-gradient(60% 40% at 50% 0%, ${palette.primary}14 0%, transparent 70%)` }} />218      {/* Bust flash */}219      <AnimatePresence>220        {phase === "busted" || (anim && anim.ev.outcome === "bust" && anim.step === "result") ? (221          <motion.div key="flash" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}44 0%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0.2, 0.7, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1100) }} />222        ) : null}223      </AnimatePresence>224      {/* Summit: the tower opens to the sky */}225      <AnimatePresence>226        {summit ? (227          <motion.div key="sky" className="pointer-events-none absolute inset-0 z-[3]" initial={{ opacity: 0, y: "60%" }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: sec(1600), ease: [0.16, 1, 0.3, 1] }} style={{ background: `linear-gradient(180deg, #e9fff5 0%, ${palette.glow}aa 22%, ${palette.primary}44 55%, transparent 100%)`, mixBlendMode: "screen" }} />228        ) : null}229      </AnimatePresence>230231      <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1">232        {/* Stats */}233        <div className="flex items-end justify-between gap-3">234          <div>235            <div className="eyebrow">{phase === "busted" ? "Run over" : phase === "cashed" ? "Exited" : phase === "completed" ? "Summit" : "Multiplier"}</div>236            <div className="flex items-baseline gap-2">237              <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 === "resolve" ? `0 0 28px ${palette.glow}88` : undefined }}>238                {phase === "idle" ? "—" : formatMultiplier(current)}239              </motion.div>240            </div>241          </div>242          <div className="text-center">243            <div className="eyebrow">Floor</div>244            <div className="text-2xl font-extrabold leading-none tabular">245              <motion.span key={floor} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="inline-block" style={{ color: phase === "busted" ? DANGER : palette.glow }}>246                {Math.max(1, floor)}247              </motion.span>248              <span className="text-sm text-fg-3">/{cfg.floors}</span>249            </div>250          </div>251          <div className="text-right">252            <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : "Potential win"}</div>253            <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(bet) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div>254            <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div>255          </div>256        </div>257258        {/* Tower + room */}259        <div className="mt-2 grid min-h-0 flex-1 grid-cols-[64px_1fr] gap-3 sm:grid-cols-[88px_1fr]">260          <Tower cfg={cfg} floor={Math.max(1, floor)} busted={phase === "busted"} checkpointsHit={checkpointsHit} palette={palette} sec={sec} />261262          <div className="relative flex min-h-0 flex-col justify-center">263            <AnimatePresence mode="wait">264              {phase === "idle" ? (265                <motion.div key="idle" initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.94 }} className="flex flex-col items-center gap-3 text-center">266                  <button267                    onClick={() => void onStart()}268                    disabled={locked}269                    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"270                    style={{ background: `radial-gradient(circle at 50% 30%, #e9fff5 0%, ${palette.glow} 18%, ${palette.primary} 60%, #0f6b4a 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#04140d" }}271                    aria-label="Start run"272                  >273                    START274                  </button>275                  <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>276                  <p className="max-w-[260px] text-[12px] text-fg-3">{definition.tagline} You enter floor 1 automatically.</p>277                </motion.div>278              ) : anim ? (279                <ResolveCard key={`res-${anim.ev.stage}-${anim.ev.kind}`} res={anim} palette={palette} reduceMotion={reduceMotion} />280              ) : phase === "running" ? (281                <motion.div key={`offers-${session?.stage}`} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: sec(260) }} className="flex flex-col gap-2">282                  <div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-fg-3">283                    <span>{isFork ? "Fork — choose a corridor" : offers[0]?.kind === "portal" ? "Portal detected" : `Floor ${floor + 1}`}</span>284                    <span className="tabular">Next</span>285                  </div>286                  <div className={cn("grid gap-2", isFork ? "grid-cols-2" : "grid-cols-1")}>287                    {offers.map((o) => (288                      <OfferCard key={o.id} offer={o} bet={runBet} selected={selectedId === o.id} fork={isFork} onSelect={() => setSelected(o.id)} palette={palette} />289                    ))}290                  </div>291                </motion.div>292              ) : phase === "busted" && bustEvent ? (293                <motion.div key="bust" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center" style={{ borderColor: `${DANGER}66` }}>294                  <Skull className="mx-auto h-10 w-10" style={{ color: DANGER }} />295                  <div className="mt-2 text-xl font-extrabold uppercase tracking-tight" style={{ color: DANGER }}>296                    {bustEvent.label}297                  </div>298                  {bustEvent.detail ? <div className="text-[12px] text-fg-3">{bustEvent.detail}</div> : null}299                  <div className="mt-2 text-[12px] text-fg-2">300                    The run ended on floor {session?.stage ?? 0}. Bet lost: <span className="tabular text-danger">{formatSC(runBet)}</span>301                  </div>302                </motion.div>303              ) : phase === "cashed" ? (304                <motion.div key="cashed" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center">305                  <ShieldCheck className="mx-auto h-10 w-10" style={{ color: palette.glow }} />306                  <div className="mt-2 text-[12px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}>307                    Exited at floor {session?.stage}308                  </div>309                  <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>310                </motion.div>311              ) : phase === "completed" ? (312                <motion.div key="summit" initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 200, damping: 16 }} className="text-center">313                  <Crown className="mx-auto h-12 w-12" style={{ color: palette.glow, filter: `drop-shadow(0 0 18px ${palette.glow})` }} />314                  <div className="mt-2 text-[clamp(20px,6vw,30px)] font-extrabold uppercase tracking-tight shimmer-text">Floor 99</div>315                  <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">The tower opens to the sky</div>316                  <div className="mt-2 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div>317                </motion.div>318              ) : null}319            </AnimatePresence>320321            {/* Checkpoint flourish */}322            <AnimatePresence>323              {flourish !== null ? (324                <motion.div key={`cp-${flourish}`} className="pointer-events-none absolute inset-x-0 top-0 flex justify-center" initial={{ opacity: 0, y: 14, scale: 0.8 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -10 }} transition={{ type: "spring", stiffness: 260, damping: 18 }}>325                  <div className="flex items-center gap-2 rounded-full px-4 py-1.5 text-[12px] font-extrabold uppercase tracking-[0.2em]" style={{ background: `${palette.primary}22`, border: `1px solid ${palette.primary}88`, color: palette.glow, boxShadow: `0 0 30px -6px ${palette.glow}` }}>326                    <Flag className="h-4 w-4" /> Checkpoint {flourish}327                  </div>328                </motion.div>329              ) : null}330            </AnimatePresence>331          </div>332        </div>333334        {/* Controls */}335        <div className="mt-2 min-h-[72px]">336          <AnimatePresence mode="wait">337            {phase === "running" || phase === "resolve" ? (338              <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="flex flex-col gap-2">339                <div className="grid grid-cols-2 gap-2">340                  <button onClick={() => void onCashout()} 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` }}>341                    {definition.presentation.secondaryVerb ?? "CASH OUT"}342                    <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span>343                  </button>344                  <button onClick={() => void onClimb()} disabled={locked || offers.length === 0} 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%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${palette.primary}` }}>345                    <span className="inline-flex items-center gap-1">346                      <ArrowUp className="h-4 w-4" /> {definition.presentation.verb}347                    </span>348                    {(() => {349                      const o = offers.find((x) => x.id === selectedId);350                      return o ? (351                        <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#0a3d2a" }}>352                          {Math.round(o.survival * 100)}% safe · {formatMultiplier(o.next)}353                        </span>354                      ) : null;355                    })()}356                  </button>357                </div>358                <button role="switch" aria-checked={auto} onClick={onToggleAuto} className="flex h-9 items-center justify-center gap-2 rounded-md text-[12px] font-semibold text-fg-2 focus-ring">359                  <span className={cn("relative h-5 w-9 rounded-full border transition-colors", auto ? "border-transparent" : "bg-surface-3 border-line-2")} style={auto ? { background: palette.primary } : undefined}>360                    <span className={cn("absolute top-0.5 h-[14px] w-[14px] rounded-full bg-white transition-transform", auto ? "translate-x-[18px]" : "translate-x-0.5")} />361                  </span>362                  <Zap className="h-3.5 w-3.5" style={{ color: auto ? palette.glow : undefined }} />363                  Auto-climb to next checkpoint (safe path)364                </button>365              </motion.div>366            ) : ended ? (367              <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">368                <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%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}>369                  <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Climb again"}370                  <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span>371                </button>372                <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}>373                  New run374                </Button>375              </motion.div>376            ) : (377              <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-3 text-[11px] uppercase tracking-wider text-fg-3">378                {cfg.checkpoints.map((c) => (379                  <span key={c} className="inline-flex items-center gap-1">380                    <Flag className="h-3 w-3" style={{ color: palette.primary }} /> {c}381                  </span>382                ))}383              </motion.div>384            )}385          </AnimatePresence>386        </div>387      </div>388389      <AnimatePresence>390        {error ? (391          <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">392            <div className="text-fg-2">{error}</div>393            <div className="mt-2 flex justify-center gap-2">394              <Button size="sm" variant="secondary" onClick={clearError}>395                Dismiss396              </Button>397              <Button size="sm" variant="accent" href="/rewards">398                Get rewards399              </Button>400            </div>401          </motion.div>402        ) : null}403      </AnimatePresence>404    </div>405  );406}407408/* ------------------------------------------------------------------ parts */409410type Palette = ArcadeGameProps["definition"]["presentation"]["palette"];411412function Tower({ cfg, floor, busted, checkpointsHit, palette, sec }: { cfg: EscapeConfig; floor: number; busted: boolean; checkpointsHit: number[]; palette: Palette; sec: (ms: number) => number }) {413  const floors = Array.from({ length: cfg.floors }, (_, i) => cfg.floors - i); // 99 … 1414  const index = cfg.floors - floor;415  return (416    <div className="relative h-full min-h-[180px] overflow-hidden rounded-lg" style={{ background: `linear-gradient(180deg, ${palette.surface} 0%, #050807 100%)`, border: `1px solid ${palette.primary}33`, boxShadow: "inset 0 0 40px -10px #000" }} aria-label={`Tower, floor ${floor}`}>417      {/* rails */}418      <div className="pointer-events-none absolute inset-y-0 left-2 w-px" style={{ background: `${palette.primary}33` }} />419      <div className="pointer-events-none absolute inset-y-0 right-2 w-px" style={{ background: `${palette.primary}33` }} />420      <motion.ol className="absolute inset-x-0 top-1/2" initial={false} animate={{ y: -(index * ROW + ROW / 2) }} transition={{ type: "spring", stiffness: 140, damping: 22, duration: sec(500) }}>421        {floors.map((f) => {422          const cp = cfg.checkpoints.includes(f);423          const cur = f === floor;424          const done = f < floor;425          const hit = checkpointsHit.includes(f);426          return (427            <li key={f} className="relative flex items-center justify-center" style={{ height: ROW }}>428              <span className="absolute inset-x-3 top-1/2 h-px" style={{ background: cur ? (busted ? DANGER : palette.glow) : done ? `${palette.primary}55` : "rgba(255,255,255,0.07)" }} />429              <span className={cn("relative z-[1] rounded px-1.5 text-[11px] font-bold tabular leading-none", cur ? "text-black" : done ? "" : "text-fg-4")} style={cur ? { background: busted ? DANGER : palette.glow, boxShadow: `0 0 16px ${busted ? DANGER : palette.glow}` } : done ? { color: palette.primary, background: "#050807" } : { background: "#050807" }}>430                {f}431              </span>432              {cp ? <Flag className="absolute right-3 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" style={{ color: hit ? palette.glow : cur ? palette.glow : palette.secondary, filter: hit ? `drop-shadow(0 0 6px ${palette.glow})` : undefined }} /> : null}433            </li>434          );435        })}436      </motion.ol>437      <div className="pointer-events-none absolute inset-x-0 top-0 h-10" style={{ background: `linear-gradient(180deg, ${palette.surface}, transparent)` }} />438      <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10" style={{ background: "linear-gradient(0deg, #050807, transparent)" }} />439      {/* current-floor marker */}440      <span className="pointer-events-none absolute left-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />441      <span className="pointer-events-none absolute right-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} />442    </div>443  );444}445446function OfferCard({ offer, bet, selected, fork, onSelect, palette }: { offer: LadderOffer; bet: number; selected: boolean; fork: boolean; onSelect: () => void; palette: Palette }) {447  const Icon = ICONS[offer.kind] ?? DoorOpen;448  const risky = offer.kind === "fork-risky";449  const accent = risky ? palette.secondary : palette.primary;450  const [title, sub] = offer.label.includes(" — ") ? offer.label.split(" — ") : [offer.label, ""];451  return (452    <button453      type="button"454      onClick={onSelect}455      aria-pressed={selected}456      className={cn("tap relative flex w-full flex-col rounded-lg border p-3 text-left transition-all active:scale-[0.99] focus-ring", fork ? "min-h-[132px]" : "min-h-[96px]")}457      style={{ borderColor: selected ? accent : "rgba(255,255,255,0.1)", background: selected ? `linear-gradient(180deg, ${accent}22, ${accent}0a)` : "linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02))", boxShadow: selected ? `0 0 28px -8px ${accent}` : undefined }}458    >459      <div className="flex w-full items-start justify-between gap-2">460        <div className="flex min-w-0 items-center gap-2">461          <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md" style={{ background: `${accent}22`, color: accent }}>462            <Icon className="h-5 w-5" />463          </span>464          <div className="min-w-0">465            <div className="truncate text-[13px] font-bold text-fg">{sub || title}</div>466            {sub ? <div className="truncate text-[11px] text-fg-3">{title}</div> : null}467          </div>468        </div>469        <div className="shrink-0 text-right">470          <div className="text-lg font-extrabold leading-none tabular" style={{ color: accent }}>471            {formatMultiplier(offer.next)}472          </div>473          <div className="text-[10px] text-fg-3 tabular">{formatSC(Math.round(bet * offer.next))}</div>474        </div>475      </div>476      <div className="mt-auto w-full pt-2">477        <div className="flex items-center justify-between text-[11px]">478          <span className="text-fg-3">{offer.description}</span>479          <span className="font-bold tabular" style={{ color: accent }}>480            {Math.round(offer.survival * 100)}%481          </span>482        </div>483        <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-white/10">484          <div className="h-full rounded-full" style={{ width: `${Math.round(offer.survival * 100)}%`, background: accent }} />485        </div>486      </div>487    </button>488  );489}490491function ResolveCard({ res, palette, reduceMotion }: { res: Resolution; palette: Palette; reduceMotion: boolean }) {492  const { ev, step } = res;493  const bust = ev.outcome === "bust";494  const Icon = bust && step === "result" ? Skull : (ICONS[ev.kind] ?? DoorOpen);495  const accent = bust && step === "result" ? DANGER : ev.kind === "fork-risky" ? palette.secondary : palette.glow;496  const motionFor = (): { animate: Record<string, number | number[]>; transition: Record<string, number | string> } => {497    if (reduceMotion || step === "result") return { animate: { scale: bust ? 1.15 : 1, rotate: 0 }, transition: { duration: 0.2 } };498    switch (ev.kind) {499      case "chest":500        return { animate: { scale: [1, 1.35, 1.1], rotate: [0, -12, 8, 0], y: [0, -8, 0] }, transition: { duration: 0.42 } };501      case "enemy":502        return { animate: { rotate: [-50, 40, -10, 0], x: [-14, 14, 0], scale: [1, 1.2, 1] }, transition: { duration: 0.42 } };503      case "trap":504        return { animate: { x: [0, -6, 6, -5, 5, 0], scale: [1, 1.15, 1] }, transition: { duration: 0.42 } };505      case "multiplier":506        return { animate: { scale: [1, 1.5, 1.2], rotate: [0, 180, 360] }, transition: { duration: 0.42 } };507      case "portal":508        return { animate: { rotate: [0, 540], scale: [1, 1.6, 0.6, 1.1] }, transition: { duration: 0.5 } };509      case "fork-safe":510      case "fork-risky":511        return { animate: { x: ev.kind === "fork-safe" ? [30, -6, 0] : [-30, 6, 0], scale: [0.9, 1.15, 1] }, transition: { duration: 0.4 } };512      default:513        return { animate: { y: [0, -14, 0], scale: [1, 1.1, 1] }, transition: { duration: 0.4 } };514    }515  };516  const m = motionFor();517  const headline = step === "resolve" ? ev.detail && bust ? ev.detail : ev.label : bust ? ev.label : SUCCESS[ev.kind] ?? "Cleared";518  return (519    <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.18 }} className="surface relative overflow-hidden rounded-lg p-4 text-center" style={{ borderColor: `${accent}66` }}>520      <motion.div key={step} className="mx-auto grid h-16 w-16 place-items-center rounded-full" style={{ background: `${accent}22`, color: accent, boxShadow: `0 0 34px -8px ${accent}` }} animate={m.animate} transition={m.transition}>521        <Icon className="h-8 w-8" />522      </motion.div>523      {!bust && step === "result" && !reduceMotion ? (524        <>525          {[0, 1, 2, 3, 4, 5].map((i) => (526            <motion.span key={i} className="pointer-events-none absolute left-1/2 top-[38px] h-1.5 w-1.5 rounded-full" style={{ background: accent }} initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: Math.cos((i / 6) * Math.PI * 2) * 70, y: Math.sin((i / 6) * Math.PI * 2) * 70, opacity: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} />527          ))}528        </>529      ) : null}530      <motion.div key={`${step}-h`} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-3 text-[15px] font-extrabold uppercase tracking-tight" style={{ color: bust && step === "result" ? DANGER : "var(--color-fg)" }}>531        {headline}532      </motion.div>533      <div className="mt-0.5 h-4 text-[12px] text-fg-3">534        {step === "resolve" ? "Resolving…" : bust ? ev.detail ?? "" : ev.data?.checkpoint === true ? "Checkpoint reached" : `Floor ${ev.stage} · ${formatMultiplier(ev.multiplierAfter)}`}535      </div>536    </motion.div>537  );538}539