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%
6.5 KB · 176 lines typescript
Raw Blame History
1"use client";23/**4 * Shared contract for the five "Beyond Slots" arcade games. Each game module5 * exports a React component with the `ArcadeGameProps` signature and uses the6 * helpers below to talk to the API. Outcomes are always resolved server-side.7 */8import { useCallback, useEffect, useRef, useState } from "react";9import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client";10import type { GameInfo } from "@spinza/shared";11import { api, ApiClientError } from "@/lib/api";12import { toast, useSession } from "@/lib/store";1314export interface ArcadeGameProps {15  game: GameInfo;16  definition: ArcadeGameDefinition;17  /** Current bet chosen in the shared shell. */18  bet: number;19  /** Tell the shell whether the game is mid-round (locks bet controls). */20  onBusy: (busy: boolean) => void;21  /** Report the last result to the shell (win banner + counters). */22  onResult: (r: { win: number; multiplier: number }) => void;23  /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */24  sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void;25  reduceMotion: boolean;26}2728export interface Progression {29  xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number };30  unlocked: { achievements: string[]; missions: string[] };31}3233export interface InstantResponse<T extends ArcadeOutcome = ArcadeOutcome> {34  roundId: string;35  outcome: T;36  win: number;37  multiplier: number;38  balance: number;39  winClass: string;40  xp?: Progression["xp"];41  unlocked?: Progression["unlocked"];42  replayed?: boolean;43}4445export type LadderView = Omit<LadderState, "game" | "version"> & { roundId: string; game: string; version: string; canCashout: boolean };4647export interface LadderResponse {48  session: LadderView;49  balance: number | null;50  winClass?: string | null;51  progression: Progression | null;52  replayed?: boolean;53}5455function applyProgression(p: Progression | null | undefined) {56  if (!p) return;57  const s = useSession.getState();58  s.setUserXp(p.xp.total, p.xp.level);59  if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" });60  for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });61  for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" });62}6364export function describeArcadeError(e: unknown): string {65  if (e instanceof ApiClientError) return e.message;66  return "Connection lost. Try again.";67}6869/** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */70export function useInstantPlay<T extends ArcadeOutcome>(slug: string) {71  const [busy, setBusy] = useState(false);72  const [error, setError] = useState<string | null>(null);73  const setBalance = useSession((s) => s.setBalance);74  const play = useCallback(75    async (bet: number, input: Record<string, unknown>): Promise<InstantResponse<T> | null> => {76      if (busy) return null;77      const balance = useSession.getState().wallet?.balance ?? 0;78      if (balance < bet) {79        setError("Not enough Spinza Credits for this bet.");80        return null;81      }82      setBusy(true);83      setError(null);84      setBalance(balance - bet);85      try {86        const res = await api<InstantResponse<T>>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } });87        setBalance(res.balance);88        applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null);89        return res;90      } catch (e) {91        setBalance(balance);92        setError(describeArcadeError(e));93        return null;94      } finally {95        setBusy(false);96      }97    },98    [busy, slug, setBalance],99  );100  return { play, busy, error, clearError: () => setError(null) };101}102103/** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */104export function useLadder(slug: string) {105  const [session, setSession] = useState<LadderView | null>(null);106  const [busy, setBusy] = useState(false);107  const [error, setError] = useState<string | null>(null);108  const setBalance = useSession((s) => s.setBalance);109  const mounted = useRef(false);110111  useEffect(() => {112    if (mounted.current) return;113    mounted.current = true;114    api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`)115      .then((r) => {116        if (r.session) setSession(r.session);117      })118      .catch(() => {});119  }, [slug]);120121  const start = useCallback(122    async (bet: number): Promise<LadderResponse | null> => {123      if (busy) return null;124      const balance = useSession.getState().wallet?.balance ?? 0;125      if (balance < bet) {126        setError("Not enough Spinza Credits for this bet.");127        return null;128      }129      setBusy(true);130      setError(null);131      try {132        const res = await api<LadderResponse>(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } });133        if (res.balance !== null) setBalance(res.balance);134        else setBalance(balance - bet);135        setSession(res.session);136        applyProgression(res.progression);137        return res;138      } catch (e) {139        if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") {140          const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null);141          if (cur?.session) setSession(cur.session);142        }143        setError(describeArcadeError(e));144        return null;145      } finally {146        setBusy(false);147      }148    },149    [busy, slug, setBalance],150  );151152  const act = useCallback(153    async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise<LadderResponse | null> => {154      if (!session || busy) return null;155      setBusy(true);156      setError(null);157      try {158        const res = await api<LadderResponse>(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } });159        setSession(res.session);160        if (res.balance !== null) setBalance(res.balance);161        applyProgression(res.progression);162        return res;163      } catch (e) {164        setError(describeArcadeError(e));165        return null;166      } finally {167        setBusy(false);168      }169    },170    [session, busy, slug, setBalance],171  );172173  const reset = useCallback(() => setSession(null), []);174  return { session, start, act, reset, busy, error, clearError: () => setError(null) };175}176