forked from phenom/healer-man
Release Healer Man 0.1.14
This commit is contained in:
1 parent
67d7a8ebe7
commit
7b41b102fa
147 files changed
+169892
-1342
No files matched your search
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
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 recipeNames = (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,
|
||||
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 = {};
|
||||
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));
|
||||
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)) ?? [];
|
||||
if (!rows.length) continue;
|
||||
const roots = [...new Set(rows.flatMap((row) => [
|
||||
rootName(row.source?.autoPlot),
|
||||
rootName(row.source?.plotClassName),
|
||||
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
|
||||
]).filter(Boolean))];
|
||||
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]) => !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));
|
||||
Reference in new issue
Block a user