SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
10.2 KB · 217 lines typescript
Raw Blame History
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