1import { and, db, eq, gameRounds, gameStates, gameStatistics, games, sql, userGameStats, userSettings, users, dailyRewards } 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 { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression";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 // User counters.78 const [u] = await tx79 .select({80 xp: users.xp,81 level: users.level,82 totalSpins: users.totalSpins,83 gamesPlayed: users.gamesPlayed,84 biggestWin: users.biggestWin,85 biggestMultiplier: users.biggestMultiplier,86 })87 .from(users)88 .where(eq(users.id, input.userId))89 .for("update");9091 const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, input.userId), eq(userGameStats.gameId, game.id)) });92 const newGame = !ugsExisting;93 const bonus = outcome.bonusTriggered || outcome.freeSpinsTriggered;94 const cls = classifyWin(outcome.multiplier);95 const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win";9697 const facts = { gameSlug: def.slug, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, bonus, jackpot: !!outcome.jackpot, newGame };9899 const totalSpins = u.totalSpins + 1;100 const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0);101 const biggestWin = Math.max(u.biggestWin, outcome.totalWin);102 const biggestMultiplier = Math.max(Number(u.biggestMultiplier), outcome.multiplier);103104 // Aggregate counters for achievements (bonuses / big wins / wins / jackpots from ledger-independent stats).105 const agg = await tx.execute(sql`106 select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${input.userId}`);107 const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses);108 const bigAgg = await tx.execute(sql`109 select count(*) filter (where multiplier >= 20)::bigint as big_wins,110 count(*) filter (where win > 0)::bigint as wins,111 count(*) filter (where jackpot_tier is not null)::bigint as jackpots112 from game_rounds where user_id = ${input.userId}`);113 const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number };114 const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, input.userId), columns: { streakDay: true } });115116 // Round row (unique on user+clientRoundId → duplicate protection).117 await tx.insert(gameRounds).values({118 roundId,119 userId: input.userId,120 gameId: game.id,121 gameSlug: def.slug,122 gameVersion: def.version,123 clientRoundId: input.clientRoundId,124 bet: input.bet,125 win: outcome.totalWin,126 multiplier: outcome.multiplier.toFixed(4),127 balanceAfter: wallet.balance,128 result: storableResult(outcome),129 features: outcome.features,130 freeSpins: outcome.freeSpinsTriggered,131 bonus: outcome.bonusTriggered,132 jackpotTier: outcome.jackpot?.tier ?? null,133 rngReference: outcome.rngReference,134 durationMs: Date.now() - started,135 });136137 // Persistent game state.138 await tx139 .insert(gameStates)140 .values({ userId: input.userId, gameId: game.id, state: outcome.stateAfter as unknown as Record<string, unknown> })141 .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: outcome.stateAfter as unknown as Record<string, unknown>, updatedAt: new Date() } });142143 // Per-user per-game stats.144 await tx145 .insert(userGameStats)146 .values({147 userId: input.userId,148 gameId: game.id,149 spins: 1,150 wagered: input.bet,151 won: outcome.totalWin,152 bonuses: bonus ? 1 : 0,153 biggestWin: outcome.totalWin,154 biggestMultiplier: outcome.multiplier.toFixed(2),155 })156 .onConflictDoUpdate({157 target: [userGameStats.userId, userGameStats.gameId],158 set: {159 spins: sql`${userGameStats.spins} + 1`,160 wagered: sql`${userGameStats.wagered} + ${input.bet}`,161 won: sql`${userGameStats.won} + ${outcome.totalWin}`,162 bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`,163 biggestWin: sql`greatest(${userGameStats.biggestWin}, ${outcome.totalWin})`,164 biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,165 lastPlayedAt: new Date(),166 },167 });168169 // Global game statistics.170 await tx171 .update(gameStatistics)172 .set({173 spins: sql`${gameStatistics.spins} + 1`,174 wagered: sql`${gameStatistics.wagered} + ${input.bet}`,175 won: sql`${gameStatistics.won} + ${outcome.totalWin}`,176 wins: sql`${gameStatistics.wins} + ${outcome.totalWin > 0 ? 1 : 0}`,177 bonuses: sql`${gameStatistics.bonuses} + ${outcome.bonusTriggered ? 1 : 0}`,178 freeSpins: sql`${gameStatistics.freeSpins} + ${outcome.freeSpinsTriggered ? 1 : 0}`,179 bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`,180 maxWin: sql`greatest(${gameStatistics.maxWin}, ${outcome.totalWin})`,181 maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,182 updatedAt: new Date(),183 })184 .where(eq(gameStatistics.gameId, game.id));185186 // XP, achievements, missions.187 let xpGain = spinXp(facts);188 const counters: UserCounters = {189 totalSpins,190 gamesPlayed,191 biggestWin,192 biggestMultiplier,193 level: u.level,194 xp: u.xp,195 bonuses: bonusesBefore + (bonus ? 1 : 0),196 bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0),197 wins: Number(ba.wins) + (outcome.totalWin > 0 ? 1 : 0),198 jackpots: Number(ba.jackpots) + (outcome.jackpot ? 1 : 0),199 dailyStreak: streak?.streakDay ?? 0,200 };201 const ach = await checkAchievements(tx, wallet, counters);202 const mis = await advanceMissions(tx, wallet, facts);203 xpGain += ach.xp + mis.xp;204 const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, roundId);205 if (lvl.leveledUp) {206 counters.level = lvl.level;207 const ach2 = await checkAchievements(tx, wallet, counters);208 ach.unlocked.push(...ach2.unlocked);209 }210211 await tx212 .update(users)213 .set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) })214 .where(eq(users.id, input.userId));215216 const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, input.userId), columns: { leaderboardOptIn: true } });217 await upsertLeaderboards(tx, input.userId, facts, roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true);218219 // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.220 await saveWallet(tx, wallet);221222 // Live counters (best effort, outside the ledger).223 redis()224 .multi()225 .incr("stats:spins:today:" + new Date().toISOString().slice(0, 10))226 .pfadd("stats:players:today:" + new Date().toISOString().slice(0, 10), input.userId)227 .zadd("live:players", Date.now(), input.userId)228 .zadd(`live:game:${def.slug}`, Date.now(), input.userId)229 .exec()230 .catch(() => {});231232 return {233 roundId,234 game: def.slug,235 version: def.version,236 bet: input.bet,237 win: outcome.totalWin,238 multiplier: outcome.multiplier,239 winClass: cls,240 balance: wallet.balance,241 xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward },242 result: storableResult(outcome),243 unlocked: { achievements: ach.unlocked, missions: mis.completed },244 } satisfies SpinResponse;245 });246 } catch (e) {247 // Concurrent duplicate: the unique index on (user_id, client_round_id) fired → return the stored round.248 if (pgCode(e) === PG_UNIQUE_VIOLATION) {249 const again = await replay(input.userId, input.clientRoundId);250 if (again) return again;251 }252 throw e;253 }254}255