"use client"; /** * Shared contract for the five "Beyond Slots" arcade games. Each game module * exports a React component with the `ArcadeGameProps` signature and uses the * helpers below to talk to the API. Outcomes are always resolved server-side. */ import { useCallback, useEffect, useRef, useState } from "react"; import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client"; import type { GameInfo } from "@spinza/shared"; import { api, ApiClientError } from "@/lib/api"; import { toast, useSession } from "@/lib/store"; export interface ArcadeGameProps { game: GameInfo; definition: ArcadeGameDefinition; /** Current bet chosen in the shared shell. */ bet: number; /** Tell the shell whether the game is mid-round (locks bet controls). */ onBusy: (busy: boolean) => void; /** Report the last result to the shell (win banner + counters). */ onResult: (r: { win: number; multiplier: number }) => void; /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */ sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void; reduceMotion: boolean; } export interface Progression { xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number }; unlocked: { achievements: string[]; missions: string[] }; } export interface InstantResponse { roundId: string; outcome: T; win: number; multiplier: number; balance: number; winClass: string; xp?: Progression["xp"]; unlocked?: Progression["unlocked"]; replayed?: boolean; } export type LadderView = Omit & { roundId: string; game: string; version: string; canCashout: boolean }; export interface LadderResponse { session: LadderView; balance: number | null; winClass?: string | null; progression: Progression | null; replayed?: boolean; } function applyProgression(p: Progression | null | undefined) { if (!p) return; const s = useSession.getState(); s.setUserXp(p.xp.total, p.xp.level); if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" }); for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" }); } export function describeArcadeError(e: unknown): string { if (e instanceof ApiClientError) return e.message; return "Connection lost. Try again."; } /** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */ export function useInstantPlay(slug: string) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const setBalance = useSession((s) => s.setBalance); const play = useCallback( async (bet: number, input: Record): Promise | null> => { if (busy) return null; const balance = useSession.getState().wallet?.balance ?? 0; if (balance < bet) { setError("Not enough Spinza Credits for this bet."); return null; } setBusy(true); setError(null); setBalance(balance - bet); try { const res = await api>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } }); setBalance(res.balance); applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null); return res; } catch (e) { setBalance(balance); setError(describeArcadeError(e)); return null; } finally { setBusy(false); } }, [busy, slug, setBalance], ); return { play, busy, error, clearError: () => setError(null) }; } /** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */ export function useLadder(slug: string) { const [session, setSession] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const setBalance = useSession((s) => s.setBalance); const mounted = useRef(false); useEffect(() => { if (mounted.current) return; mounted.current = true; api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`) .then((r) => { if (r.session) setSession(r.session); }) .catch(() => {}); }, [slug]); const start = useCallback( async (bet: number): Promise => { if (busy) return null; const balance = useSession.getState().wallet?.balance ?? 0; if (balance < bet) { setError("Not enough Spinza Credits for this bet."); return null; } setBusy(true); setError(null); try { const res = await api(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } }); if (res.balance !== null) setBalance(res.balance); else setBalance(balance - bet); setSession(res.session); applyProgression(res.progression); return res; } catch (e) { if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") { const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null); if (cur?.session) setSession(cur.session); } setError(describeArcadeError(e)); return null; } finally { setBusy(false); } }, [busy, slug, setBalance], ); const act = useCallback( async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise => { if (!session || busy) return null; setBusy(true); setError(null); try { const res = await api(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } }); setSession(res.session); if (res.balance !== null) setBalance(res.balance); applyProgression(res.progression); return res; } catch (e) { setError(describeArcadeError(e)); return null; } finally { setBusy(false); } }, [session, busy, slug, setBalance], ); const reset = useCallback(() => setSession(null), []); return { session, start, act, reset, busy, error, clearError: () => setError(null) }; }