1import fs from "node:fs";2import path from "node:path";3import { createHash } from "node:crypto";4import { createRequire } from "node:module";5import { GAMES, FEATURED_ORDER } from "@spinza/games";6import { validateDefinition, type CertificationReport, type GameDefinition } from "@spinza/game-core";7import { eq, sql } from "drizzle-orm";8import type { Db } from "./index";9import { gameStatistics, gameVersions, games } from "./schema";1011const require = createRequire(import.meta.url);1213/** Locate games/certifications on disk (works from source and from the deployed tree). */14export function certificationsDir(): string {15 const pkg = path.dirname(require.resolve("@spinza/games/package.json"));16 return path.join(pkg, "certifications");17}1819export function loadCertification(slug: string): CertificationReport | null {20 try {21 return JSON.parse(fs.readFileSync(path.join(certificationsDir(), `${slug}.json`), "utf8"));22 } catch {23 return null;24 }25}2627export function publicSummary(def: GameDefinition) {28 return {29 tagline: def.tagline,30 description: def.description,31 theme: def.theme,32 volatility: def.volatility,33 grid: def.grid,34 minBet: def.minBet,35 maxBet: def.maxBet,36 maxMultiplier: def.maxMultiplier,37 features: def.featureNames,38 tags: def.tags,39 palette: def.presentation.palette,40 presentation: def.presentation,41 isJackpot: !!(def.jackpot || def.holdRespin),42 rtp: def.rtp,43 };44}4546export function definitionHash(def: GameDefinition): string {47 return createHash("sha256").update(JSON.stringify(def)).digest("hex");48}4950export interface SyncReport {51 slug: string;52 version: string;53 lifecycle: string;54 certified: boolean;55}5657/**58 * Upsert every game of the library. Lifecycle rules:59 * - a game with a PASS certification for its exact version becomes `published`60 * (unless an admin has set it to `disabled`);61 * - otherwise it stays `simulation` (visible only in admin).62 */63export async function syncGames(db: Db, log: (s: string) => void = () => {}): Promise<SyncReport[]> {64 const reports: SyncReport[] = [];65 let order = 0;66 for (const def of GAMES) {67 order++;68 const errors = validateDefinition(def).filter((i) => i.level === "error");69 const cert = loadCertification(def.slug);70 const certified = !!cert && cert.status === "PASS" && cert.version === def.version && errors.length === 0;71 const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });72 let lifecycle = certified ? "published" : "simulation";73 if (existing?.lifecycle === "disabled") lifecycle = "disabled";74 const featuredIdx = FEATURED_ORDER.indexOf(def.slug);75 const summary = { ...publicSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null };76 const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true;77 const [row] = await db78 .insert(games)79 .values({80 slug: def.slug,81 name: def.name,82 version: def.version,83 lifecycle,84 sortOrder: order,85 isFeatured: featuredIdx >= 0,86 isNew,87 summary,88 publishedAt: certified ? new Date() : null,89 })90 .onConflictDoUpdate({91 target: games.slug,92 set: {93 name: def.name,94 version: def.version,95 lifecycle,96 sortOrder: order,97 isFeatured: featuredIdx >= 0,98 isNew,99 summary,100 publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt,101 updatedAt: new Date(),102 },103 })104 .returning();105 await db106 .insert(gameVersions)107 .values({108 gameId: row.id,109 version: def.version,110 definition: def as unknown as Record<string, unknown>,111 definitionHash: definitionHash(def),112 certification: (cert as unknown as Record<string, unknown>) ?? null,113 status: certified ? "published" : "simulation",114 })115 .onConflictDoUpdate({116 target: [gameVersions.gameId, gameVersions.version],117 set: { definition: def as unknown as Record<string, unknown>, definitionHash: definitionHash(def), certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" },118 });119 await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing();120 reports.push({ slug: def.slug, version: def.version, lifecycle, certified });121 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} → ${lifecycle}`);122 }123 return reports;124}125