TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { FastifyInstance } from "fastify";2import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database";3import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core";4import { ARCADE_BY_SLUG } from "@spinza/games";5import { BET_LEVELS, classifyWin } from "@spinza/shared";6import { z } from "zod";7import { errors } from "../lib/errors";8import { newRoundId } from "../lib/crypto";9import { flag, maintenance } from "../lib/settings";10import { rateLimit, redis } from "../lib/redis";11import { requireUser } from "../plugins/auth";12import { applyCredit, lockWallet, saveWallet } from "../services/wallet";13import { settleRound } from "../services/settle";14import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";1516type SessionRow = typeof arcadeSessions.$inferSelect;1718function guard(slug: string): { def: ArcadeGameDefinition } {19 const def = ARCADE_BY_SLUG.get(slug);20 if (!def) throw errors.notFound("Unknown game");21 const m = maintenance();22 if (m.enabled) throw errors.maintenance(m.message);23 if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");24 return { def };25}2627function checkBet(def: ArcadeGameDefinition, bet: number) {28 if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");29}3031export async function arcadeRoutes(app: FastifyInstance) {32 /* ------------------------------------------------------------ instant */33 app.post("/api/arcade/:slug/play", async (req) => {34 const user = requireUser(req);35 const { slug } = req.params as { slug: string };36 const { def } = guard(slug);37 if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start.");38 const retry = await rateLimit(`arcade:${user.id}`, 180, 60);39 if (retry) throw errors.rateLimited(retry);40 const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body);41 checkBet(def, body.bet);42 const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });43 if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");4445 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}`);46 if (existing.rows.length) {47 const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };48 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 };49 }50 const started = Date.now();51 const roundId = newRoundId();52 const rng = new CryptoRng();53 try {54 const res = await db.transaction(async (tx) => {55 const wallet = await lockWallet(tx, user.id);56 if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);57 const outcome = resolveInstant(def, body.bet, body.input, rng);58 await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });59 if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier });60 const settled = await settleRound(tx, wallet, {61 roundId,62 gameId: game.id,63 gameSlug: slug,64 gameVersion: def.version,65 clientRoundId: body.clientRoundId,66 bet: body.bet,67 win: outcome.totalWin,68 multiplier: outcome.multiplier,69 result: { kind: "arcade", ...outcome } as unknown as Record<string, unknown>,70 features: outcome.features,71 freeSpins: false,72 bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"),73 jackpotTier: null,74 rngReference: rng.reference(),75 durationMs: Date.now() - started,76 });77 await saveWallet(tx, wallet);78 return { outcome, balance: wallet.balance, settled };79 });80 redis().zadd("live:players", Date.now(), user.id).catch(() => {});81 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 };82 } catch (e) {83 if (pgCode(e) === PG_UNIQUE_VIOLATION) {84 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}`);85 if (again.rows.length) {86 const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown };87 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 };88 }89 }90 throw e;91 }92 });9394 /* ------------------------------------------------------------- ladder */95 app.get("/api/arcade/:slug/current", async (req) => {96 const user = requireUser(req);97 const { slug } = req.params as { slug: string };98 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) });99 return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null };100 });101102 app.post("/api/arcade/:slug/start", async (req) => {103 const user = requireUser(req);104 const { slug } = req.params as { slug: string };105 const { def } = guard(slug);106 if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play.");107 const retry = await rateLimit(`arcade:${user.id}`, 180, 60);108 if (retry) throw errors.rateLimited(retry);109 const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body);110 checkBet(def, body.bet);111 const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });112 if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");113114 const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });115 if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true };116 const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) });117 if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`);118119 const roundId = newRoundId();120 const rng = new CryptoRng();121 try {122 const result = await db.transaction(async (tx) => {123 const wallet = await lockWallet(tx, user.id);124 if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);125 await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });126 const state = ladderStart(def, body.bet, rng);127 let row: SessionRow;128 [row] = await tx129 .insert(arcadeSessions)130 .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<string, unknown>, status: state.status })131 .returning();132 let settled: Awaited<ReturnType<typeof settleRound>> | null = null;133 if (state.status !== "running") {134 // Busted (or completed) on the mandatory first step.135 settled = await finalize(tx, wallet, row, state, def, rng.reference());136 [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();137 }138 await saveWallet(tx, wallet);139 return { row, state, balance: wallet.balance, settled };140 });141 redis().zadd("live:players", Date.now(), user.id).catch(() => {});142 return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null };143 } catch (e) {144 if (pgCode(e) === PG_UNIQUE_VIOLATION) {145 const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) });146 if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true };147 }148 throw e;149 }150 });151152 app.post("/api/arcade/:slug/act", async (req) => {153 const user = requireUser(req);154 const { slug } = req.params as { slug: string };155 const { def } = guard(slug);156 const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body);157 const rng = new CryptoRng();158 const res = await db.transaction(async (tx) => {159 const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`);160 if (!lock.rows.length) throw errors.notFound("Run not found");161 const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!;162 const state = row.state as unknown as LadderState;163 if (row.status !== "running") {164 return { row, state, balance: null as number | null, settled: null as Awaited<ReturnType<typeof settleRound>> | null };165 }166 let next: LadderState;167 try {168 next = ladderAdvance(def, state, rng, body.action as LadderAction);169 } catch (e) {170 throw errors.badRequest((e as Error).message);171 }172 let settled: Awaited<ReturnType<typeof settleRound>> | null = null;173 let balance: number | null = null;174 if (next.status !== "running") {175 const wallet = await lockWallet(tx, user.id);176 settled = await finalize(tx, wallet, row, next, def, rng.reference());177 await saveWallet(tx, wallet);178 balance = wallet.balance;179 }180 const [updated] = await tx181 .update(arcadeSessions)182 .set({ state: next as unknown as Record<string, unknown>, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null })183 .where(eq(arcadeSessions.id, row.id))184 .returning();185 return { row: updated, state: next, balance, settled };186 });187 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 };188 });189190 app.get("/api/arcade/:slug/history", async (req) => {191 const { slug } = req.params as { slug: string };192 if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound();193 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`);194 return { history: rows.rows };195 });196}197198/** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */199async function finalize(tx: Parameters<typeof settleRound>[0], wallet: Parameters<typeof settleRound>[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) {200 if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet });201 return settleRound(tx, wallet, {202 roundId: row.roundId,203 gameId: row.gameId,204 gameSlug: def.slug,205 gameVersion: def.version,206 clientRoundId: row.clientRoundId,207 bet: row.bet,208 win: state.win,209 multiplier: row.bet ? state.win / row.bet : 0,210 result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record<string, unknown>,211 features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))],212 freeSpins: false,213 bonus: state.status === "completed",214 jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null,215 rngReference,216 durationMs: Date.now() - row.startedAt.getTime(),217 });218}219