SPB Git

spb/spinza

Public
10.2 KB · 199 lines typescript
1import type { FastifyInstance } from "fastify";2import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";3import { getGame, FIRST_GAME_RECOMMENDATIONS } from "@spinza/games";4import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";5import { requireUser } from "../plugins/auth";6import { errors } from "../lib/errors";7import { flag } from "../lib/settings";8import { spin } from "../services/spin";9import { rateLimit, redis } from "../lib/redis";10import { clientIp, logSecurity } from "../lib/security";11import { z } from "zod";1213type GameRow = typeof games.$inferSelect;1415function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; lastPlayedAt?: Date | null } = {}): GameCard {16  const s = g.summary as Record<string, unknown>;17  return {18    slug: g.slug,19    name: g.name,20    tagline: s.tagline as string,21    theme: s.theme as string,22    volatility: s.volatility as GameCard["volatility"],23    version: g.version,24    grid: s.grid as GameCard["grid"],25    minBet: s.minBet as number,26    maxBet: s.maxBet as number,27    maxMultiplier: s.maxMultiplier as number,28    features: (s.features as string[]) ?? [],29    tags: (s.tags as string[]) ?? [],30    palette: s.palette as GameCard["palette"],31    lifecycle: g.lifecycle as GameCard["lifecycle"],32    isNew: g.isNew,33    isFeatured: g.isFeatured,34    isJackpot: !!s.isJackpot,35    popularity: extra.popularity ?? 0,36    favorite: extra.favorite,37    lastPlayedAt: extra.lastPlayedAt ? extra.lastPlayedAt.toISOString() : null,38  };39}4041export async function publishedGames(): Promise<GameRow[]> {42  const rows = await db.select().from(games).where(eq(games.lifecycle, "published")).orderBy(games.sortOrder);43  return rows.filter((g) => flag(`game.${g.slug}.enabled`));44}4546export async function gameRoutes(app: FastifyInstance) {47  /** Public lobby data. When signed in, includes favorites + continue playing. */48  app.get("/api/games", async (req) => {49    const rows = await publishedGames();50    const stats = await db.select({ gameId: gameStatistics.gameId, spins: gameStatistics.spins, launches: gameStatistics.launches }).from(gameStatistics);51    const statMap = new Map(stats.map((s) => [s.gameId, s]));52    let favs = new Set<string>();53    let recent = new Map<string, Date>();54    if (req.user) {55      const f = await db.select({ gameId: favorites.gameId }).from(favorites).where(eq(favorites.userId, req.user.id));56      favs = new Set(f.map((x) => x.gameId));57      const r = await db.select({ gameId: userGameStats.gameId, at: userGameStats.lastPlayedAt }).from(userGameStats).where(eq(userGameStats.userId, req.user.id));58      recent = new Map(r.map((x) => [x.gameId, x.at]));59    }60    const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);61    const cards = rows.map((g) => {62      const st = statMap.get(g.id);63      return toCard(g, { popularity: Number(st?.spins ?? 0) + Number(st?.launches ?? 0) * 20, favorite: favs.has(g.id), lastPlayedAt: recent.get(g.id) ?? null });64    });65    return { games: cards, livePlayers: live, recommendations: FIRST_GAME_RECOMMENDATIONS };66  });6768  app.get("/api/games/:slug", async (req) => {69    const { slug } = req.params as { slug: string };70    const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });71    const def = getGame(slug);72    if (!g || !def || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found");73    const s = g.summary as Record<string, unknown>;74    const cert = (s.certification as GameInfo["certification"] | null) ?? null;75    let favorite = false;76    if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));77    const info: GameInfo = {78      ...toCard(g, { favorite }),79      rtp: def.rtp,80      hitFrequency: cert?.hitRate ?? null,81      description: def.description,82      rules: def.rules,83      paytable: def.symbols84        .filter((sym) => sym.pays || sym.scatterPays)85        .map((sym) => ({86          symbol: sym.id,87          label: sym.name,88          // Displayed as multiples of total bet (payScale/betDivisor applied), rounded to 2 decimals.89          pays: Object.fromEntries(90            Object.entries(sym.pays ?? sym.scatterPays ?? {}).map(([k, v]) => [k, Number(((v * def.payScale) / (sym.pays ? def.betDivisor : 1)).toFixed(3))]),91          ),92        })),93      certification: cert,94    };95    return { game: info, definition: clientDefinition(def) };96  });9798  app.post("/api/games/:slug/launch", async (req) => {99    const { slug } = req.params as { slug: string };100    const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });101    if (!g) throw errors.notFound();102    await db.update(gameStatistics).set({ launches: sql`${gameStatistics.launches} + 1` }).where(eq(gameStatistics.gameId, g.id));103    if (req.user) {104      // Persisted game state for meters/heat so the client can render them on load.105      const st = await db.execute(sql`select state from game_states where user_id = ${req.user.id} and game_id = ${g.id}`);106      return { ok: true, state: (st.rows[0] as { state?: unknown } | undefined)?.state ?? null };107    }108    return { ok: true, state: null };109  });110111  app.post("/api/games/:slug/spin", async (req) => {112    const user = requireUser(req);113    const { slug } = req.params as { slug: string };114    const retry = await rateLimit(`spin:${user.id}`, 240, 60);115    if (retry) {116      await logSecurity(req, "spin.suspicious", { userId: user.id, severity: "warn", meta: { slug, ip: clientIp(req) } });117      throw errors.rateLimited(retry);118    }119    const parsed = spinSchema.safeParse(req.body);120    if (!parsed.success) throw errors.badRequest("Invalid spin request.", parsed.error.flatten());121    return spin({ userId: user.id, slug, bet: parsed.data.bet, clientRoundId: parsed.data.clientRoundId });122  });123124  app.get("/api/games/:slug/history", async (req) => {125    const user = requireUser(req);126    const { slug } = req.params as { slug: string };127    const q = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);128    const rows = await db.execute(sql`select round_id, bet, win, multiplier, balance_after, created_at, features from game_rounds where user_id = ${user.id} and game_slug = ${slug} order by created_at desc limit ${q.limit}`);129    return {130      entries: (rows.rows as Record<string, unknown>[]).map((r) => ({131        roundId: r.round_id,132        bet: Number(r.bet),133        win: Number(r.win),134        multiplier: Number(r.multiplier),135        balanceAfter: Number(r.balance_after),136        createdAt: (r.created_at as Date).toISOString(),137        features: r.features,138      })),139    };140  });141142  app.post("/api/games/:slug/favorite", async (req) => {143    const user = requireUser(req);144    const { slug } = req.params as { slug: string };145    const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });146    if (!g) throw errors.notFound();147    const existing = await db.query.favorites.findFirst({ where: and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)) });148    if (existing) {149      await db.delete(favorites).where(and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)));150      await db.update(gameStatistics).set({ favorites: sql`greatest(${gameStatistics.favorites} - 1, 0)` }).where(eq(gameStatistics.gameId, g.id));151      return { favorite: false };152    }153    await db.insert(favorites).values({ userId: user.id, gameId: g.id });154    await db.update(gameStatistics).set({ favorites: sql`${gameStatistics.favorites} + 1` }).where(eq(gameStatistics.gameId, g.id));155    return { favorite: true };156  });157158  app.get("/api/user/games", async (req) => {159    const user = requireUser(req);160    const rows = await db161      .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, won: userGameStats.won, wagered: userGameStats.wagered, biggestWin: userGameStats.biggestWin, biggestMultiplier: userGameStats.biggestMultiplier, lastPlayedAt: userGameStats.lastPlayedAt })162      .from(userGameStats)163      .innerJoin(games, eq(games.id, userGameStats.gameId))164      .where(eq(userGameStats.userId, user.id))165      .orderBy(desc(userGameStats.lastPlayedAt));166    return { games: rows.map((r) => ({ ...r, biggestMultiplier: Number(r.biggestMultiplier), lastPlayedAt: r.lastPlayedAt.toISOString() })) };167  });168}169170/** Definition data the renderer needs (symbols, grid, presentation). Weights are public "Game Info" data too. */171export function clientDefinition(def: ReturnType<typeof getGame> & object) {172  return {173    slug: def.slug,174    name: def.name,175    version: def.version,176    grid: def.grid,177    payModel: def.payModel,178    symbols: def.symbols.map((s) => ({ id: s.id, name: s.name, kind: s.kind, tier: s.tier, style: s.style })),179    wild: def.wild ? { id: def.wild.id, expanding: !!def.wild.expanding, sticky: !!def.wild.sticky, multiplier: !!def.wild.multiplier, exploding: !!def.wild.exploding, moving: !!def.wild.moving } : null,180    scatter: def.scatter ? { id: def.scatter.id, triggers: def.scatter.triggers } : null,181    cascades: !!def.cascades,182    spinCollect: def.spinCollect ? { symbolId: def.spinCollect.symbolId, ladder: def.spinCollect.ladder } : null,183    holdRespin: def.holdRespin ? { symbolId: def.holdRespin.symbolId, respins: def.holdRespin.respins, jackpots: def.holdRespin.jackpots } : null,184    meter: def.meter ? { id: def.meter.id, name: def.meter.name, symbolId: def.meter.symbolId, max: def.meter.max, label: def.meter.onFull.label } : null,185    heat: def.heat ? { max: def.heat.max, ladder: def.heat.ladder, bandSize: def.heat.bandSize, label: def.heat.meltdown.label } : null,186    mystery: def.mystery ? { symbolId: def.mystery.symbolId } : null,187    quantum: !!def.quantum,188    dynamicGrid: def.dynamicGrid ?? null,189    jackpot: def.jackpot ? { tiers: def.jackpot.tiers.map((t) => ({ id: t.id, multiplier: t.multiplier })) } : null,190    pickBonuses: def.pickBonuses?.map((b) => ({ id: b.id, name: b.name, cells: b.cells, picks: b.picks })) ?? [],191    minBet: def.minBet,192    maxBet: def.maxBet,193    maxMultiplier: def.maxMultiplier,194    volatility: def.volatility,195    presentation: def.presentation,196    featureNames: def.featureNames,197  };198}199
Files · 3b4179a710ab1cd5f12ca7cfd253b81674f1521e
Menu

Theme