SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
17 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
6.8 KB · 162 lines typescript
Raw Blame History
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