Files
healer-man/scripts/combat-effects/audit-rom-media.mjs
2026-09-24 18:15:45 -04:00

109 lines
7.3 KiB
JavaScript

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 manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const particleArchivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
const particleArchive = fs.existsSync(particleArchivePath)
? JSON.parse(fs.readFileSync(particleArchivePath, "utf8")) : null;
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8"));
const entries = new Map(manifest.entries.filter((entry) => entry.source === "runewaker").map((entry) => [entry.spellId, entry]));
const cohorts = [
["Priest", "rom-priest", "rom-knight"],
["Knight", "rom-knight", "rom-scout"],
["Scout", "rom-scout", "rom-rogue"],
["Rogue", "rom-rogue", "rom-warrior"],
["Mage", "rom-mage", "rom-warlock"],
];
const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"];
const phaseOrder = (phase) => {
const index = phases.indexOf(phase);
return index < 0 ? phases.length : index;
};
function idsForSkill(skill) {
return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])]
.filter((id) => Number.isInteger(id) && id > 0);
}
function skillMedia(skill) {
const ids = idsForSkill(skill);
const mapped = ids.map((id) => entries.get(id)).filter(Boolean);
const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) =>
(entry.phases?.[phase] ?? []).map((asset) => (
`${phase}: ${path.posix.basename(asset.model)}`
+ (asset.visualCompleteness === "partial" ? " (partial original)" : "")
)))))];
const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? [])
.filter((sound) => sound.url)
.map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))].sort((left, right) => (
phaseOrder(left.split(":", 1)[0]) - phaseOrder(right.split(":", 1)[0])
));
const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat())
.filter((asset) => asset.effectGraphUrl).length;
for (const entry of mapped) {
for (const sound of entry.sounds ?? []) {
if (!sound.url || !fs.existsSync(path.join(projectRoot, "public", sound.url))) {
throw new Error(`Missing packaged RuneWaker sound ${entry.key}: ${sound.sourcePath}`);
}
}
}
return { skill, ids, models, sounds, playableVisuals };
}
function renderTable(rows) {
const result = [
"| Skill | Source spell IDs | Effect phase and source model | Sound phase and source cue |",
"| --- | --- | --- | --- |",
];
for (const row of rows) {
const clean = (value) => value.replaceAll("|", "\\|");
result.push(`| ${clean(row.skill.name)} | ${row.ids.join(", ")} | ${row.models.map(clean).join("<br>") || "—"} | ${row.sounds.map(clean).join("<br>") || "—"} |`);
}
return result.join("\n");
}
const sections = [];
const summary = [];
for (const [label, classId, secondaryClassId] of cohorts) {
for (const [kind, secondary] of [["Base", null], ["Accepted elite pair", secondaryClassId]]) {
const rows = catalog.skills.filter((skill) => (
skill.classId === classId && (skill.secondaryClassId ?? null) === secondary && !skill.passive
)).map(skillMedia);
const visual = rows.filter((row) => row.models.length).length;
const audio = rows.filter((row) => row.sounds.length).length;
const ready = rows.filter((row) => row.playableVisuals > 0).length;
summary.push({ label, kind, skills: rows.length, sourceVisuals: visual, packagedSounds: audio, playableVisuals: ready });
sections.push(`## ${label} — ${kind.toLowerCase()}\n\n${renderTable(rows)}`);
}
}
const lines = [
"# RuneWaker primary-five spell media mapping",
"",
"Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.",
"",
`The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. A \`partial original\` phase renders verified source layers and declares its remaining fidelity limit. The sound column lists packaged client cues on their mapped phase. A dash means the source record or matching sound file did not provide that media.`,
"",
"| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |",
"| --- | --- | ---: | ---: | ---: | ---: |",
...summary.map((row) => `| ${row.label} | ${row.kind} | ${row.skills} | ${row.sourceVisuals} | ${row.packagedSounds} | ${row.playableVisuals} |`),
"",
...sections.flatMap((section) => [section, ""]),
"## Decoded particle archive",
"",
particleArchive
? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready and ${particleArchive.results.filter((result) => result.partialRendererReady).length} has a browser-playable partial original. The Priest holy cast's remaining EventTrigger is an audio callback; Urgent Heal's target renders its source sprites, emitted rune mesh, and animated ground decal. Its terrain-triangle decal projection remains approximated by a level plane. The archive exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.`
: "The RuneWaker particle archive has not been packaged.",
"",
"## Next conversion step",
"",
"The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting; Urgent Heal's target impact with its source rune mesh and decal; Rising Tide's cast, projectile release, and target impact; Knight Charge cast and Whirlwind Shield impact; and Mage Electric Bolt projectile. Urgent Heal's holy cast EventTrigger starts a looping 3D WAV, and its target impact triggers the source benedictory heal MP3. Rising Tide retains its packaged cast WAV and now plays its ROS-linked projectile release and target impact MP3s. Impact cues use the target position. Urgent Heal's decal uses a level plane in place of the client's terrain projection. The Direct3D source and packaged ROS data remain the reference for subsequent phases.",
];
const markdown = `${lines.join("\n").trimEnd()}\n`;
if (process.argv.includes("--write")) {
fs.writeFileSync(path.join(projectRoot, "docs/ROM_SPELL_MEDIA_MAPPING.md"), markdown);
}
console.log(JSON.stringify(summary));