import fs from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { GAMES, FEATURED_ORDER } from "@spinza/games"; import { validateDefinition, type CertificationReport, type GameDefinition } 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 publicSummary(def: GameDefinition) { return { 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}`); } return reports; }