SPB Git

spb/spinza

Public
11.5 KB · 255 lines typescript
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
Files · 3b4179a710ab1cd5f12ca7cfd253b81674f1521e
Menu

Theme