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%
12.9 KB · 219 lines typescript
Raw Blame History
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