#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const catalog = JSON.parse(fs.readFileSync( path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json"), "utf8", )); const manifest = JSON.parse(fs.readFileSync( path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8", )); const highConfidenceEffectName = /\b(?:dominat\w*|polymorph|hex|silence|slow|hamstring|frost nova|chill nova|summon|raise dead|ward|frost armor|drain life|death coil|vampiric touch|leech poison|pierce armor|sunder armor|dark shell|shock barrier|damage immunity)\b/i; const auxiliarySpellIds = new Set([21_708, 47_669, 54_888]); const manifestBySpellId = new Map(); for (const entry of manifest.entries ?? []) manifestBySpellId.set(Number(entry.spellId), entry); const bossCasts = []; for (const dungeon of catalog.definitions ?? []) { for (const entity of Object.values(dungeon.entities ?? {})) { if (entity.kind !== "boss") continue; for (const attack of entity.combat?.attacks ?? []) { if (!attack.spellId) continue; bossCasts.push({ dungeonId: dungeon.id, boss: entity.name, ...attack }); } } } const effectCasts = bossCasts.filter((attack) => attack.effect); const suspicious = bossCasts.filter((attack) => ( !attack.effect && highConfidenceEffectName.test(attack.name) )); const auxiliaryLeaks = bossCasts.filter((attack) => auxiliarySpellIds.has(attack.spellId)); const invalidComposites = effectCasts.filter((attack) => ( attack.effect.kind === "composite" && !attack.effect.effects?.length )); const atomicEffects = (attack) => attack.effect?.kind === "composite" ? attack.effect.effects : attack.effect ? [attack.effect] : []; const harmful = (attack) => atomicEffects(attack).some((effect) => ( ["damage", "leech", "target-aura", "control", "pull"].includes(effect.kind) )); const inferredGroundTelegraph = (attack) => harmful(attack) && ( attack.delivery === "area" || attack.radius !== undefined || /\b(?:breath|cleave|frontal|sweep|cone|beam|charge|line|lance)\b/i.test(attack.name) ); const presentation = effectCasts.map((attack) => { const entry = manifestBySpellId.get(attack.spellId); const originalVisual = Boolean(entry && Object.values(entry.phases ?? {}).some( (assets) => Array.isArray(assets) && assets.length > 0, )); const originalAudio = Boolean(entry?.sounds?.some((sound) => sound.url)); return { ...attack, originalVisual, originalAudio }; }); const issues = [...suspicious, ...auxiliaryLeaks, ...invalidComposites].map((attack) => ( `${attack.dungeonId}:${attack.boss}:${attack.spellId}:${attack.name}` )); const report = { status: issues.length ? "failed" : "complete", bosses: new Set(bossCasts.map((attack) => `${attack.dungeonId}:${attack.boss}`)).size, spellCasts: bossCasts.length, explicitEffectCasts: effectCasts.length, presentation: { originalVisual: presentation.filter((attack) => attack.originalVisual).length, generatedVisualFallback: presentation.filter((attack) => !attack.originalVisual).length, originalAudio: presentation.filter((attack) => attack.originalAudio).length, generatedAudioFallback: presentation.filter((attack) => !attack.originalAudio).length, }, indicators: { castBars: effectCasts.filter((attack) => ( (attack.castTimeMs ?? 0) > 0 || attack.animation === "cast" || inferredGroundTelegraph(attack) )).length, groundTelegraphs: effectCasts.filter((attack) => ( attack.telegraph || inferredGroundTelegraph(attack) )).length, releaseOrImpactCues: effectCasts.length, auraOrControlHud: effectCasts.filter((attack) => { return atomicEffects(attack).some((effect) => ( ["aura", "target-aura", "control"].includes(effect.kind) )); }).length, }, issues, }; console.log(JSON.stringify(report, null, 2)); if (issues.length) process.exitCode = 1;