Files
healer-man/scripts/runewaker-pipeline/import-boss-mechanics.mjs
T
2026-10-04 13:13:35 -04:00

526 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
optionValue,
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const mechanicCatalogFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const spellCatalogFile = path.join(
pipelineDirectory,
"snapshots",
"runewaker-npc-spell-catalog.json",
);
const requestedRecipe = optionValue(process.argv.slice(2), "--recipe");
const recipeNames = requestedRecipe
? [path.basename(requestedRecipe)]
: (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
function slugify(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
}
function cleanLua(source) {
return source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
}
function functionIndex(luaSources) {
const functions = new Map();
for (const source of luaSources) {
const cleaned = cleanLua(source.text);
const matches = [...cleaned.matchAll(/^\s*function\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/gm)];
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
const next = matches[index + 1];
functions.set(match[1], {
file: source.file,
body: cleaned.slice(match.index, next?.index ?? cleaned.length),
});
}
}
return functions;
}
function rootName(value) {
return String(value ?? "").trim().match(/^([A-Za-z_][A-Za-z0-9_:.]*)/)?.[1] ?? null;
}
function reachableFunctions(index, roots) {
const queue = roots.filter(Boolean);
const visited = new Set();
const result = [];
while (queue.length) {
const name = queue.shift();
if (!name || visited.has(name)) continue;
visited.add(name);
const definition = index.get(name);
if (!definition) continue;
result.push({ name, ...definition });
for (const match of definition.body.matchAll(/\b([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
for (const callbackCall of definition.body.matchAll(/\b(?:BeginPlot|SetPlot)\s*\([^\n]*/g)) {
for (const match of callbackCall[0].matchAll(/["']([A-Za-z_][A-Za-z0-9_:.]*)["']/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
}
}
return result;
}
function telegraphFor(spell, delivery, target, range) {
const name = spell.name ?? "";
const effectRadius = Math.max(3, Math.min(15, Number(spell.source?.effectRange ?? 0) * 0.1));
if (/\b(?:breath|cleave|frontal|sweep|fan)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1800,
length: Math.max(6, range),
angleDegrees: /breath/i.test(name) ? 70 : 95,
},
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "line",
anchor: "source",
warningMs: 1800,
length: Math.max(7, range),
width: Math.max(2, effectRadius * 0.45),
},
};
}
const area = Number(spell.source?.rangeType ?? 0) !== 0
|| Number(spell.source?.effectRange ?? 0) >= 30
|| /\b(?:area|group|storm|rain|nova|vortex|wave|burst|explosion|whirl|spin|quake|shock)\b/i.test(name);
if (!area) return { delivery, target, radius: undefined, telegraph: undefined };
const targetAnchored = /\b(?:rain|meteor|eruption|ground|barrage)\b/i.test(name)
&& Number(spell.source?.targetType ?? 0) !== 0;
return {
delivery: "area",
target: "nearby-party",
radius: effectRadius,
telegraph: {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1800,
radius: effectRadius,
},
};
}
function damageMechanic(entityId, spell) {
const runtime = spell.runtime;
const spatial = telegraphFor(spell, runtime.delivery, runtime.target, runtime.range);
const castTimeMs = Math.max(
spatial.telegraph?.warningMs ?? 0,
Math.round(Math.max(0, Number(spell.source?.spellTimeSeconds ?? 0)) * 1000),
);
return {
id: entityId + "-spell-" + spell.spellId,
name: spell.name,
spellId: spell.spellId,
delivery: spatial.delivery,
target: spatial.target,
school: runtime.school,
damageOrigin: "runewaker",
attackKind: runtime.attackKind,
runewakerEffectType: runtime.runewakerEffectType,
animation: runtime.animation,
castTimeMs,
range: Math.max(runtime.range, spatial.radius ?? 0),
cooldownMs: runtime.cooldownMs,
initialCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
repeatCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
damageMultiplier: runtime.damageMultiplier,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
};
}
function controlMechanic(entityId, spell) {
const identity = [spell.name, ...(spell.components ?? []).map((component) => component.name)]
.filter(Boolean)
.join(" ");
const mechanic = /fear|terr(?:or|if)/i.test(identity)
? "fear"
: /sleep/i.test(identity)
? "sleep"
: /root|catch|prison|immobili/i.test(identity)
? "root"
: /silence/i.test(identity)
? "silence"
: /stun|paraly/i.test(identity)
? "stun"
: null;
if (!mechanic) return null;
const cooldownMs = Math.max(4_000, Math.min(
25_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-control-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "shadow",
animation: "cast",
castTimeMs: Math.max(1800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: { kind: "control", mechanic, durationMs: 3_000 },
};
}
function auraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? -1) !== 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent && /^RuneWaker Spell /i.test(spell.name ?? "")) return null;
const cooldownMs = Math.max(6_000, Math.min(
45_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 15_000,
));
return {
id: entityId + "-aura-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_200, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: {
kind: "aura",
aura: slugify(namedComponent?.name ?? spell.name),
durationMs: Math.min(12_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function targetAuraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? 0) === 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent) return null;
const cooldownMs = Math.max(6_000, Math.min(
30_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-debuff-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: {
kind: "target-aura",
aura: slugify(namedComponent.name),
durationMs: Math.min(10_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function scriptPhaseMechanics(entityId, bodies) {
const thresholds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\bHPPercent\s*<=\s*(\d+(?:\.\d+)?)\b/g)].map((match) => {
const raw = Number(match[1]);
return Math.round(raw <= 1 ? raw * 100 : raw);
})
)).filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
return thresholds.map((threshold) => ({
id: entityId + "-phase-" + threshold,
name: "Phase Change",
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
cooldownMs: 1_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
damageMultiplier: 0,
effect: { kind: "aura", aura: "phase-" + threshold, durationMs: 5_000 },
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
}));
}
function summonMechanic(entityId, spell) {
const summon = (spell.components ?? []).find((component) => Number(component.summonCreatureId) > 0);
if (!summon) return null;
const cooldownMs = Math.max(8_000, Math.min(
40_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 20_000,
));
return {
id: entityId + "-summon-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
interruptible: false,
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry: summon.summonCreatureId, count: 1 },
};
}
function scriptSummons(entityId, bodies) {
const joined = bodies.join("\n");
if (!/\b(?:CallMonster|CreateNPC|CreateMonster)/i.test(joined)) return [];
const constants = new Map([...joined.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(1\d{5})\b/g)]
.map((match) => [match[1], Number(match[2])]));
const creatureEntry = [...constants.entries()]
.find(([name]) => /monster|mob|summon/i.test(name))?.[1];
if (!creatureEntry) return [];
const count = Math.max(1, Math.min(6, Number(
[...joined.matchAll(/\b(?:MonsterNumber|MobNumber|SummonNumber)\w*\s*=\s*(\d+)\b/gi)][0]?.[1] ?? 1,
)));
const thresholds = [...new Set([...joined.matchAll(/\bHPPercent\s*<=\s*(0?\.\d+|1(?:\.0+)?)\b/g)]
.map((match) => Math.round(Number(match[1]) * 100))
.filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
const events = thresholds.length ? thresholds : [null];
return events.map((threshold, index) => ({
id: entityId + "-summon-" + creatureEntry + (threshold ? "-at-" + threshold : ""),
name: threshold ? "Reinforcements at " + threshold + "%" : "Call Reinforcements",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_800,
interruptible: false,
range: 0,
cooldownMs: threshold ? 1_000 : 20_000,
initialCooldownMs: threshold ? [0, 0] : [8_000, 8_000],
repeatCooldownMs: threshold ? [1_000, 1_000] : [20_000, 20_000],
...(threshold ? { maximumCasts: 1 } : {}),
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count },
...(threshold ? {
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
} : {}),
_sourceOrder: index,
})).map(({ _sourceOrder, ...mechanic }) => mechanic);
}
function sourceGapMechanic(entityId, name, archetype) {
const caster = /mage|witch|goddess|disciple|tree|seeker|guardian/i.test(name);
if (caster) {
return {
id: entityId + "-source-gap-burst",
name: "Arcane Burst",
delivery: "area",
target: "nearby-party",
school: "arcane",
animation: "cast",
castTimeMs: 2_000,
range: 12,
cooldownMs: 12_000,
initialCooldownMs: [8_000, 8_000],
repeatCooldownMs: [12_000, 12_000],
damageMultiplier: 0.82,
radius: 8,
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 8 },
};
}
return {
id: entityId + "-source-gap-sweep",
name: archetype === "ooze" ? "Corrosive Sweep" : "Heavy Sweep",
delivery: "area",
target: "nearby-party",
school: archetype === "ooze" ? "nature" : "physical",
animation: "attack",
castTimeMs: 1_800,
range: 7,
cooldownMs: 10_000,
initialCooldownMs: [7_000, 7_000],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 1.1,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1_800,
length: 7,
angleDegrees: 100,
},
};
}
const spellCatalog = await readJson(spellCatalogFile);
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
const catalog = await readJson(mechanicCatalogFile);
const runewakerBosses = {};
const replacedRuntimePrefixes = new Set();
let sourceBacked = 0;
let sourceGap = 0;
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
const sharedSourceRoot = sourceRootFor(firstRecipe);
const sharedLuaRoot = path.join(
sharedSourceRoot,
firstRecipe.source.luaRoot ?? "Resource/luascript",
);
const sharedLuaSources = [];
for (const file of (await readdir(sharedLuaRoot)).filter((name) => name.endsWith(".lua")).sort()) {
sharedLuaSources.push({ file, text: await readFile(path.join(sharedLuaRoot, file), "utf8") });
}
const sharedFunctions = functionIndex(sharedLuaSources);
for (const recipeName of recipeNames) {
const recipe = await readJson(path.join(recipesDirectory, recipeName));
replacedRuntimePrefixes.add(recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId);
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
const rowsByTemplate = new Map();
for (const row of snapshot.rows ?? []) {
if (row.classification !== "boss") continue;
const rows = rowsByTemplate.get(Number(row.templateId)) ?? [];
rows.push(row);
rowsByTemplate.set(Number(row.templateId), rows);
}
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
const roots = [...new Set([
...(template.source?.scriptRoots ?? []).map(rootName),
...rows.flatMap((row) => [
rootName(row.source?.autoPlot),
rootName(row.source?.plotClassName),
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
]),
].filter(Boolean))];
if (!rows.length && !roots.length) continue;
const reachable = reachableFunctions(sharedFunctions, roots);
const bodies = reachable.map((entry) => entry.body);
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\b49\d{4}\b/g)].map((match) => Number(match[0]))
)))];
const templateSpellIds = [...new Set(rows.flatMap((row) => (
(row.source?.spells ?? []).map((spell) => Number(spell.id)).filter((id) => id > 0)
)))];
const spellIds = [...new Set([...templateSpellIds, ...scriptSpellIds])].sort((a, b) => a - b);
const entityId = (recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId) + "-" + template.id;
const mechanics = [];
for (const spellId of spellIds) {
const spell = spellsById.get(spellId);
if (!spell) continue;
if (spell.mappingStatus === "damage-family" && spell.runtime) {
mechanics.push(damageMechanic(entityId, spell));
continue;
}
const summon = summonMechanic(entityId, spell);
if (summon) {
mechanics.push(summon);
continue;
}
const control = controlMechanic(entityId, spell);
if (control) {
mechanics.push(control);
continue;
}
const aura = auraMechanic(entityId, spell);
if (aura) {
mechanics.push(aura);
continue;
}
const targetAura = targetAuraMechanic(entityId, spell);
if (targetAura) mechanics.push(targetAura);
}
mechanics.push(...scriptSummons(entityId, bodies));
mechanics.push(...scriptPhaseMechanics(entityId, bodies));
const uniqueMechanics = [...new Map(mechanics.map((mechanic) => [mechanic.id, mechanic])).values()];
const fallback = uniqueMechanics.length === 0;
if (fallback) uniqueMechanics.push(sourceGapMechanic(entityId, template.name, template.archetype));
if (fallback) sourceGap += 1;
else sourceBacked += 1;
runewakerBosses[entityId] = {
name: template.name,
mode: "replace",
source: {
kind: fallback ? "healer-man-source-gap" : "runewaker",
dungeonId: recipe.dungeonId,
templateId: template.id,
roots,
functions: reachable.map((entry) => entry.name),
spellIds,
},
mechanics: uniqueMechanics,
};
}
}
catalog.bosses = Object.fromEntries([
...Object.entries(catalog.bosses).filter(([key]) => (
requestedRecipe
? ![...replacedRuntimePrefixes].some((prefix) => key.startsWith(prefix + "-"))
: !key.startsWith("rw-")
)),
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
]);
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
console.log("[runewaker-boss-mechanics] wrote " + Object.keys(runewakerBosses).length + " boss kits.");
console.log("[runewaker-boss-mechanics] source-backed: " + sourceBacked + "; source-gap fallback: " + sourceGap + ".");
console.log("[runewaker-boss-mechanics] " + projectPath(mechanicCatalogFile));