TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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