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