Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 18:46:09 -04:00
1 parent a868401ebc
commit 48a678dcc5
295 files changed
+7644 -467

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
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));