TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { and, dailyRewards, eq, gameRounds, gameStates, gameStatistics, sql, userGameStats, userSettings, users, type Tx } from "@spinza/database";2import { classifyWin, type SpinResponse } from "@spinza/shared";3import type { LockedWallet } from "./wallet";4import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression";56export interface SettleArgs {7 roundId: string;8 gameId: string;9 gameSlug: string;10 gameVersion: string;11 clientRoundId: string;12 bet: number;13 win: number;14 multiplier: number;15 result: Record<string, unknown>;16 features: string[];17 freeSpins: boolean;18 bonus: boolean;19 jackpotTier: string | null;20 rngReference: string;21 durationMs: number;22 /** Persistent per-game state (slots); omitted for crash/arcade games. */23 stateAfter?: Record<string, unknown>;24}2526export interface SettleResult {27 xp: SpinResponse["xp"];28 unlocked: SpinResponse["unlocked"];29 winClass: SpinResponse["winClass"];30}3132/**33 * Shared bookkeeping for any settled round (slot spin, crash cash-out, arcade34 * round): round row, per-user/per-game stats, global stats, XP + level rewards,35 * achievements, missions, leaderboards. Must run inside the transaction that36 * holds the wallet lock, AFTER the bet/win ledger entries have been applied.37 */38export async function settleRound(tx: Tx, wallet: LockedWallet, a: SettleArgs): Promise<SettleResult> {39 const userId = wallet.userId;40 const [u] = await tx41 .select({ xp: users.xp, level: users.level, totalSpins: users.totalSpins, gamesPlayed: users.gamesPlayed, biggestWin: users.biggestWin, biggestMultiplier: users.biggestMultiplier })42 .from(users)43 .where(eq(users.id, userId))44 .for("update");4546 const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, userId), eq(userGameStats.gameId, a.gameId)), columns: { spins: true } });47 const newGame = !ugsExisting;48 const bonus = a.bonus || a.freeSpins;49 const cls = classifyWin(a.multiplier);50 const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win";51 const facts = { gameSlug: a.gameSlug, bet: a.bet, win: a.win, multiplier: a.multiplier, bonus, jackpot: !!a.jackpotTier, newGame };5253 const totalSpins = u.totalSpins + 1;54 const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0);55 const biggestWin = Math.max(u.biggestWin, a.win);56 const biggestMultiplier = Math.max(Number(u.biggestMultiplier), a.multiplier);5758 const agg = await tx.execute(sql`select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${userId}`);59 const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses);60 const bigAgg = await tx.execute(sql`61 select count(*) filter (where multiplier >= 20)::bigint as big_wins,62 count(*) filter (where win > 0)::bigint as wins,63 count(*) filter (where jackpot_tier is not null)::bigint as jackpots64 from game_rounds where user_id = ${userId}`);65 const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number };66 const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId), columns: { streakDay: true } });6768 await tx.insert(gameRounds).values({69 roundId: a.roundId,70 userId,71 gameId: a.gameId,72 gameSlug: a.gameSlug,73 gameVersion: a.gameVersion,74 clientRoundId: a.clientRoundId,75 bet: a.bet,76 win: a.win,77 multiplier: a.multiplier.toFixed(4),78 balanceAfter: wallet.balance,79 result: a.result,80 features: a.features,81 freeSpins: a.freeSpins,82 bonus: a.bonus,83 jackpotTier: a.jackpotTier,84 rngReference: a.rngReference,85 durationMs: a.durationMs,86 });8788 if (a.stateAfter) {89 await tx90 .insert(gameStates)91 .values({ userId, gameId: a.gameId, state: a.stateAfter })92 .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: a.stateAfter, updatedAt: new Date() } });93 }9495 await tx96 .insert(userGameStats)97 .values({ userId, gameId: a.gameId, spins: 1, wagered: a.bet, won: a.win, bonuses: bonus ? 1 : 0, biggestWin: a.win, biggestMultiplier: a.multiplier.toFixed(2) })98 .onConflictDoUpdate({99 target: [userGameStats.userId, userGameStats.gameId],100 set: {101 spins: sql`${userGameStats.spins} + 1`,102 wagered: sql`${userGameStats.wagered} + ${a.bet}`,103 won: sql`${userGameStats.won} + ${a.win}`,104 bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`,105 biggestWin: sql`greatest(${userGameStats.biggestWin}, ${a.win})`,106 biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`,107 lastPlayedAt: new Date(),108 },109 });110111 await tx112 .update(gameStatistics)113 .set({114 spins: sql`${gameStatistics.spins} + 1`,115 wagered: sql`${gameStatistics.wagered} + ${a.bet}`,116 won: sql`${gameStatistics.won} + ${a.win}`,117 wins: sql`${gameStatistics.wins} + ${a.win > 0 ? 1 : 0}`,118 bonuses: sql`${gameStatistics.bonuses} + ${a.bonus ? 1 : 0}`,119 freeSpins: sql`${gameStatistics.freeSpins} + ${a.freeSpins ? 1 : 0}`,120 bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`,121 maxWin: sql`greatest(${gameStatistics.maxWin}, ${a.win})`,122 maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`,123 updatedAt: new Date(),124 })125 .where(eq(gameStatistics.gameId, a.gameId));126127 let xpGain = spinXp(facts);128 const counters: UserCounters = {129 totalSpins,130 gamesPlayed,131 biggestWin,132 biggestMultiplier,133 level: u.level,134 xp: u.xp,135 bonuses: bonusesBefore + (bonus ? 1 : 0),136 bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0),137 wins: Number(ba.wins) + (a.win > 0 ? 1 : 0),138 jackpots: Number(ba.jackpots) + (a.jackpotTier ? 1 : 0),139 dailyStreak: streak?.streakDay ?? 0,140 };141 const ach = await checkAchievements(tx, wallet, counters);142 const mis = await advanceMissions(tx, wallet, facts);143 xpGain += ach.xp + mis.xp;144 const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, a.roundId);145 if (lvl.leveledUp) {146 counters.level = lvl.level;147 const ach2 = await checkAchievements(tx, wallet, counters);148 ach.unlocked.push(...ach2.unlocked);149 }150151 await tx.update(users).set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) }).where(eq(users.id, userId));152153 const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, userId), columns: { leaderboardOptIn: true } });154 await upsertLeaderboards(tx, userId, facts, a.roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true);155156 return {157 xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward },158 unlocked: { achievements: ach.unlocked, missions: mis.completed },159 winClass: cls,160 };161}162