import type { FastifyInstance } from "fastify"; import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games"; import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; import { requireUser } from "../plugins/auth"; import { errors } from "../lib/errors"; import { flag } from "../lib/settings"; import { spin } from "../services/spin"; import { rateLimit, redis } from "../lib/redis"; import { clientIp, logSecurity } from "../lib/security"; import { z } from "zod"; type GameRow = typeof games.$inferSelect; function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; lastPlayedAt?: Date | null } = {}): GameCard { const s = g.summary as Record; return { slug: g.slug, name: g.name, tagline: s.tagline as string, theme: s.theme as string, volatility: s.volatility as GameCard["volatility"], version: g.version, grid: s.grid as GameCard["grid"], minBet: s.minBet as number, maxBet: s.maxBet as number, maxMultiplier: s.maxMultiplier as number, features: (s.features as string[]) ?? [], tags: (s.tags as string[]) ?? [], palette: s.palette as GameCard["palette"], presentation: s.presentation ? { frame: (s.presentation as GameCard["presentation"])!.frame, backdrop: (s.presentation as GameCard["presentation"])!.backdrop, particles: (s.presentation as GameCard["presentation"])!.particles, ambience: (s.presentation as GameCard["presentation"])!.ambience } : undefined, lifecycle: g.lifecycle as GameCard["lifecycle"], kind: ((s.kind as string) ?? "slot") as GameCard["kind"], category: ((s.category as string) ?? "slots") as GameCard["category"], isNew: g.isNew, isFeatured: g.isFeatured, isJackpot: !!s.isJackpot, popularity: extra.popularity ?? 0, favorite: extra.favorite, lastPlayedAt: extra.lastPlayedAt ? extra.lastPlayedAt.toISOString() : null, }; } export async function publishedGames(): Promise { const rows = await db.select().from(games).where(eq(games.lifecycle, "published")).orderBy(games.sortOrder); return rows.filter((g) => flag(`game.${g.slug}.enabled`)); } export async function gameRoutes(app: FastifyInstance) { /** Public lobby data. When signed in, includes favorites + continue playing. */ app.get("/api/games", async (req) => { const rows = await publishedGames(); const stats = await db.select({ gameId: gameStatistics.gameId, spins: gameStatistics.spins, launches: gameStatistics.launches }).from(gameStatistics); const statMap = new Map(stats.map((s) => [s.gameId, s])); let favs = new Set(); let recent = new Map(); if (req.user) { const f = await db.select({ gameId: favorites.gameId }).from(favorites).where(eq(favorites.userId, req.user.id)); favs = new Set(f.map((x) => x.gameId)); const r = await db.select({ gameId: userGameStats.gameId, at: userGameStats.lastPlayedAt }).from(userGameStats).where(eq(userGameStats.userId, req.user.id)); recent = new Map(r.map((x) => [x.gameId, x.at])); } const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0); const cards = rows.map((g) => { const st = statMap.get(g.id); return toCard(g, { popularity: Number(st?.spins ?? 0) + Number(st?.launches ?? 0) * 20, favorite: favs.has(g.id), lastPlayedAt: recent.get(g.id) ?? null }); }); return { games: cards, livePlayers: live, recommendations: FIRST_GAME_RECOMMENDATIONS }; }); app.get("/api/games/:slug", async (req) => { const { slug } = req.params as { slug: string }; const g = await db.query.games.findFirst({ where: eq(games.slug, slug) }); if (!g || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found"); const crashDef = CRASH_BY_SLUG.get(slug); if (crashDef) { const s = g.summary as Record; let favorite = false; if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); const info: GameInfo = { ...toCard(g, { favorite }), rtp: crashDef.rtp, hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null, description: crashDef.description, rules: crashDef.rules, paytable: [], certification: (s.certification as GameInfo["certification"] | null) ?? null, }; return { game: info, definition: crashDef }; } const arcadeDef = ARCADE_BY_SLUG.get(slug); if (arcadeDef) { const s = g.summary as Record; let favorite = false; if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); const info: GameInfo = { ...toCard(g, { favorite }), rtp: arcadeDef.rtp, hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null, description: arcadeDef.description, rules: arcadeDef.rules, paytable: [], certification: (s.certification as GameInfo["certification"] | null) ?? null, }; return { game: info, definition: arcadeDef }; } const def = getGame(slug); if (!def) throw errors.notFound("Game not found"); const s = g.summary as Record; const cert = (s.certification as GameInfo["certification"] | null) ?? null; let favorite = false; if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); const info: GameInfo = { ...toCard(g, { favorite }), rtp: def.rtp, hitFrequency: cert?.hitRate ?? null, description: def.description, rules: def.rules, paytable: def.symbols .filter((sym) => sym.pays || sym.scatterPays) .map((sym) => ({ symbol: sym.id, label: sym.name, // Displayed as multiples of total bet (payScale/betDivisor applied), rounded to 2 decimals. pays: Object.fromEntries( Object.entries(sym.pays ?? sym.scatterPays ?? {}).map(([k, v]) => [k, Number(((v * def.payScale) / (sym.pays ? def.betDivisor : 1)).toFixed(3))]), ), })), certification: cert, }; return { game: info, definition: clientDefinition(def) }; }); app.post("/api/games/:slug/launch", async (req) => { const { slug } = req.params as { slug: string }; const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } }); if (!g) throw errors.notFound(); await db.update(gameStatistics).set({ launches: sql`${gameStatistics.launches} + 1` }).where(eq(gameStatistics.gameId, g.id)); if (req.user) { // Persisted game state for meters/heat so the client can render them on load. const st = await db.execute(sql`select state from game_states where user_id = ${req.user.id} and game_id = ${g.id}`); return { ok: true, state: (st.rows[0] as { state?: unknown } | undefined)?.state ?? null }; } return { ok: true, state: null }; }); app.post("/api/games/:slug/spin", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const retry = await rateLimit(`spin:${user.id}`, 240, 60); if (retry) { await logSecurity(req, "spin.suspicious", { userId: user.id, severity: "warn", meta: { slug, ip: clientIp(req) } }); throw errors.rateLimited(retry); } const parsed = spinSchema.safeParse(req.body); if (!parsed.success) throw errors.badRequest("Invalid spin request.", parsed.error.flatten()); return spin({ userId: user.id, slug, bet: parsed.data.bet, clientRoundId: parsed.data.clientRoundId }); }); app.get("/api/games/:slug/history", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const q = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query); 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}`); return { entries: (rows.rows as Record[]).map((r) => ({ roundId: r.round_id, bet: Number(r.bet), win: Number(r.win), multiplier: Number(r.multiplier), balanceAfter: Number(r.balance_after), createdAt: (r.created_at as Date).toISOString(), features: r.features, })), }; }); app.post("/api/games/:slug/favorite", async (req) => { const user = requireUser(req); const { slug } = req.params as { slug: string }; const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } }); if (!g) throw errors.notFound(); const existing = await db.query.favorites.findFirst({ where: and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)) }); if (existing) { await db.delete(favorites).where(and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id))); await db.update(gameStatistics).set({ favorites: sql`greatest(${gameStatistics.favorites} - 1, 0)` }).where(eq(gameStatistics.gameId, g.id)); return { favorite: false }; } await db.insert(favorites).values({ userId: user.id, gameId: g.id }); await db.update(gameStatistics).set({ favorites: sql`${gameStatistics.favorites} + 1` }).where(eq(gameStatistics.gameId, g.id)); return { favorite: true }; }); app.get("/api/user/games", async (req) => { const user = requireUser(req); const rows = await db .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 }) .from(userGameStats) .innerJoin(games, eq(games.id, userGameStats.gameId)) .where(eq(userGameStats.userId, user.id)) .orderBy(desc(userGameStats.lastPlayedAt)); return { games: rows.map((r) => ({ ...r, biggestMultiplier: Number(r.biggestMultiplier), lastPlayedAt: r.lastPlayedAt.toISOString() })) }; }); } /** Definition data the renderer needs (symbols, grid, presentation). Weights are public "Game Info" data too. */ export function clientDefinition(def: ReturnType & object) { return { slug: def.slug, name: def.name, version: def.version, grid: def.grid, payModel: def.payModel, symbols: def.symbols.map((s) => ({ id: s.id, name: s.name, kind: s.kind, tier: s.tier, style: s.style })), 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, scatter: def.scatter ? { id: def.scatter.id, triggers: def.scatter.triggers } : null, cascades: !!def.cascades, spinCollect: def.spinCollect ? { symbolId: def.spinCollect.symbolId, ladder: def.spinCollect.ladder } : null, holdRespin: def.holdRespin ? { symbolId: def.holdRespin.symbolId, respins: def.holdRespin.respins, jackpots: def.holdRespin.jackpots } : null, 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, heat: def.heat ? { max: def.heat.max, ladder: def.heat.ladder, bandSize: def.heat.bandSize, label: def.heat.meltdown.label } : null, mystery: def.mystery ? { symbolId: def.mystery.symbolId } : null, quantum: !!def.quantum, dynamicGrid: def.dynamicGrid ?? null, jackpot: def.jackpot ? { tiers: def.jackpot.tiers.map((t) => ({ id: t.id, multiplier: t.multiplier })) } : null, pickBonuses: def.pickBonuses?.map((b) => ({ id: b.id, name: b.name, cells: b.cells, picks: b.picks })) ?? [], minBet: def.minBet, maxBet: def.maxBet, maxMultiplier: def.maxMultiplier, volatility: def.volatility, presentation: def.presentation, featureNames: def.featureNames, }; }