Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

@@ -1,6 +1,7 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
@@ -12,8 +13,21 @@ if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirect
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
+71
View File
@@ -0,0 +1,71 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
await copyFile(
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
path.join(outputDirectory, "content-cache-sw.js"),
);
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
+55 -7
View File
@@ -18,14 +18,34 @@ export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns",
]);
const RUNTIME_EXTENSIONS = new Set([
".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png",
".svg", ".wav", ".webm", ".webp",
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
".svg", ".wasm", ".wav", ".webm", ".webp",
]);
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
]);
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
{
directory: path.join(PROJECT_ROOT, "public", "assets"),
@@ -49,18 +69,33 @@ function packLabel(packId) {
const [category, id = category] = packId.split(":", 2);
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
if (category === "dungeon") return name;
if (category === "characters") return `${name} Characters`;
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
if (category === "characters") return `${name} Character Models & Skins`;
if (category === "manastorm") return `${name} Manastorm`;
if (packId === "shared:creatures") return "Shared Creatures";
if (packId === "core:ui") return "Interface";
if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name;
}
export function contentPackForPath(logicalPath) {
const segments = normalizedRelativePath(logicalPath).split("/");
const normalizedPath = normalizedRelativePath(logicalPath);
const segments = normalizedPath.split("/");
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
if (segments[0] !== "assets") return "core:misc";
if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`;
if (segments[1] === "characters" && segments[2]) {
const basicWrathHuman = segments[2] === "wow335a-v1" && (
segments[3] === "manifest.json"
|| segments[3] === "human"
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
);
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
}
if (segments[1] === "creatures" && segments[2]) {
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
}
@@ -73,12 +108,17 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
return `manastorm:${segments[3]}`;
}
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
if (segments[1] === "ui") return "core:ui";
if (segments[1] === "equipment") return "core:equipment";
return "core:misc";
}
async function walkFiles(directory) {
const rootStats = await stat(directory);
if (rootStats.isFile()) {
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
}
const files = [];
async function visit(current) {
const entries = await readdir(current, { withFileTypes: true });
@@ -111,7 +151,9 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
const files = await walkFiles(sourceDirectory);
for (const sourcePath of files) {
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`);
const logicalPath = normalizedRelativePath(relative
? `${source.logicalPrefix}/${relative}`
: source.logicalPrefix);
const fileStats = await stat(sourcePath);
const sha256 = await sha256File(sourcePath);
const prior = discovered.get(logicalPath);
@@ -170,11 +212,17 @@ export function createContentManifest(files, options = {}) {
if (packs.has("shared:creatures")) {
for (const pack of packs.values()) {
if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) {
if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|| pack.id.startsWith("manastorm:")) {
pack.dependencies = ["shared:creatures"];
}
}
}
if (packs.has("core:character-basic")) {
for (const pack of packs.values()) {
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
}
}
return {
schemaVersion: CONTENT_SCHEMA_VERSION,
+13 -1
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
BUNDLED_ANDROID_PACKS,
contentPackForPath,
createContentManifest,
publishContent,
@@ -13,8 +14,19 @@ import {
test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
"core:combat-media",
);
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:combat-media"), false);
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
});
test("publishes objects before a deterministic latest manifest", async () => {
@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
"generated",
"azerothCoreReference.json",
);
const azerothCoreWorldAiFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreWorldAi.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
const epochCatalogFile = path.join(
@@ -533,8 +541,194 @@ function fallbackSpecial(id, boss) {
};
}
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
function validateBossMechanicsCatalog(catalog) {
if (catalog.schemaVersion !== 1) {
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
}
const deliveries = new Set(["melee", "projectile", "area"]);
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const animations = new Set(["attack", "cast"]);
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
}
if (!["append", "replace"].includes(boss.mode)) {
throw new Error(`${entry}: mechanic mode must be append or replace.`);
}
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
const ids = new Set();
for (const mechanic of boss.mechanics) {
if (!mechanic.id || ids.has(mechanic.id)) {
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
}
ids.add(mechanic.id);
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
}
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
}
}
}
}
function smartAiEventProperties(row) {
if (row.event_type === 0) {
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
return {
initialCooldownMs: initial,
repeatCooldownMs: repeat,
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
};
}
if (row.event_type === 2) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
condition: {
kind: "self-health-percent",
minimum: Math.max(0, Number(row.event_param1) || 0),
maximum: Math.min(100, Number(row.event_param2) || 100),
},
};
}
if (row.event_type === 4) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
};
}
if (row.event_type === 14) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
cooldownMs: 1_000,
condition: {
kind: "friendly-missing-health",
amount: Math.max(1, Number(row.event_param1) || 1),
range: Math.max(1, Number(row.event_param2) || 30),
},
};
}
return null;
}
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!boss) return [];
const entry = Number(entity.entry);
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
const mechanics = [];
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
const event = smartAiEventProperties(trigger);
if (!event) continue;
const chain = [];
let row = trigger;
const visited = new Set();
while (row && !visited.has(Number(row.id))) {
visited.add(Number(row.id));
chain.push(row);
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
}
for (const action of chain) {
const actionType = Number(action.action_type);
const source = {
entry,
sourceType: Number(action.source_type) || 0,
rowId: Number(action.id) || 0,
eventType: Number(trigger.event_type) || 0,
actionType,
targetType: Number(action.target_type) || 0,
comment: String(action.comment ?? trigger.comment ?? ""),
};
if (actionType === 11) {
const spellId = Number(action.action_param1);
const spell = spells.get(spellId);
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target: [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target,
source,
});
} else if (actionType === 12) {
const creatureEntry = Number(action.action_param1);
if (!creatureEntry) continue;
mechanics.push({
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
name: String(action.comment ?? "Summon Reinforcements")
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count: 1 },
...event,
source,
});
} else if (actionType === 39) {
const radius = Math.max(1, Number(action.action_param1) || 30);
mechanics.push({
id: `smart-${entry}-${action.id}-call-for-help`,
name: "Call for Help",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 800,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "call-for-help", radius },
...event,
source,
});
}
}
}
return mechanics;
}
function uniqueMechanics(mechanics) {
const seenIds = new Set();
const seenSpellIds = new Set();
return mechanics.filter((mechanic) => {
if (seenIds.has(mechanic.id)) return false;
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
seenIds.add(mechanic.id);
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
return true;
});
}
function combatFor(
entity,
boss,
spells,
scriptCatalog = undefined,
worldAi = undefined,
bossMechanics = undefined,
) {
const hash = stableHash(`${entity.id}:${entity.name}`);
const entry = Number(entity.entry);
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
.filter((spell) => spell.combat);
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
: null;
})
.filter(Boolean)
.slice(0, boss ? 4 : 2)
.map((spell) => spellAttack(spell, hash, boss));
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true));
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
mechanics = uniqueMechanics(mechanics);
if (!mechanics.length && boss && !authored) mechanics.push(fallbackSpecial(entity.id, true));
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
const level = levels.length
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
: undefined;
return {
...(entry ? { serverEntry: entry } : {}),
...(level ? { level } : {}),
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
damageMultiplier: boss ? 1.12 : 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 70 : 52,
attacks: [basicAttack(basicCooldown), ...spellAttacks],
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
attacks: [basicAttack(basicCooldown), ...mechanics],
};
}
@@ -596,6 +797,7 @@ function wailingServerCombatFor(entity, boss, reference) {
damageMultiplier: 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 55 : 42,
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
attacks: [{
...basicAttack(basicCooldown),
initialCooldownMs: [450, 450],
@@ -664,7 +866,7 @@ function offsetPosition(position, index, radius = 3.2) {
];
}
function syntheticEntity(id, name, boss, level) {
function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
const entity = {
id,
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
combat: {
...combatFor(entity, boss, new Map()),
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
},
visual: visualFor(id, name, boss, entity.rank),
@@ -764,6 +966,9 @@ async function compile() {
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
? await readJson(scriptSpellCatalogFile)
: { scripts: {} };
const azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
const bossMechanics = await readJson(bossMechanicsFile);
validateBossMechanicsCatalog(bossMechanics);
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
@@ -798,6 +1003,14 @@ async function compile() {
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
}
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
}
}
}
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
for (const mechanic of boss.mechanics ?? []) {
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
}
}
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
@@ -897,7 +1110,14 @@ async function compile() {
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, boss, azerothCoreReference)
: combatFor(source, boss, spells, scriptSpellCatalog),
: combatFor(
source,
boss,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
source.id,
source.name,
@@ -920,7 +1140,14 @@ async function compile() {
hasLoot: true,
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, true, azerothCoreReference)
: combatFor(source, true, spells, scriptSpellCatalog),
: combatFor(
source,
true,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
entityId,
encounter.name,
@@ -949,6 +1176,7 @@ async function compile() {
encounter.name,
true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
bossMechanics,
);
}
}
@@ -19,14 +19,15 @@ const outputFile = path.join(
"generated",
"azerothCoreScriptSpells.json",
);
const epochSourceFile = path.join(
const campaignSourceFile = path.join(
projectRoot,
"dungeon-pipeline",
"epoch-five-player-instances.json",
"dungeon-campaign.json",
);
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
const sourceRoots = [
"src/server/scripts/Kalimdor/CavernsOfTime",
"src/server/scripts/EasternKingdoms",
"src/server/scripts/Kalimdor",
"src/server/scripts/Northrend",
"src/server/scripts/Outland",
"src/server/scripts/World",
@@ -45,7 +46,10 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function runGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd,
stdio: "inherit",
windowsHide: true,
@@ -61,7 +65,10 @@ function runGit(argumentsList, cwd = projectRoot) {
function readGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
const safeArguments = cwd === sourceRoot
? ["-c", `safe.directory=${sourceRoot.replace(/\\/g, "/")}`, ...argumentsList]
: argumentsList;
const child = spawn("git", safeArguments, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -203,8 +210,8 @@ if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
`AzerothCore script checkout is ${checkedOutCommit}; rerun with --fetch to restore ${DEFAULT_AZEROTHCORE_COMMIT}.`,
);
}
const epochSource = await readJson(epochSourceFile);
const wantedSlugs = new Set(epochSource.dungeons.map((dungeon) => dungeon.slug));
const campaignSource = await readJson(campaignSourceFile);
const wantedSlugs = new Set(campaignSource.dungeons.map((dungeon) => dungeon.slug));
const scriptNames = new Set();
for (const slug of wantedSlugs) {
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
+20 -1
View File
@@ -105,8 +105,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
catalogs into a checked-in portable snapshot:
~~~powershell
npm run runewaker:spells:refresh
npm run runewaker:bosses:refresh
npm run runewaker:build:all -- --phase population --force
npm run runewaker:forsaken:population
npm run runewaker:audit:gameplay
~~~
@@ -123,11 +124,29 @@ evidence-only. The portable mapping preserves source spell identity, name,
damage family, school, range, and cooldown. The current 0.85 damage coefficient
is HealerMan-authored and is not represented as the original RuneWaker formula.
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
statically follows each packaged boss's preserved AutoPlot/template callbacks
and writes modular `rw-<dungeon>-<entity-id>` kits to
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
with stable ID, trigger, target, effects, and explicit circle, cone, or line
telegraph geometry. Both population builders copy those IDs into
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
Runewaker and WoW encounters and can later support reviewed randomization.
Source-backed records identify the RuneWaker Lua and spell evidence they came
from. If the preserved scripts expose only encounter-progression hooks, the
importer creates a conservative playable mechanic marked
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
then rerun the population build and gameplay audit to update an encounter.
The gameplay audit also:
- verifies every executable attack.spellId against both its source template
assignment and the portable spell catalog;
- rejects evidence-only spell IDs if they enter runtime combat data;
- requires every boss objective to resolve a modular catalog kit and validates
its source label, telegraph, timed trigger, and health trigger;
- reuses the live party-navigation graph to inspect boss approach points
projected to the retained surface within 6m;
- validates native animation families, with Rune Device's missing locomotion
@@ -8,11 +8,16 @@
"runtimeSpawns": 4086,
"runtimeEntities": 436,
"bossObjectives": 61,
"runtimeAttacks": 824,
"runtimeAttacks": 1068,
"genericDefaultAttacks": 436,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 388,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 111,
"healthTriggeredBossMechanics": 28,
"sourceBackedDamageFamilyAttacks": 483,
"sourceSpellTemplates": 293,
"sourceSpellAssignments": 659,
"scriptedTemplates": 357,
@@ -27,9 +32,9 @@
"dungeons": 30,
"uniqueNativeModels": 321,
"uniqueSourceSpellIds": 411,
"portableSpellCatalogRecords": 411,
"portableDamageFamilyRecords": 227,
"portableEvidenceOnlyRecords": 184
"portableSpellCatalogRecords": 3199,
"portableDamageFamilyRecords": 1342,
"portableEvidenceOnlyRecords": 1857
},
"mechanicsCategories": {
"death": 75,
@@ -53,7 +58,7 @@
"boss spawn membership"
],
"genericRuntimeSystems": [
"unmapped attacks",
"source-gap attacks",
"health/damage scaling",
"movement/leash",
"clustering/aggro",
@@ -63,9 +68,9 @@
"procedural animation"
],
"deferredMechanics": [
"doors/gates",
"summons/waves",
"phases",
"complex doors/gates",
"unmapped multi-actor waves",
"unmapped phases",
"patrols",
"interactions",
"encounter resets",
@@ -216,7 +221,10 @@
},
"sourceMechanics": {
"status": "review-required",
"executableMechanicsGenerated": false,
"executableBossMechanicsGenerated": true,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"scriptedTemplates": 357,
"evidenceCounts": {
"death": 75,
@@ -239,7 +247,7 @@
"unknownEffectsExecute": false,
"coefficientsAreSourceExact": false
},
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only."
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
},
"resolvedRuntimeObservations": [
{
@@ -257,8 +265,7 @@
"reviewNotes": [
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"436 executable attacks do not carry an audited source-spell mapping.",
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
],
@@ -284,6 +291,11 @@
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 16,
"proceduralBindings": 0,
@@ -333,10 +345,15 @@
]
}
],
"runtimeAttacks": 14,
"runtimeAttacks": 15,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 7,
"nativeBindings": 7,
"proceduralBindings": 0,
@@ -400,11 +417,16 @@
]
}
],
"runtimeAttacks": 9,
"runtimeAttacks": 20,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 11,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 7,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -453,11 +475,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 32,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -491,6 +518,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -525,6 +557,11 @@
"genericDefaultAttacks": 17,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 17,
"proceduralBindings": 0,
@@ -700,11 +737,16 @@
]
}
],
"runtimeAttacks": 15,
"runtimeAttacks": 26,
"genericDefaultAttacks": 15,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 15,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -738,6 +780,11 @@
"genericDefaultAttacks": 11,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 10,
"nativeBindings": 11,
"proceduralBindings": 0,
@@ -931,11 +978,16 @@
]
}
],
"runtimeAttacks": 74,
"runtimeAttacks": 122,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 49,
"sourceBackedBossMechanics": 49,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 23,
"healthTriggeredBossMechanics": 2,
"sourceBackedDamageFamilyAttacks": 60,
"nativeBindings": 39,
"proceduralBindings": 1,
"proxyBindings": 0,
@@ -1052,11 +1104,16 @@
]
}
],
"runtimeAttacks": 54,
"runtimeAttacks": 75,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 28,
"catalogBossMechanics": 25,
"sourceBackedBossMechanics": 25,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1173,11 +1230,16 @@
]
}
],
"runtimeAttacks": 68,
"runtimeAttacks": 80,
"genericDefaultAttacks": 32,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 36,
"catalogBossMechanics": 16,
"sourceBackedBossMechanics": 16,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 39,
"nativeBindings": 32,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1211,6 +1273,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 4,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -1316,11 +1383,16 @@
]
}
],
"runtimeAttacks": 31,
"runtimeAttacks": 48,
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 17,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 20,
"nativeBindings": 14,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1369,11 +1441,16 @@
]
}
],
"runtimeAttacks": 27,
"runtimeAttacks": 33,
"genericDefaultAttacks": 20,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 7,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 20,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1422,11 +1499,16 @@
]
}
],
"runtimeAttacks": 34,
"runtimeAttacks": 40,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 18,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 22,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1460,6 +1542,11 @@
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 14,
"proceduralBindings": 0,
@@ -1545,10 +1632,15 @@
]
}
],
"runtimeAttacks": 38,
"runtimeAttacks": 50,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 11,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 26,
"nativeBindings": 12,
"proceduralBindings": 0,
@@ -1670,11 +1762,16 @@
]
}
],
"runtimeAttacks": 18,
"runtimeAttacks": 52,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 6,
"catalogBossMechanics": 34,
"sourceBackedBossMechanics": 34,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 9,
"healthTriggeredBossMechanics": 16,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 12,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1719,11 +1816,16 @@
]
}
],
"runtimeAttacks": 61,
"runtimeAttacks": 65,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 21,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 25,
"nativeBindings": 40,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1772,11 +1874,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 5,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1861,11 +1968,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 31,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1924,11 +2036,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 64,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 29,
"catalogBossMechanics": 9,
"sourceBackedBossMechanics": 9,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1962,6 +2079,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 32,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2035,11 +2157,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 63,
"genericDefaultAttacks": 21,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 12,
"sourceBackedBossMechanics": 12,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 36,
"nativeBindings": 21,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2073,6 +2200,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -2150,11 +2282,16 @@
]
}
],
"runtimeAttacks": 5,
"runtimeAttacks": 25,
"genericDefaultAttacks": 3,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 22,
"sourceBackedBossMechanics": 22,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 3,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2203,11 +2340,16 @@
]
}
],
"runtimeAttacks": 2,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 1,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2256,11 +2398,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 3,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2294,6 +2441,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 23,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2328,6 +2480,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## Coverage
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
- 61 boss objectives and 824 executable attacks.
- 61 boss objectives and 1068 executable attacks.
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
## Authentic source data currently used
@@ -21,17 +23,17 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
directions, model paths, source hashes, and boss spawn membership.
- Navmesh-projected placements derived from each packaged environment anchor.
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
## HealerMan-generic or deferred
- 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions without source mapping.
- 388 executable attacks carry a conservative source-backed direct-damage family mapping.
- 0 attacks are builder exceptions outside the modular boss catalog.
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
attack, cast, wound, and death families.
@@ -42,33 +44,33 @@ Animation columns are native/procedural/proxy entity bindings.
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
@@ -111,7 +113,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
- death: 75
- door: 22
@@ -139,7 +141,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 436 executable attacks do not carry an audited source-spell mapping.
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
"- " + summary.catalogBossMechanics + " modular boss mechanics are active: "
+ summary.sourceBackedBossMechanics + " source-backed and "
+ summary.sourceGapBossMechanics + " explicitly flagged source-gap fallbacks.",
"- Boss signaling includes " + summary.telegraphedBossMechanics
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
+ " visible health-triggered mechanics.",
"",
"## Authentic source data currently used",
"",
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
" directions, model paths, source hashes, and boss spawn membership.",
"- Navmesh-projected placements derived from each packaged environment anchor.",
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.",
"",
"## HealerMan-generic or deferred",
"",
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
"- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.",
"",
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
"",
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
"Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
"",
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
.map(([kind, count]) => "- " + kind + ": " + count),
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
const spellCatalogFile = path.join(
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
let spellCatalogSnapshot = null;
let spellCatalog = new Map();
let bossMechanics = { bosses: {} };
try {
spellCatalogSnapshot = await readJson(spellCatalogFile);
if (spellCatalogSnapshot.status !== "portable"
@@ -305,6 +313,14 @@ try {
} catch (error) {
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
}
try {
bossMechanics = await readJson(bossMechanicsFile);
if (bossMechanics.schemaVersion !== 1) {
issues.push("RuneWaker boss mechanics use an unsupported catalog schema.");
}
} catch (error) {
issues.push("Could not load the modular boss mechanic catalog: " + error.message);
}
const registrySource = await readFile(
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
genericDefaultAttacks: 0,
builderAuthoredAttacks: 0,
recipeAuthoredAttacks: 0,
catalogBossMechanics: 0,
sourceBackedBossMechanics: 0,
sourceGapBossMechanics: 0,
telegraphedBossMechanics: 0,
healthTriggeredBossMechanics: 0,
sourceBackedDamageFamilyAttacks: 0,
nativeBindings: 0,
proceduralBindings: 0,
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
const attacks = entity.combat?.attacks ?? [];
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
const authoredBoss = bossMechanics.bosses?.[entityId];
const authoredMechanicIds = new Set(
(authoredBoss?.mechanics ?? []).map((mechanic) => mechanic.id),
);
const authoredSourceSpellIds = new Set(
(authoredBoss?.source?.spellIds ?? []).map(Number),
);
if (entity.kind === "boss") {
if (!authoredBoss) issue(issues, dungeonId, entityId + " has no modular boss mechanic kit.");
const runtimeMechanicIds = entity.combat?.mechanicIds ?? [];
if (runtimeMechanicIds.length !== authoredMechanicIds.size
|| runtimeMechanicIds.some((id) => !authoredMechanicIds.has(id))) {
issue(issues, dungeonId, entityId + " mechanic id registry drifted from the modular catalog.");
}
}
const attackIds = new Set();
const executableSourceSpellIds = new Set();
const candidateSpellIds = new Set(
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
[
...(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
...authoredSourceSpellIds,
],
);
for (const attack of attacks) {
counts.runtimeAttacks += 1;
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
}
attackIds.add(attack.id);
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
const catalogAuthored = authoredMechanicIds.has(attack.id);
if (catalogAuthored) {
counts.catalogBossMechanics += 1;
if (authoredBoss.source?.kind === "runewaker") counts.sourceBackedBossMechanics += 1;
else counts.sourceGapBossMechanics += 1;
if (attack.telegraph) counts.telegraphedBossMechanics += 1;
if (attack.condition?.kind === "self-health-percent") {
counts.healthTriggeredBossMechanics += 1;
}
}
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
if (executableSpellId > 0) {
executableSourceSpellIds.add(executableSpellId);
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
} else {
counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [
"name", "delivery", "target", "school", "animation", "range",
"cooldownMs", "damageMultiplier",
"name", "school", "animation", "cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) {
@@ -499,9 +547,9 @@ for (const recipeName of recipeNames) {
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
+ sourceSpellId + ".");
}
} else if (attack.id.endsWith("-basic-attack")) {
} else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
counts.genericDefaultAttacks += 1;
} else if (!attack.spellId) {
} else if (!catalogAuthored && !attack.spellId) {
counts.builderAuthoredAttacks += 1;
}
}
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
const summaryKeys = [
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
summary.portableSpellCatalogRecords = spellCatalog.size;
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
+ " executable attacks do not carry an audited source-spell mapping.");
}
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
}
reviewNotes.push(summary.sourceGapBossMechanics
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
@@ -751,11 +796,11 @@ const report = {
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
],
genericRuntimeSystems: [
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation",
],
deferredMechanics: [
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
"encounter resets", "source loot",
],
blockerGroups: {
@@ -769,7 +814,10 @@ const report = {
},
sourceMechanics: {
status: "review-required",
executableMechanicsGenerated: false,
executableBossMechanicsGenerated: true,
catalogBossMechanics: summary.catalogBossMechanics,
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
sourceGapBossMechanics: summary.sourceGapBossMechanics,
scriptedTemplates: summary.scriptedTemplates,
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
},
@@ -779,7 +827,7 @@ const report = {
status: spellCatalogSnapshot?.status ?? "missing",
safety: spellCatalogSnapshot?.safety ?? null,
mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "unknown/buff/heal/summon/script effects remain evidence-only.",
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
},
resolvedRuntimeObservations,
documentedExceptions,
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
"manastorm-assets",
"validate-package-glbs.cjs",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
}
function attackFor(template) {
if (template.id === 100074 || template.id === 100070) {
return {
id: "rw-fa-shadow-bolt",
name: "Shadow Bolt",
delivery: "projectile",
target: "primary",
school: "shadow",
animation: "cast",
range: 18,
cooldownMs: 4200,
damageMultiplier: 1.08,
};
}
return {
id: "rw-fa-basic-attack",
id: "basic-attack",
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
delivery: "melee",
target: "primary",
@@ -312,8 +300,9 @@ function attackFor(template) {
};
}
function entityFor(template, actor) {
function entityFor(template, actor, bossMechanics) {
const boss = template.classification === "boss";
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
return {
id: "rw-fa-" + template.id,
name: template.name,
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
damageMultiplier: boss ? 1.3 : 1,
moveSpeed: boss ? 2.25 : 2.15,
leashRange: boss ? 32 : 24,
attacks: [attackFor(template)],
...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
attacks: [attackFor(template), ...mechanics],
},
visual: {
primaryColor: template.primaryColor,
@@ -384,11 +374,16 @@ async function generatePopulation() {
manifestFile,
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
),
requireFile(
bossMechanicsFile,
"Run npm run runewaker:bosses:refresh to create the boss mechanic catalog.",
),
]);
const [snapshot, manifest, environment] = await Promise.all([
const [snapshot, manifest, environment, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]);
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
@@ -438,7 +433,7 @@ async function generatePopulation() {
const entityId = "rw-fa-" + row.templateId;
if (!entities[entityId]) {
entities[entityId] = entityFor(template, actors.get(template.actor));
entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
}
const spawn = {
id: "rw-fa-spawn-" + row.spawnId,
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
"snapshots",
"runewaker-npc-spell-catalog.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const paperdollCatalogFile = path.join(
pipelineDirectory,
"snapshots",
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
}
function attacksFor(template, sourceSpells, spellCatalog) {
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
const ooze = template.archetype === "ooze";
const basicAttack = {
id: runtimePrefix + "-basic-attack",
id: "basic-attack",
name: ooze ? "Corrosive Slam" : "Basic Attack",
delivery: "melee",
target: "primary",
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
damageMultiplier: spell.runtime.damageMultiplier,
}];
});
return [basicAttack, ...sourceAttacks];
const recipeAttacks = Array.isArray(template.attacks)
? template.attacks.filter((attack) => attack.id !== "basic-attack")
: [];
const authored = template.classification === "boss"
? bossMechanics?.bosses?.[runtimePrefix + "-" + template.id]
: null;
const mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
return [basicAttack, ...unique];
}
function entityFor(template, actor, sourceSpells, spellCatalog) {
function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
const boss = template.classification === "boss";
const defaults = recipe.combatDefaults ?? {};
const combat = template.combat ?? {};
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
return {
id: runtimePrefix + "-" + template.id,
name: template.name,
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
attacks: attacksFor(template, sourceSpells, spellCatalog),
...(boss ? { mechanicIds: attacks.slice(1).map((attack) => attack.id) } : {}),
attacks,
},
visual: {
primaryColor: template.primaryColor,
@@ -537,12 +549,14 @@ async function generatePopulation() {
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
requireFile(bossMechanicsFile, "Run npm run runewaker:bosses:refresh to create the boss mechanic catalog."),
]);
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([
const [snapshot, manifest, environment, spellCatalogSnapshot, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(spellCatalogFile, "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]);
if (spellCatalogSnapshot.status !== "portable"
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
@@ -619,6 +633,7 @@ async function generatePopulation() {
actorForTemplate(template),
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
spellCatalog,
bossMechanics,
);
}
const spawn = {
+3
View File
@@ -36,6 +36,7 @@ Usage:
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs boss-mechanics
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
@@ -73,6 +74,8 @@ if (command === "help" || command === "--help" || command === "-h") {
await run("build-population.mjs", argv);
} else if (command === "mechanics") {
await run("extract-mechanics.mjs", argv);
} else if (command === "boss-mechanics") {
await run("import-boss-mechanics.mjs", argv);
} else if (command === "validate") {
await run("validate-instance.mjs", argv);
} else if (command === "prepare-all") {
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
}
}
}
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
for (const fileName of luaNames) {
const source = await readFile(path.join(luaRoot, fileName), "utf8");
const executableSource = source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
const spellId = Number(match[0]);
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
const key = "lua:" + fileName + ":" + spellId;
assignments.set(key, {
sourceKind: "lua-reference",
sourceFile: fileName,
});
assignmentsBySpell.set(spellId, assignments);
}
}
const spells = [];
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
String(actorManifest.proceduralFallbacks.length)
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
] : []),
"Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.",
"Boss combat kits are compiled from RuneWaker spell/script evidence; complex doors, patrol scripts, and loot remain review-required.",
];
const definition = {
schemaVersion: "__SCHEMA__",
@@ -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));
File diff suppressed because it is too large. Load diff