523 lines
18 KiB
JavaScript
523 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,
|
|
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));
|