#!/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));