import type { FastifyInstance } from "fastify"; import { and, crashRounds, db, desc, eq, gameRounds, games, sql } from "@spinza/database"; import { multiplierAt, setupCrashRound, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core"; import { CRASH_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 CrashRow = typeof crashRounds.$inferSelect; /** Public view of a round (never leaks seed/crash while running). */ function view(def: CrashGameDefinition, r: CrashRow, serverNow: number) { const running = r.status === "running"; return { roundId: r.roundId, game: r.gameSlug, version: r.gameVersion, bet: r.bet, status: r.status, startedAt: r.startedAt.toISOString(), serverNow, elapsedMs: serverNow - r.startedAt.getTime(), commitment: r.commitment, events: r.events as CrashEvent[], autoCashout: r.autoCashout ? Number(r.autoCashout) : null, curve: def.curve, maxMultiplier: def.maxMultiplier, crashMultiplier: running ? null : Number(r.crashMultiplier), seed: running ? null : r.seed, cashoutMultiplier: r.cashoutMultiplier ? Number(r.cashoutMultiplier) : null, win: running ? null : r.win, }; } /** Settle a running round that has crashed or reached its auto cash-out. Returns the updated row. */ async function autoSettle(def: CrashGameDefinition, row: CrashRow, now: number): Promise { if (row.status !== "running") return row; const elapsed = now - row.startedAt.getTime(); const crash = Number(row.crashMultiplier); const auto = row.autoCashout ? Number(row.autoCashout) : null; const current = multiplierAt(def, row.events as CrashEvent[], elapsed); // Auto cash-out fires the instant the curve reaches the target, provided the crash is strictly later. if (auto && current >= auto && crash > auto) return settle(def, row, auto, "cashed"); if (elapsed >= row.crashAtMs) return settle(def, row, null, "crashed"); return row; } async function settle(def: CrashGameDefinition, row: CrashRow, cashout: number | null, status: "cashed" | "crashed"): Promise { const win = cashout ? Math.round(row.bet * cashout) : 0; const multiplier = cashout ?? 0; try { return await db.transaction(async (tx) => { // Re-check under lock (concurrent cashout vs poll). const fresh = await tx.execute(sql`select status from crash_rounds where id = ${row.id} for update`); if ((fresh.rows[0] as { status: string } | undefined)?.status !== "running") { const again = await tx.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) }); return again ?? row; } const wallet = await lockWallet(tx, row.userId); if (win > 0) await applyCredit(tx, wallet, "WIN", win, row.roundId, { game: row.gameSlug, multiplier }); const settled = await settleRound(tx, wallet, { roundId: row.roundId, gameId: row.gameId, gameSlug: row.gameSlug, gameVersion: row.gameVersion, clientRoundId: row.clientRoundId, bet: row.bet, win, multiplier, 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 }, features: [status === "cashed" ? "Cash out" : "Crash", ...((row.events as CrashEvent[]).map((e) => e.label))], freeSpins: false, bonus: false, jackpotTier: null, rngReference: `crash:${row.commitment.slice(0, 16)}`, durationMs: Date.now() - row.startedAt.getTime(), }); await saveWallet(tx, wallet); const [updated] = await tx .update(crashRounds) .set({ status, cashoutMultiplier: cashout ? cashout.toFixed(2) : null, win, settledAt: new Date() }) .where(eq(crashRounds.id, row.id)) .returning(); // Attach progression to the row for the response (not persisted here). (updated as CrashRow & { progression?: typeof settled }).progression = settled; redis().zadd("live:players", Date.now(), row.userId).catch(() => {}); return updated; }); } catch (e) { if (pgCode(e) === PG_UNIQUE_VIOLATION) { const again = await db.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) }); if (again) return again; } throw e; } } export async function crashRoutes(app: FastifyInstance) { /** Recent crash points for the strip at the top of the game (public). */ app.get("/api/crash/:slug/history", async (req) => { const { slug } = req.params as { slug: string }; if (!CRASH_BY_SLUG.has(slug)) throw errors.notFound(); 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`); return { history: (rows.rows as { crash: string; created_at: Date }[]).map((r) => ({ crash: Number(r.crash), at: r.created_at })) }; }); /** Player's own running round for this game (resume after reload). */ app.get("/api/crash/:slug/current", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const def = CRASH_BY_SLUG.get(slug); if (!def) throw errors.notFound(); 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) }); if (!row) return { round: null }; row = await autoSettle(def, row, Date.now()); return { round: view(def, row, Date.now()) }; }); app.post("/api/crash/:slug/start", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const def = CRASH_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."); const retry = await rateLimit(`crash:${user.id}`, 120, 60); if (retry) throw errors.rateLimited(retry); const body = z .object({ bet: z.number().int(), clientRoundId: z.string().uuid(), autoCashout: z.number().min(1.01).max(def.maxMultiplier).optional().nullable() }) .parse(req.body); 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."); 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."); // Idempotency. const existing = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) }); if (existing) return { round: view(def, await autoSettle(def, existing, Date.now()), Date.now()), balance: null, replayed: true }; // Settle any abandoned running round first (its crash time has passed or it is still live → keep it live). const running = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.status, "running")) }); if (running) { const r = await autoSettle(CRASH_BY_SLUG.get(running.gameSlug) ?? def, running, Date.now()); if (r.status === "running") throw errors.conflict("ROUND_IN_PROGRESS", "You already have a round in progress."); } const setup = setupCrashRound(def); const roundId = newRoundId(); 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 }); await saveWallet(tx, wallet); const [row] = await tx .insert(crashRounds) .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, crashMultiplier: setup.crashMultiplier.toFixed(2), crashAtMs: Math.max(0, Math.round(setup.crashAtMs)), seed: setup.seed, commitment: setup.commitment, events: setup.events, autoCashout: body.autoCashout ? body.autoCashout.toFixed(2) : null, }) .returning(); return { row, balance: wallet.balance }; }); redis().zadd("live:players", Date.now(), user.id).catch(() => {}); return { round: view(def, result.row, Date.now()), balance: result.balance }; } catch (e) { if (pgCode(e) === PG_UNIQUE_VIOLATION) { const again = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) }); if (again) return { round: view(def, again, Date.now()), balance: null, replayed: true }; } throw e; } }); app.post("/api/crash/:slug/cashout", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const def = CRASH_BY_SLUG.get(slug); if (!def) throw errors.notFound(); const body = z.object({ roundId: z.string(), claimedMultiplier: z.number().min(1).optional() }).parse(req.body); const now = Date.now(); let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, body.roundId), eq(crashRounds.userId, user.id)) }); if (!row) throw errors.notFound("Round not found"); row = await autoSettle(def, row, now); if (row.status === "running") { const elapsed = now - row.startedAt.getTime(); const server = multiplierAt(def, row.events as CrashEvent[], elapsed); // Honour the player's displayed multiplier when it is not ahead of the server clock (network latency works in the player's favour). const claimed = body.claimedMultiplier ? Math.floor(body.claimedMultiplier * 100) / 100 : server; const cashout = Math.max(1, Math.min(server, claimed)); const crash = Number(row.crashMultiplier); row = cashout < crash && elapsed < row.crashAtMs ? await settle(def, row, cashout, "cashed") : await settle(def, row, null, "crashed"); } const prog = (row as CrashRow & { progression?: { xp: unknown; unlocked: unknown } }).progression; const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]); return { round: view(def, row, Date.now()), balance: Number(bal.balance), winClass: classifyWin(row.cashoutMultiplier ? Number(row.cashoutMultiplier) : 0), progression: prog ?? null }; }); app.get("/api/crash/:slug/rounds/:roundId", async (req) => { const user = requireUser(req); const { slug, roundId } = req.params as { slug: string; roundId: string }; const def = CRASH_BY_SLUG.get(slug); if (!def) throw errors.notFound(); let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, roundId), eq(crashRounds.userId, user.id)) }); if (!row) throw errors.notFound("Round not found"); row = await autoSettle(def, row, Date.now()); const prog = (row as CrashRow & { progression?: unknown }).progression; let balance: number | null = null; if (row.status !== "running") { const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]); balance = Number(bal.balance); } return { round: view(def, row, Date.now()), balance, progression: prog ?? null }; }); void gameRounds; }