import { and, db, eq, gameRounds, games, sql, users } from "@spinza/database"; import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core"; import { getGame } from "@spinza/games"; import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared"; import { errors } from "../lib/errors"; import { newRoundId } from "../lib/crypto"; import { flag, maintenance } from "../lib/settings"; import { applyCredit, lockWallet, saveWallet } from "./wallet"; import { settleRound } from "./settle"; import { redis } from "../lib/redis"; import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; export interface SpinInput { userId: string; slug: string; bet: number; clientRoundId: string; } /** Strip the pre-state from the stored result to keep rounds compact. */ function storableResult(out: SpinOutcome): Record { const { stateBefore: _b, stateAfter: _a, rngReference: _r, ...rest } = out; return rest as unknown as Record; } async function replay(userId: string, clientRoundId: string): Promise { const row = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.userId, userId), eq(gameRounds.clientRoundId, clientRoundId)) }); if (!row) return null; const u = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { xp: true, level: true } }); return { roundId: row.roundId, game: row.gameSlug, version: row.gameVersion, bet: row.bet, win: row.win, multiplier: Number(row.multiplier), winClass: classifyWin(Number(row.multiplier)), balance: row.balanceAfter, xp: { gained: 0, total: u?.xp ?? 0, level: u?.level ?? 1, leveledUp: false, levelReward: 0 }, result: row.result, unlocked: { achievements: [], missions: [] }, replayed: true, }; } export async function spin(input: SpinInput): Promise { const m = maintenance(); if (m.enabled) throw errors.maintenance(m.message); const def = getGame(input.slug); if (!def) throw errors.notFound("Unknown game"); if (!flag(`game.${def.slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable."); 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."); const game = await db.query.games.findFirst({ where: eq(games.slug, def.slug) }); if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); if (game.version !== def.version) throw errors.unavailable("Game version mismatch — please reload."); // Fast idempotency path. const existing = await replay(input.userId, input.clientRoundId); if (existing) return existing; const started = Date.now(); try { return await db.transaction(async (tx) => { const wallet = await lockWallet(tx, input.userId); if (wallet.balance < input.bet) throw errors.insufficient(wallet.balance, input.bet); const stateRow = await tx.execute(sql`select state from game_states where user_id = ${input.userId} and game_id = ${game.id} for update`); const state = ((stateRow.rows[0] as { state?: PlayerGameState } | undefined)?.state ?? undefined) as PlayerGameState | undefined; const outcome = runSpin(def, input.bet, { state }); const roundId = newRoundId(); await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug }); if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier }); const settled = await settleRound(tx, wallet, { roundId, gameId: game.id, gameSlug: def.slug, gameVersion: def.version, clientRoundId: input.clientRoundId, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, result: storableResult(outcome), features: outcome.features, freeSpins: outcome.freeSpinsTriggered, bonus: outcome.bonusTriggered, jackpotTier: outcome.jackpot?.tier ?? null, rngReference: outcome.rngReference, durationMs: Date.now() - started, stateAfter: outcome.stateAfter as unknown as Record, }); const cls = settled.winClass; // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards. await saveWallet(tx, wallet); // Live counters (best effort, outside the ledger). redis() .multi() .incr("stats:spins:today:" + new Date().toISOString().slice(0, 10)) .pfadd("stats:players:today:" + new Date().toISOString().slice(0, 10), input.userId) .zadd("live:players", Date.now(), input.userId) .zadd(`live:game:${def.slug}`, Date.now(), input.userId) .exec() .catch(() => {}); return { roundId, game: def.slug, version: def.version, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, winClass: cls, balance: wallet.balance, xp: settled.xp, result: storableResult(outcome), unlocked: settled.unlocked, } satisfies SpinResponse; }); } catch (e) { // Concurrent duplicate: the unique index on (user_id, client_round_id) fired → return the stored round. if (pgCode(e) === PG_UNIQUE_VIOLATION) { const again = await replay(input.userId, input.clientRoundId); if (again) return again; } throw e; } }