Files
healer-man/scripts/dungeon-pipeline/audit-boss-spell-effects.mjs
T
2026-09-15 14:43:15 -04:00

95 lines
4.0 KiB
JavaScript

#!/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;