import type { FastifyInstance } from "fastify"; import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database"; import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core"; import { ARCADE_BY_SLUG } from "@spinza/games"; import { BET_LEVELS, classifyWin } from "@spinza/shared"; import { z } from "zod"; import { errors } from "../lib/errors"; import { newRoundId } from "../lib/crypto"; import { flag, maintenance } from "../lib/settings"; import { rateLimit, redis } from "../lib/redis"; import { requireUser } from "../plugins/auth"; import { applyCredit, lockWallet, saveWallet } from "../services/wallet"; import { settleRound } from "../services/settle"; import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; type SessionRow = typeof arcadeSessions.$inferSelect; function guard(slug: string): { def: ArcadeGameDefinition } { const def = ARCADE_BY_SLUG.get(slug); if (!def) throw errors.notFound("Unknown game"); const m = maintenance(); if (m.enabled) throw errors.maintenance(m.message); if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable."); return { def }; } function checkBet(def: ArcadeGameDefinition, bet: number) { if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game."); } export async function arcadeRoutes(app: FastifyInstance) { /* ------------------------------------------------------------ instant */ app.post("/api/arcade/:slug/play", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const { def } = guard(slug); if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start."); const retry = await rateLimit(`arcade:${user.id}`, 180, 60); if (retry) throw errors.rateLimited(retry); const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body); checkBet(def, body.bet); const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); const existing = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); if (existing.rows.length) { const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; } const started = Date.now(); const roundId = newRoundId(); const rng = new CryptoRng(); try { const res = await db.transaction(async (tx) => { const wallet = await lockWallet(tx, user.id); if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); const outcome = resolveInstant(def, body.bet, body.input, rng); await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier }); const settled = await settleRound(tx, wallet, { roundId, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, win: outcome.totalWin, multiplier: outcome.multiplier, result: { kind: "arcade", ...outcome } as unknown as Record, features: outcome.features, freeSpins: false, bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"), jackpotTier: null, rngReference: rng.reference(), durationMs: Date.now() - started, }); await saveWallet(tx, wallet); return { outcome, balance: wallet.balance, settled }; }); redis().zadd("live:players", Date.now(), user.id).catch(() => {}); return { roundId, outcome: res.outcome, win: res.outcome.totalWin, multiplier: res.outcome.multiplier, balance: res.balance, winClass: res.settled.winClass, xp: res.settled.xp, unlocked: res.settled.unlocked }; } catch (e) { if (pgCode(e) === PG_UNIQUE_VIOLATION) { const again = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); if (again.rows.length) { const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; } } throw e; } }); /* ------------------------------------------------------------- ladder */ app.get("/api/arcade/:slug/current", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const row = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.gameSlug, slug), eq(arcadeSessions.status, "running")), orderBy: desc(arcadeSessions.startedAt) }); return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null }; }); app.post("/api/arcade/:slug/start", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const { def } = guard(slug); if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play."); const retry = await rateLimit(`arcade:${user.id}`, 180, 60); if (retry) throw errors.rateLimited(retry); const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body); checkBet(def, body.bet); const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true }; const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) }); if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`); const roundId = newRoundId(); const rng = new CryptoRng(); try { const result = await db.transaction(async (tx) => { const wallet = await lockWallet(tx, user.id); if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); const state = ladderStart(def, body.bet, rng); let row: SessionRow; [row] = await tx .insert(arcadeSessions) .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, state: state as unknown as Record, status: state.status }) .returning(); let settled: Awaited> | null = null; if (state.status !== "running") { // Busted (or completed) on the mandatory first step. settled = await finalize(tx, wallet, row, state, def, rng.reference()); [row] = await tx.update(arcadeSessions).set({ status: state.status, win: state.win, settledAt: new Date(), updatedAt: new Date() }).where(eq(arcadeSessions.id, row.id)).returning(); } await saveWallet(tx, wallet); return { row, state, balance: wallet.balance, settled }; }); redis().zadd("live:players", Date.now(), user.id).catch(() => {}); return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null }; } catch (e) { if (pgCode(e) === PG_UNIQUE_VIOLATION) { const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true }; } throw e; } }); app.post("/api/arcade/:slug/act", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const { def } = guard(slug); const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body); const rng = new CryptoRng(); const res = await db.transaction(async (tx) => { const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`); if (!lock.rows.length) throw errors.notFound("Run not found"); const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!; const state = row.state as unknown as LadderState; if (row.status !== "running") { return { row, state, balance: null as number | null, settled: null as Awaited> | null }; } let next: LadderState; try { next = ladderAdvance(def, state, rng, body.action as LadderAction); } catch (e) { throw errors.badRequest((e as Error).message); } let settled: Awaited> | null = null; let balance: number | null = null; if (next.status !== "running") { const wallet = await lockWallet(tx, user.id); settled = await finalize(tx, wallet, row, next, def, rng.reference()); await saveWallet(tx, wallet); balance = wallet.balance; } const [updated] = await tx .update(arcadeSessions) .set({ state: next as unknown as Record, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null }) .where(eq(arcadeSessions.id, row.id)) .returning(); return { row: updated, state: next, balance, settled }; }); return { session: { roundId: res.row.roundId, ...ladderView(res.state) }, balance: res.balance, winClass: res.state.status === "running" ? null : classifyWin(res.state.win / res.state.bet), progression: res.settled ? { xp: res.settled.xp, unlocked: res.settled.unlocked } : null }; }); app.get("/api/arcade/:slug/history", async (req) => { const { slug } = req.params as { slug: string }; if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound(); const rows = await db.execute(sql`select multiplier, win, bet, created_at, result->'summary' as summary, result->>'status' as status, (result->>'stage')::int as stage from game_rounds where game_slug = ${slug} order by created_at desc limit 20`); return { history: rows.rows }; }); } /** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */ async function finalize(tx: Parameters[0], wallet: Parameters[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) { if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet }); return settleRound(tx, wallet, { roundId: row.roundId, gameId: row.gameId, gameSlug: def.slug, gameVersion: def.version, clientRoundId: row.clientRoundId, bet: row.bet, win: state.win, multiplier: row.bet ? state.win / row.bet : 0, result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record, features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))], freeSpins: false, bonus: state.status === "completed", jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null, rngReference, durationMs: Date.now() - row.startedAt.getTime(), }); }