TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { and, db, eq, gameRounds, games, sql, users } from "@spinza/database";2import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core";3import { getGame } from "@spinza/games";4import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared";5import { errors } from "../lib/errors";6import { newRoundId } from "../lib/crypto";7import { flag, maintenance } from "../lib/settings";8import { applyCredit, lockWallet, saveWallet } from "./wallet";9import { settleRound } from "./settle";10import { redis } from "../lib/redis";11import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";1213export interface SpinInput {14 userId: string;15 slug: string;16 bet: number;17 clientRoundId: string;18}1920/** Strip the pre-state from the stored result to keep rounds compact. */21function storableResult(out: SpinOutcome): Record<string, unknown> {22 const { stateBefore: _b, stateAfter: _a, rngReference: _r, ...rest } = out;23 return rest as unknown as Record<string, unknown>;24}2526async function replay(userId: string, clientRoundId: string): Promise<SpinResponse | null> {27 const row = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.userId, userId), eq(gameRounds.clientRoundId, clientRoundId)) });28 if (!row) return null;29 const u = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { xp: true, level: true } });30 return {31 roundId: row.roundId,32 game: row.gameSlug,33 version: row.gameVersion,34 bet: row.bet,35 win: row.win,36 multiplier: Number(row.multiplier),37 winClass: classifyWin(Number(row.multiplier)),38 balance: row.balanceAfter,39 xp: { gained: 0, total: u?.xp ?? 0, level: u?.level ?? 1, leveledUp: false, levelReward: 0 },40 result: row.result,41 unlocked: { achievements: [], missions: [] },42 replayed: true,43 };44}4546export async function spin(input: SpinInput): Promise<SpinResponse> {47 const m = maintenance();48 if (m.enabled) throw errors.maintenance(m.message);49 const def = getGame(input.slug);50 if (!def) throw errors.notFound("Unknown game");51 if (!flag(`game.${def.slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");52 if (!(BET_LEVELS as readonly number[]).includes(input.bet) || input.bet < def.minBet || input.bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");5354 const game = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });55 if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");56 if (game.version !== def.version) throw errors.unavailable("Game version mismatch — please reload.");5758 // Fast idempotency path.59 const existing = await replay(input.userId, input.clientRoundId);60 if (existing) return existing;6162 const started = Date.now();63 try {64 return await db.transaction(async (tx) => {65 const wallet = await lockWallet(tx, input.userId);66 if (wallet.balance < input.bet) throw errors.insufficient(wallet.balance, input.bet);6768 const stateRow = await tx.execute(sql`select state from game_states where user_id = ${input.userId} and game_id = ${game.id} for update`);69 const state = ((stateRow.rows[0] as { state?: PlayerGameState } | undefined)?.state ?? undefined) as PlayerGameState | undefined;7071 const outcome = runSpin(def, input.bet, { state });72 const roundId = newRoundId();7374 await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug });75 if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier });7677 const settled = await settleRound(tx, wallet, {78 roundId,79 gameId: game.id,80 gameSlug: def.slug,81 gameVersion: def.version,82 clientRoundId: input.clientRoundId,83 bet: input.bet,84 win: outcome.totalWin,85 multiplier: outcome.multiplier,86 result: storableResult(outcome),87 features: outcome.features,88 freeSpins: outcome.freeSpinsTriggered,89 bonus: outcome.bonusTriggered,90 jackpotTier: outcome.jackpot?.tier ?? null,91 rngReference: outcome.rngReference,92 durationMs: Date.now() - started,93 stateAfter: outcome.stateAfter as unknown as Record<string, unknown>,94 });95 const cls = settled.winClass;9697 // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.98 await saveWallet(tx, wallet);99100 // Live counters (best effort, outside the ledger).101 redis()102 .multi()103 .incr("stats:spins:today:" + new Date().toISOString().slice(0, 10))104 .pfadd("stats:players:today:" + new Date().toISOString().slice(0, 10), input.userId)105 .zadd("live:players", Date.now(), input.userId)106 .zadd(`live:game:${def.slug}`, Date.now(), input.userId)107 .exec()108 .catch(() => {});109110 return {111 roundId,112 game: def.slug,113 version: def.version,114 bet: input.bet,115 win: outcome.totalWin,116 multiplier: outcome.multiplier,117 winClass: cls,118 balance: wallet.balance,119 xp: settled.xp,120 result: storableResult(outcome),121 unlocked: settled.unlocked,122 } satisfies SpinResponse;123 });124 } catch (e) {125 // Concurrent duplicate: the unique index on (user_id, client_round_id) fired → return the stored round.126 if (pgCode(e) === PG_UNIQUE_VIOLATION) {127 const again = await replay(input.userId, input.clientRoundId);128 if (again) return again;129 }130 throw e;131 }132}133