TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import fs from "node:fs";2import path from "node:path";3import { createHash } from "node:crypto";4import { createRequire } from "node:module";5import { GAMES, FEATURED_ORDER, CRASH_GAMES, ARCADE_GAMES } from "@spinza/games";6import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition, type ArcadeGameDefinition } 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 crashSummary(def: CrashGameDefinition) {28 return {29 kind: "crash",30 category: "risk",31 tagline: def.tagline,32 description: def.description,33 theme: def.theme,34 volatility: def.volatility,35 grid: { reels: 0, rows: 0 },36 minBet: def.minBet,37 maxBet: def.maxBet,38 maxMultiplier: def.maxMultiplier,39 features: def.featureNames,40 tags: def.tags,41 palette: def.presentation.palette,42 presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb },43 isJackpot: false,44 rtp: def.rtp,45 };46}4748export function arcadeSummary(def: ArcadeGameDefinition) {49 return {50 kind: "arcade",51 category: "originals",52 mode: def.mode,53 tagline: def.tagline,54 description: def.description,55 theme: def.theme,56 volatility: def.volatility,57 grid: { reels: 0, rows: 0 },58 minBet: def.minBet,59 maxBet: def.maxBet,60 maxMultiplier: def.maxMultiplier,61 features: def.featureNames,62 tags: def.tags,63 palette: def.presentation.palette,64 presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb },65 isJackpot: def.slug === "the-vault",66 rtp: def.rtp,67 };68}6970export function publicSummary(def: GameDefinition) {71 return {72 kind: "slot",73 category: "slots",74 tagline: def.tagline,75 description: def.description,76 theme: def.theme,77 volatility: def.volatility,78 grid: def.grid,79 minBet: def.minBet,80 maxBet: def.maxBet,81 maxMultiplier: def.maxMultiplier,82 features: def.featureNames,83 tags: def.tags,84 palette: def.presentation.palette,85 presentation: def.presentation,86 isJackpot: !!(def.jackpot || def.holdRespin),87 rtp: def.rtp,88 };89}9091export function definitionHash(def: GameDefinition): string {92 return createHash("sha256").update(JSON.stringify(def)).digest("hex");93}9495export interface SyncReport {96 slug: string;97 version: string;98 lifecycle: string;99 certified: boolean;100}101102/**103 * Upsert every game of the library. Lifecycle rules:104 * - a game with a PASS certification for its exact version becomes `published`105 * (unless an admin has set it to `disabled`);106 * - otherwise it stays `simulation` (visible only in admin).107 */108export async function syncGames(db: Db, log: (s: string) => void = () => {}): Promise<SyncReport[]> {109 const reports: SyncReport[] = [];110 let order = 0;111 for (const def of GAMES) {112 order++;113 const errors = validateDefinition(def).filter((i) => i.level === "error");114 const cert = loadCertification(def.slug);115 const certified = !!cert && cert.status === "PASS" && cert.version === def.version && errors.length === 0;116 const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });117 let lifecycle = certified ? "published" : "simulation";118 if (existing?.lifecycle === "disabled") lifecycle = "disabled";119 const featuredIdx = FEATURED_ORDER.indexOf(def.slug);120 const summary = { ...publicSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null };121 const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true;122 const [row] = await db123 .insert(games)124 .values({125 slug: def.slug,126 name: def.name,127 version: def.version,128 lifecycle,129 sortOrder: order,130 isFeatured: featuredIdx >= 0,131 isNew,132 summary,133 publishedAt: certified ? new Date() : null,134 })135 .onConflictDoUpdate({136 target: games.slug,137 set: {138 name: def.name,139 version: def.version,140 lifecycle,141 sortOrder: order,142 isFeatured: featuredIdx >= 0,143 isNew,144 summary,145 publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt,146 updatedAt: new Date(),147 },148 })149 .returning();150 await db151 .insert(gameVersions)152 .values({153 gameId: row.id,154 version: def.version,155 definition: def as unknown as Record<string, unknown>,156 definitionHash: definitionHash(def),157 certification: (cert as unknown as Record<string, unknown>) ?? null,158 status: certified ? "published" : "simulation",159 })160 .onConflictDoUpdate({161 target: [gameVersions.gameId, gameVersions.version],162 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" },163 });164 await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing();165 reports.push({ slug: def.slug, version: def.version, lifecycle, certified });166 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} → ${lifecycle}`);167 }168 for (const def of CRASH_GAMES) {169 order++;170 const cert = loadCertification(def.slug);171 const certified = !!cert && cert.status === "PASS" && cert.version === def.version;172 const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });173 let lifecycle = certified ? "published" : "simulation";174 if (existing?.lifecycle === "disabled") lifecycle = "disabled";175 const summary = { ...crashSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null };176 const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true;177 const [row] = await db178 .insert(games)179 .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null })180 .onConflictDoUpdate({181 target: games.slug,182 set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() },183 })184 .returning();185 await db186 .insert(gameVersions)187 .values({ gameId: row.id, version: def.version, definition: def as unknown as Record<string, unknown>, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" })188 .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record<string, unknown>, certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" } });189 await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing();190 reports.push({ slug: def.slug, version: def.version, lifecycle, certified });191 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (crash) → ${lifecycle}`);192 }193 for (const def of ARCADE_GAMES) {194 order++;195 const cert = loadCertification(def.slug);196 const certified = !!cert && cert.status === "PASS" && cert.version === def.version;197 const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });198 let lifecycle = certified ? "published" : "simulation";199 if (existing?.lifecycle === "disabled") lifecycle = "disabled";200 const summary = { ...arcadeSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null };201 const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true;202 const [row] = await db203 .insert(games)204 .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null })205 .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() } })206 .returning();207 await db208 .insert(gameVersions)209 .values({ gameId: row.id, version: def.version, definition: def as unknown as Record<string, unknown>, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" })210 .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record<string, unknown>, certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" } });211 await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing();212 reports.push({ slug: def.slug, version: def.version, lifecycle, certified });213 log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (arcade) → ${lifecycle}`);214 }215 return reports;216}217