import fs from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { GAMES, FEATURED_ORDER, CRASH_GAMES, ARCADE_GAMES } from "@spinza/games"; import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition, type ArcadeGameDefinition } from "@spinza/game-core"; import { eq, sql } from "drizzle-orm"; import type { Db } from "./index"; import { gameStatistics, gameVersions, games } from "./schema"; const require = createRequire(import.meta.url); /** Locate games/certifications on disk (works from source and from the deployed tree). */ export function certificationsDir(): string { const pkg = path.dirname(require.resolve("@spinza/games/package.json")); return path.join(pkg, "certifications"); } export function loadCertification(slug: string): CertificationReport | null { try { return JSON.parse(fs.readFileSync(path.join(certificationsDir(), `${slug}.json`), "utf8")); } catch { return null; } } export function crashSummary(def: CrashGameDefinition) { return { kind: "crash", category: "risk", tagline: def.tagline, description: def.description, theme: def.theme, volatility: def.volatility, grid: { reels: 0, rows: 0 }, minBet: def.minBet, maxBet: def.maxBet, maxMultiplier: def.maxMultiplier, features: def.featureNames, tags: def.tags, palette: def.presentation.palette, presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb }, isJackpot: false, rtp: def.rtp, }; } export function arcadeSummary(def: ArcadeGameDefinition) { return { kind: "arcade", category: "originals", mode: def.mode, tagline: def.tagline, description: def.description, theme: def.theme, volatility: def.volatility, grid: { reels: 0, rows: 0 }, minBet: def.minBet, maxBet: def.maxBet, maxMultiplier: def.maxMultiplier, features: def.featureNames, tags: def.tags, palette: def.presentation.palette, presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb }, isJackpot: def.slug === "the-vault", rtp: def.rtp, }; } export function publicSummary(def: GameDefinition) { return { kind: "slot", category: "slots", tagline: def.tagline, description: def.description, theme: def.theme, volatility: def.volatility, grid: def.grid, minBet: def.minBet, maxBet: def.maxBet, maxMultiplier: def.maxMultiplier, features: def.featureNames, tags: def.tags, palette: def.presentation.palette, presentation: def.presentation, isJackpot: !!(def.jackpot || def.holdRespin), rtp: def.rtp, }; } export function definitionHash(def: GameDefinition): string { return createHash("sha256").update(JSON.stringify(def)).digest("hex"); } export interface SyncReport { slug: string; version: string; lifecycle: string; certified: boolean; } /** * Upsert every game of the library. Lifecycle rules: * - a game with a PASS certification for its exact version becomes `published` * (unless an admin has set it to `disabled`); * - otherwise it stays `simulation` (visible only in admin). */ export async function syncGames(db: Db, log: (s: string) => void = () => {}): Promise { const reports: SyncReport[] = []; let order = 0; for (const def of GAMES) { order++; const errors = validateDefinition(def).filter((i) => i.level === "error"); const cert = loadCertification(def.slug); const certified = !!cert && cert.status === "PASS" && cert.version === def.version && errors.length === 0; const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) }); let lifecycle = certified ? "published" : "simulation"; if (existing?.lifecycle === "disabled") lifecycle = "disabled"; const featuredIdx = FEATURED_ORDER.indexOf(def.slug); const summary = { ...publicSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null }; const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true; const [row] = await db .insert(games) .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: featuredIdx >= 0, isNew, summary, publishedAt: certified ? new Date() : null, }) .onConflictDoUpdate({ target: games.slug, set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: featuredIdx >= 0, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date(), }, }) .returning(); await db .insert(gameVersions) .values({ gameId: row.id, version: def.version, definition: def as unknown as Record, definitionHash: definitionHash(def), certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation", }) .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record, definitionHash: definitionHash(def), certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation" }, }); await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing(); reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} → ${lifecycle}`); } for (const def of CRASH_GAMES) { order++; const cert = loadCertification(def.slug); const certified = !!cert && cert.status === "PASS" && cert.version === def.version; const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) }); let lifecycle = certified ? "published" : "simulation"; if (existing?.lifecycle === "disabled") lifecycle = "disabled"; const summary = { ...crashSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null }; const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true; const [row] = await db .insert(games) .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null }) .onConflictDoUpdate({ target: games.slug, set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() }, }) .returning(); await db .insert(gameVersions) .values({ gameId: row.id, version: def.version, definition: def as unknown as Record, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation" }) .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record, certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation" } }); await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing(); reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (crash) → ${lifecycle}`); } for (const def of ARCADE_GAMES) { order++; const cert = loadCertification(def.slug); const certified = !!cert && cert.status === "PASS" && cert.version === def.version; const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) }); let lifecycle = certified ? "published" : "simulation"; if (existing?.lifecycle === "disabled") lifecycle = "disabled"; const summary = { ...arcadeSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null }; const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true; const [row] = await db .insert(games) .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null }) .onConflictDoUpdate({ target: games.slug, set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() } }) .returning(); await db .insert(gameVersions) .values({ gameId: row.id, version: def.version, definition: def as unknown as Record, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation" }) .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record, certification: (cert as unknown as Record) ?? null, status: certified ? "published" : "simulation" } }); await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing(); reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (arcade) → ${lifecycle}`); } return reports; }