forked from phenom/healer-man
182 lines
10 KiB
JavaScript
182 lines
10 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 publicRoot = path.join(projectRoot, "public");
|
|
const archiveRoot = path.join(publicRoot, "assets/game/combat-effects/original/runewaker");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(publicRoot, "assets/game/combat-effects/manifest.json"), "utf8"));
|
|
const archive = JSON.parse(fs.readFileSync(path.join(archiveRoot, "manifest.json"), "utf8"));
|
|
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8"));
|
|
const write = process.argv.includes("--write");
|
|
if (process.argv.some((argument, index) => index > 1 && argument !== "--write")) {
|
|
throw new Error("Usage: node scripts/combat-effects/plan-rom-media.mjs [--write]");
|
|
}
|
|
|
|
const cohorts = [
|
|
["rom-priest", null], ["rom-priest", "rom-knight"],
|
|
["rom-knight", null], ["rom-knight", "rom-scout"],
|
|
["rom-scout", null], ["rom-scout", "rom-rogue"],
|
|
["rom-rogue", null], ["rom-rogue", "rom-warrior"],
|
|
["rom-mage", null], ["rom-mage", "rom-warlock"],
|
|
];
|
|
const spellIds = new Set(catalog.skills.filter((skill) => !skill.passive && cohorts.some(
|
|
([primary, secondary]) => skill.classId === primary && (skill.secondaryClassId ?? null) === secondary,
|
|
)).flatMap((skill) => [skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)]));
|
|
const entries = manifest.entries.filter((entry) => entry.source === "runewaker" && spellIds.has(entry.spellId));
|
|
const entriesBySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
|
|
const usages = new Map();
|
|
for (const entry of entries) for (const [phase, assets] of Object.entries(entry.phases ?? {})) {
|
|
for (const asset of assets) {
|
|
if (!asset.model) continue;
|
|
const key = asset.model.toLowerCase();
|
|
const list = usages.get(key) ?? [];
|
|
list.push({ spellId: entry.spellId, phase });
|
|
usages.set(key, list);
|
|
}
|
|
}
|
|
|
|
function graphFor(result) {
|
|
if (!result.graphUrl) return null;
|
|
const graphPath = path.resolve(publicRoot, result.graphUrl.replace(/^\/+/, ""));
|
|
const graphsRoot = path.join(archiveRoot, "graphs") + path.sep;
|
|
if (!graphPath.toLowerCase().startsWith(graphsRoot.toLowerCase())) {
|
|
throw new Error(`RuneWaker graph path escapes archive: ${result.graphUrl}`);
|
|
}
|
|
return JSON.parse(fs.readFileSync(graphPath, "utf8"));
|
|
}
|
|
|
|
function eventSoundPath(value) {
|
|
const normalized = String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
|
|
return /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(normalized) ? normalized : null;
|
|
}
|
|
|
|
const results = archive.results.map((result) => {
|
|
const model = result.model.toLowerCase();
|
|
const graph = graphFor(result);
|
|
const references = [...new Map((usages.get(model) ?? []).map((usage) =>
|
|
[`${usage.spellId}:${usage.phase}`, usage])).values()];
|
|
const blockers = [...new Set(result.unsupported ?? [])].sort();
|
|
const errors = [...new Set(result.errors ?? [])];
|
|
const state = result.rendererReady ? "ready"
|
|
: result.partialRendererReady ? "partial-original"
|
|
: result.status === "decoded" && errors.length === 0 ? "clean-unverified"
|
|
: result.status === "failed" ? "failed" : "blocked";
|
|
const directAudioEvents = [...new Map((graph?.motions ?? []).flatMap((motion) =>
|
|
(motion.controllers ?? []).filter((controller) => (
|
|
controller.sourceType === "CRuController_EventTrigger"
|
|
&& controller.eventID === 43 && eventSoundPath(controller.eventString)
|
|
)).map((controller) => {
|
|
const sourcePath = eventSoundPath(controller.eventString);
|
|
return [`${motion.name}:${sourcePath}:${controller.eventParameter0}:${controller.eventParameter1}`,
|
|
{ motion: motion.name, sourcePath, loop: controller.eventParameter0 === 1,
|
|
parameter1: controller.eventParameter1 }];
|
|
}))).values()];
|
|
const missingEventCues = directAudioEvents.flatMap((event) => {
|
|
if (event.loop || event.parameter1 !== 0) return [];
|
|
const missing = references.filter((reference) => {
|
|
const entry = entriesBySpellId.get(reference.spellId);
|
|
return !entry?.sounds?.some((sound) => sound.phase === reference.phase
|
|
&& sound.sourcePath?.toLowerCase() === event.sourcePath && sound.url);
|
|
});
|
|
return missing.length ? [{ ...event, references: missing }] : [];
|
|
});
|
|
return {
|
|
model: result.model,
|
|
state,
|
|
graphUrl: result.graphUrl,
|
|
spriteControllers: result.spriteControllers,
|
|
textures: result.textures,
|
|
blockers,
|
|
errors,
|
|
references,
|
|
directAudioEvents,
|
|
missingEventCues,
|
|
};
|
|
});
|
|
|
|
const stateCounts = Object.fromEntries([
|
|
"ready", "partial-original", "clean-unverified", "blocked", "failed",
|
|
].map((state) => [state, results.filter((result) => result.state === state).length]));
|
|
const blockerFamilies = [...new Set(results.flatMap((result) => result.blockers))].map((controller) => {
|
|
const models = results.filter((result) => result.blockers.includes(controller));
|
|
return { controller, models: models.length,
|
|
spellIds: new Set(models.flatMap((model) => model.references.map((reference) => reference.spellId))).size };
|
|
}).sort((left, right) => right.models - left.models || left.controller.localeCompare(right.controller));
|
|
const signatureGroups = [...new Set(results.filter((result) => result.state === "blocked")
|
|
.map((result) => result.blockers.join(" + ") || "decode errors"))].map((signature) => {
|
|
const models = results.filter((result) => result.state === "blocked"
|
|
&& (result.blockers.join(" + ") || "decode errors") === signature);
|
|
return { signature, models: models.length,
|
|
spellIds: new Set(models.flatMap((model) => model.references.map((reference) => reference.spellId))).size,
|
|
example: models[0]?.model };
|
|
}).sort((left, right) => right.models - left.models || left.signature.localeCompare(right.signature));
|
|
const summary = {
|
|
models: results.length,
|
|
...stateCounts,
|
|
cohortSpellEntries: entries.length,
|
|
entriesWithPackagedAudio: entries.filter((entry) => entry.sounds?.some((sound) => sound.url)).length,
|
|
eventCueCandidates: results.reduce((sum, result) => sum + result.missingEventCues.length, 0),
|
|
};
|
|
const report = { schemaVersion: 1, scope: archive.scope, summary, blockerFamilies, signatureGroups, results };
|
|
|
|
const clean = results.filter((result) => result.state === "clean-unverified");
|
|
const eventOnly = results.filter((result) => result.state === "blocked"
|
|
&& result.spriteControllers > 0 && result.blockers.length === 1
|
|
&& result.blockers[0] === "CRuController_EventTrigger");
|
|
const countRefs = (result) => result.references.length;
|
|
const priority = (left, right) => countRefs(right) - countRefs(left) || left.model.localeCompare(right.model);
|
|
const row = (result) => `| ${result.model} | ${result.references.length} | ${result.spriteControllers} | ${result.blockers.join(", ") || "—"} |`;
|
|
const lines = [
|
|
"# RuneWaker media batch plan",
|
|
"",
|
|
"Generated by `npm run effects:rom:plan` from the current primary-five spell manifest and decoded ROS archive. It groups remaining controller and timing work. The manifest generator already attaches source direct audio events at supported phase boundaries.",
|
|
"",
|
|
`The archive has ${summary.models} unique models: ${summary.ready} ready, ${summary["partial-original"]} partial original, ${summary["clean-unverified"]} fully decoded but unverified, ${summary.blocked} blocked, and ${summary.failed} failed. ${summary.entriesWithPackagedAudio} of ${summary.cohortSpellEntries} source spell entries have at least one packaged audio cue.`,
|
|
"",
|
|
"## Shared controller blockers",
|
|
"",
|
|
"Counts overlap when a model has several controller families. A family should be ported and verified once against representative models before its other users are considered for promotion.",
|
|
"",
|
|
"| Controller family | Models | Source spell IDs using those models |",
|
|
"| --- | ---: | ---: |",
|
|
...blockerFamilies.map((family) => `| ${family.controller} | ${family.models} | ${family.spellIds} |`),
|
|
"",
|
|
"## Fully decoded models awaiting visual review",
|
|
"",
|
|
"| Source model | Spell-phase references | Sprite layers | Blockers |",
|
|
"| --- | ---: | ---: | --- |",
|
|
...clean.sort(priority).map(row),
|
|
"",
|
|
"## Event-only models still blocked",
|
|
"",
|
|
"These models have sprite data but include an event callback outside the validated visual-only set. Effect-spawning and projectile-flight callbacks require browser behavior before their visuals can be promoted.",
|
|
"",
|
|
"| Source model | Spell-phase references | Sprite layers | Blockers |",
|
|
"| --- | ---: | ---: | --- |",
|
|
...eventOnly.sort(priority).slice(0, 20).map(row),
|
|
"",
|
|
`There are ${eventOnly.length} event-only models. The archive also exposes ${summary.eventCueCandidates} model/phase audio-event candidates missing from the current packaged manifest. They need delayed timing or a later lifecycle phase; an ROS motion name alone is insufficient. Full per-model evidence is in \`docs/ROM_MEDIA_BATCH_PLAN.json\`.`,
|
|
"",
|
|
"## Largest blocker combinations",
|
|
"",
|
|
"| Controller signature | Models | Source spell IDs | Example model |",
|
|
"| --- | ---: | ---: | --- |",
|
|
...signatureGroups.slice(0, 15).map((group) =>
|
|
`| ${group.signature.replaceAll("|", "\\|")} | ${group.models} | ${group.spellIds} | ${group.example} |`),
|
|
"",
|
|
"## Batch workflow",
|
|
"",
|
|
"1. Run `npm run effects:rom:batch` to refresh source links, package audio and ROS graphs, attach verified phases, and rebuild this plan.",
|
|
"2. Review the remaining event timing candidates and largest shared blocker families above.",
|
|
"3. Port one controller family in the exporter/WebGL runtime and add a parser check that every promoted graph is renderable.",
|
|
"4. Regenerate the manifest and media audit, replay representative graphs in the RuneWaker preview, then spot-check distinct motion and attachment patterns in live combat.",
|
|
];
|
|
|
|
if (write) {
|
|
const docs = path.join(projectRoot, "docs");
|
|
fs.writeFileSync(path.join(docs, "ROM_MEDIA_BATCH_PLAN.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(docs, "ROM_MEDIA_BATCH_PLAN.md"), `${lines.join("\n").trimEnd()}\n`);
|
|
}
|
|
console.log(JSON.stringify(summary));
|