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%
5.5 KB · 133 lines typescript
Raw Blame History
1import { and, db, eq, gameRounds, games, sql, users } 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 { settleRound } from "./settle";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      const settled = await settleRound(tx, wallet, {78        roundId,79        gameId: game.id,80        gameSlug: def.slug,81        gameVersion: def.version,82        clientRoundId: input.clientRoundId,83        bet: input.bet,84        win: outcome.totalWin,85        multiplier: outcome.multiplier,86        result: storableResult(outcome),87        features: outcome.features,88        freeSpins: outcome.freeSpinsTriggered,89        bonus: outcome.bonusTriggered,90        jackpotTier: outcome.jackpot?.tier ?? null,91        rngReference: outcome.rngReference,92        durationMs: Date.now() - started,93        stateAfter: outcome.stateAfter as unknown as Record<string, unknown>,94      });95      const cls = settled.winClass;9697      // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.98      await saveWallet(tx, wallet);99100      // Live counters (best effort, outside the ledger).101      redis()102        .multi()103        .incr("stats:spins:today:" + new Date().toISOString().slice(0, 10))104        .pfadd("stats:players:today:" + new Date().toISOString().slice(0, 10), input.userId)105        .zadd("live:players", Date.now(), input.userId)106        .zadd(`live:game:${def.slug}`, Date.now(), input.userId)107        .exec()108        .catch(() => {});109110      return {111        roundId,112        game: def.slug,113        version: def.version,114        bet: input.bet,115        win: outcome.totalWin,116        multiplier: outcome.multiplier,117        winClass: cls,118        balance: wallet.balance,119        xp: settled.xp,120        result: storableResult(outcome),121        unlocked: settled.unlocked,122      } satisfies SpinResponse;123    });124  } catch (e) {125    // Concurrent duplicate: the unique index on (user_id, client_round_id) fired → return the stored round.126    if (pgCode(e) === PG_UNIQUE_VIOLATION) {127      const again = await replay(input.userId, input.clientRoundId);128      if (again) return again;129    }130    throw e;131  }132}133