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.1 KB · 236 lines typescript
Raw Blame History
1import type { FastifyInstance } from "fastify";2import { and, crashRounds, db, desc, eq, gameRounds, games, sql } from "@spinza/database";3import { multiplierAt, setupCrashRound, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core";4import { CRASH_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 CrashRow = typeof crashRounds.$inferSelect;1718/** Public view of a round (never leaks seed/crash while running). */19function view(def: CrashGameDefinition, r: CrashRow, serverNow: number) {20  const running = r.status === "running";21  return {22    roundId: r.roundId,23    game: r.gameSlug,24    version: r.gameVersion,25    bet: r.bet,26    status: r.status,27    startedAt: r.startedAt.toISOString(),28    serverNow,29    elapsedMs: serverNow - r.startedAt.getTime(),30    commitment: r.commitment,31    events: r.events as CrashEvent[],32    autoCashout: r.autoCashout ? Number(r.autoCashout) : null,33    curve: def.curve,34    maxMultiplier: def.maxMultiplier,35    crashMultiplier: running ? null : Number(r.crashMultiplier),36    seed: running ? null : r.seed,37    cashoutMultiplier: r.cashoutMultiplier ? Number(r.cashoutMultiplier) : null,38    win: running ? null : r.win,39  };40}4142/** Settle a running round that has crashed or reached its auto cash-out. Returns the updated row. */43async function autoSettle(def: CrashGameDefinition, row: CrashRow, now: number): Promise<CrashRow> {44  if (row.status !== "running") return row;45  const elapsed = now - row.startedAt.getTime();46  const crash = Number(row.crashMultiplier);47  const auto = row.autoCashout ? Number(row.autoCashout) : null;48  const current = multiplierAt(def, row.events as CrashEvent[], elapsed);49  // Auto cash-out fires the instant the curve reaches the target, provided the crash is strictly later.50  if (auto && current >= auto && crash > auto) return settle(def, row, auto, "cashed");51  if (elapsed >= row.crashAtMs) return settle(def, row, null, "crashed");52  return row;53}5455async function settle(def: CrashGameDefinition, row: CrashRow, cashout: number | null, status: "cashed" | "crashed"): Promise<CrashRow> {56  const win = cashout ? Math.round(row.bet * cashout) : 0;57  const multiplier = cashout ?? 0;58  try {59    return await db.transaction(async (tx) => {60      // Re-check under lock (concurrent cashout vs poll).61      const fresh = await tx.execute(sql`select status from crash_rounds where id = ${row.id} for update`);62      if ((fresh.rows[0] as { status: string } | undefined)?.status !== "running") {63        const again = await tx.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) });64        return again ?? row;65      }66      const wallet = await lockWallet(tx, row.userId);67      if (win > 0) await applyCredit(tx, wallet, "WIN", win, row.roundId, { game: row.gameSlug, multiplier });68      const settled = await settleRound(tx, wallet, {69        roundId: row.roundId,70        gameId: row.gameId,71        gameSlug: row.gameSlug,72        gameVersion: row.gameVersion,73        clientRoundId: row.clientRoundId,74        bet: row.bet,75        win,76        multiplier,77        result: { kind: "crash", crashMultiplier: Number(row.crashMultiplier), cashoutMultiplier: cashout, status, events: row.events, commitment: row.commitment, seed: row.seed, autoCashout: row.autoCashout ? Number(row.autoCashout) : null },78        features: [status === "cashed" ? "Cash out" : "Crash", ...((row.events as CrashEvent[]).map((e) => e.label))],79        freeSpins: false,80        bonus: false,81        jackpotTier: null,82        rngReference: `crash:${row.commitment.slice(0, 16)}`,83        durationMs: Date.now() - row.startedAt.getTime(),84      });85      await saveWallet(tx, wallet);86      const [updated] = await tx87        .update(crashRounds)88        .set({ status, cashoutMultiplier: cashout ? cashout.toFixed(2) : null, win, settledAt: new Date() })89        .where(eq(crashRounds.id, row.id))90        .returning();91      // Attach progression to the row for the response (not persisted here).92      (updated as CrashRow & { progression?: typeof settled }).progression = settled;93      redis().zadd("live:players", Date.now(), row.userId).catch(() => {});94      return updated;95    });96  } catch (e) {97    if (pgCode(e) === PG_UNIQUE_VIOLATION) {98      const again = await db.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) });99      if (again) return again;100    }101    throw e;102  }103}104105export async function crashRoutes(app: FastifyInstance) {106  /** Recent crash points for the strip at the top of the game (public). */107  app.get("/api/crash/:slug/history", async (req) => {108    const { slug } = req.params as { slug: string };109    if (!CRASH_BY_SLUG.has(slug)) throw errors.notFound();110    const rows = await db.execute(sql`select (result->>'crashMultiplier')::numeric as crash, created_at from game_rounds where game_slug = ${slug} order by created_at desc limit 24`);111    return { history: (rows.rows as { crash: string; created_at: Date }[]).map((r) => ({ crash: Number(r.crash), at: r.created_at })) };112  });113114  /** Player's own running round for this game (resume after reload). */115  app.get("/api/crash/:slug/current", async (req) => {116    const user = requireUser(req);117    const { slug } = req.params as { slug: string };118    const def = CRASH_BY_SLUG.get(slug);119    if (!def) throw errors.notFound();120    let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.gameSlug, slug), eq(crashRounds.status, "running")), orderBy: desc(crashRounds.startedAt) });121    if (!row) return { round: null };122    row = await autoSettle(def, row, Date.now());123    return { round: view(def, row, Date.now()) };124  });125126  app.post("/api/crash/:slug/start", async (req) => {127    const user = requireUser(req);128    const { slug } = req.params as { slug: string };129    const def = CRASH_BY_SLUG.get(slug);130    if (!def) throw errors.notFound("Unknown game");131    const m = maintenance();132    if (m.enabled) throw errors.maintenance(m.message);133    if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");134    const retry = await rateLimit(`crash:${user.id}`, 120, 60);135    if (retry) throw errors.rateLimited(retry);136    const body = z137      .object({ bet: z.number().int(), clientRoundId: z.string().uuid(), autoCashout: z.number().min(1.01).max(def.maxMultiplier).optional().nullable() })138      .parse(req.body);139    if (!(BET_LEVELS as readonly number[]).includes(body.bet) || body.bet < def.minBet || body.bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");140    const game = await db.query.games.findFirst({ where: eq(games.slug, slug) });141    if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");142143    // Idempotency.144    const existing = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) });145    if (existing) return { round: view(def, await autoSettle(def, existing, Date.now()), Date.now()), balance: null, replayed: true };146147    // Settle any abandoned running round first (its crash time has passed or it is still live → keep it live).148    const running = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.status, "running")) });149    if (running) {150      const r = await autoSettle(CRASH_BY_SLUG.get(running.gameSlug) ?? def, running, Date.now());151      if (r.status === "running") throw errors.conflict("ROUND_IN_PROGRESS", "You already have a round in progress.");152    }153154    const setup = setupCrashRound(def);155    const roundId = newRoundId();156    try {157      const result = await db.transaction(async (tx) => {158        const wallet = await lockWallet(tx, user.id);159        if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet);160        await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug });161        await saveWallet(tx, wallet);162        const [row] = await tx163          .insert(crashRounds)164          .values({165            roundId,166            userId: user.id,167            gameId: game.id,168            gameSlug: slug,169            gameVersion: def.version,170            clientRoundId: body.clientRoundId,171            bet: body.bet,172            crashMultiplier: setup.crashMultiplier.toFixed(2),173            crashAtMs: Math.max(0, Math.round(setup.crashAtMs)),174            seed: setup.seed,175            commitment: setup.commitment,176            events: setup.events,177            autoCashout: body.autoCashout ? body.autoCashout.toFixed(2) : null,178          })179          .returning();180        return { row, balance: wallet.balance };181      });182      redis().zadd("live:players", Date.now(), user.id).catch(() => {});183      return { round: view(def, result.row, Date.now()), balance: result.balance };184    } catch (e) {185      if (pgCode(e) === PG_UNIQUE_VIOLATION) {186        const again = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) });187        if (again) return { round: view(def, again, Date.now()), balance: null, replayed: true };188      }189      throw e;190    }191  });192193  app.post("/api/crash/:slug/cashout", async (req) => {194    const user = requireUser(req);195    const { slug } = req.params as { slug: string };196    const def = CRASH_BY_SLUG.get(slug);197    if (!def) throw errors.notFound();198    const body = z.object({ roundId: z.string(), claimedMultiplier: z.number().min(1).optional() }).parse(req.body);199    const now = Date.now();200    let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, body.roundId), eq(crashRounds.userId, user.id)) });201    if (!row) throw errors.notFound("Round not found");202    row = await autoSettle(def, row, now);203    if (row.status === "running") {204      const elapsed = now - row.startedAt.getTime();205      const server = multiplierAt(def, row.events as CrashEvent[], elapsed);206      // Honour the player's displayed multiplier when it is not ahead of the server clock (network latency works in the player's favour).207      const claimed = body.claimedMultiplier ? Math.floor(body.claimedMultiplier * 100) / 100 : server;208      const cashout = Math.max(1, Math.min(server, claimed));209      const crash = Number(row.crashMultiplier);210      row = cashout < crash && elapsed < row.crashAtMs ? await settle(def, row, cashout, "cashed") : await settle(def, row, null, "crashed");211    }212    const prog = (row as CrashRow & { progression?: { xp: unknown; unlocked: unknown } }).progression;213    const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]);214    return { round: view(def, row, Date.now()), balance: Number(bal.balance), winClass: classifyWin(row.cashoutMultiplier ? Number(row.cashoutMultiplier) : 0), progression: prog ?? null };215  });216217  app.get("/api/crash/:slug/rounds/:roundId", async (req) => {218    const user = requireUser(req);219    const { slug, roundId } = req.params as { slug: string; roundId: string };220    const def = CRASH_BY_SLUG.get(slug);221    if (!def) throw errors.notFound();222    let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, roundId), eq(crashRounds.userId, user.id)) });223    if (!row) throw errors.notFound("Round not found");224    row = await autoSettle(def, row, Date.now());225    const prog = (row as CrashRow & { progression?: unknown }).progression;226    let balance: number | null = null;227    if (row.status !== "running") {228      const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]);229      balance = Number(bal.balance);230    }231    return { round: view(def, row, Date.now()), balance, progression: prog ?? null };232  });233234  void gameRounds;235}236