Files
healer-man/scripts/dungeon-pipeline/import-azerothcore-scripts.mjs
T
2026-08-14 15:56:39 -04:00

284 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourceRoot = path.resolve(
process.env.AZEROTHCORE_SCRIPT_ROOT
?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", "azerothcore-scripts"),
);
const scriptsRoot = path.join(sourceRoot, "src", "server", "scripts");
const outputFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreScriptSpells.json",
);
const epochSourceFile = path.join(
projectRoot,
"dungeon-pipeline",
"epoch-five-player-instances.json",
);
const repository = "https://github.com/azerothcore/azerothcore-wotlk";
const sourceRoots = [
"src/server/scripts/Kalimdor/CavernsOfTime",
"src/server/scripts/Northrend",
"src/server/scripts/Outland",
"src/server/scripts/World",
];
const exists = async (file) => {
try {
await access(file);
return true;
} catch {
return false;
}
};
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function runGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
cwd,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => (
code === 0
? resolve()
: reject(new Error(`git ${argumentsList.join(" ")} exited ${code}.`))
));
});
}
function readGit(argumentsList, cwd = projectRoot) {
return new Promise((resolve, reject) => {
const child = spawn("git", argumentsList, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let output = "";
let errorOutput = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { output += chunk; });
child.stderr.on("data", (chunk) => { errorOutput += chunk; });
child.once("error", reject);
child.once("exit", (code) => (
code === 0
? resolve(output.trim())
: reject(new Error(
`git ${argumentsList.join(" ")} exited ${code}: ${errorOutput.trim()}`,
))
));
});
}
async function fetchPinnedSources() {
const gitDirectory = path.join(sourceRoot, ".git");
if (!await exists(gitDirectory)) {
await mkdir(path.dirname(sourceRoot), { recursive: true });
await runGit([
"clone",
"--filter=blob:none",
"--no-checkout",
"--no-tags",
repository,
sourceRoot,
]);
}
await runGit(["fetch", "--depth=1", "origin", DEFAULT_AZEROTHCORE_COMMIT], sourceRoot);
await runGit(["sparse-checkout", "init", "--cone"], sourceRoot);
await runGit(["sparse-checkout", "set", ...sourceRoots], sourceRoot);
await runGit(["checkout", "--detach", DEFAULT_AZEROTHCORE_COMMIT], sourceRoot);
}
async function sourceFiles(root) {
const output = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
const file = path.join(root, entry.name);
if (entry.isDirectory()) output.push(...await sourceFiles(file));
else if (/\.(?:cpp|h)$/i.test(entry.name)) output.push(file);
}
return output.sort();
}
function balancedBlock(source, start) {
const opening = source.indexOf("{", start);
if (opening < 0) return "";
let depth = 0;
let quoted = null;
let escaped = false;
for (let index = opening; index < source.length; index += 1) {
const character = source[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quoted) quoted = null;
continue;
}
if (character === "\"" || character === "'") {
quoted = character;
continue;
}
if (character === "{") depth += 1;
if (character === "}") {
depth -= 1;
if (depth === 0) return source.slice(opening + 1, index);
}
}
return source.slice(opening + 1);
}
function spellDefinitions(source) {
return new Map([...source.matchAll(/\b(SPELL_[A-Z0-9_]+)\s*=\s*(\d+)\b/g)]
.map((match) => [match[1], Number(match[2])]));
}
function secondsToMs(value, unit) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
return Math.round(number * (unit.toLowerCase() === "min" ? 60_000 : 1_000));
}
function cooldownFor(block, spellSymbol) {
const stem = spellSymbol.replace(/^SPELL_/, "");
const eventNames = [...new Set([
`EVENT_${stem}`,
`EVENT_SPELL_${stem}`,
])];
const values = [];
for (const eventName of eventNames) {
for (const match of block.matchAll(new RegExp(
`(?:RescheduleEvent|ScheduleEvent|Repeat)\\s*\\(\\s*${eventName}\\s*,?([^;]*)`,
"g",
))) {
for (const duration of match[1].matchAll(/(\d+(?:\.\d+)?)\s*(ms|s|min)\b/gi)) {
const milliseconds = duration[2].toLowerCase() === "ms"
? Number(duration[1])
: secondsToMs(duration[1], duration[2]);
if (milliseconds && milliseconds >= 500) values.push(milliseconds);
}
}
const caseStart = block.indexOf(`case ${eventName}:`);
if (caseStart >= 0) {
const caseEnd = block.indexOf("break;", caseStart);
const caseBlock = block.slice(caseStart, caseEnd < 0 ? undefined : caseEnd);
for (const duration of caseBlock.matchAll(/(\d+(?:\.\d+)?)\s*(ms|s|min)\b/gi)) {
const milliseconds = duration[2].toLowerCase() === "ms"
? Number(duration[1])
: secondsToMs(duration[1], duration[2]);
if (milliseconds && milliseconds >= 500) values.push(milliseconds);
}
}
}
return values.length ? Math.min(...values) : undefined;
}
function isCombatSpell(symbol) {
return !/(?:VISUAL|TELEPORT|CHANNEL_OOC|DUMMY|SCRIPT|TRIGGER|TARGETING|SUMMON|EMOTE|INTRO|OUTRO|ACHIEVEMENT|TRANSFORM|DESPAWN|KILL_CREDIT)/.test(symbol);
}
if (process.argv.includes("--fetch")) await fetchPinnedSources();
if (!await exists(scriptsRoot)) {
throw new Error(
`AzerothCore scripts are missing at ${scriptsRoot}; rerun with --fetch to check out commit ${DEFAULT_AZEROTHCORE_COMMIT}.`,
);
}
if (!await exists(path.join(sourceRoot, ".git"))) {
throw new Error(`AzerothCore script source at ${sourceRoot} is not a verifiable Git checkout.`);
}
const checkedOutCommit = await readGit(["rev-parse", "HEAD"], sourceRoot);
if (checkedOutCommit !== DEFAULT_AZEROTHCORE_COMMIT) {
throw new Error(
`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 scriptNames = new Set();
for (const slug of wantedSlugs) {
const draftFile = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", slug, "runtime-draft.json");
if (!await exists(draftFile)) continue;
const draft = await readJson(draftFile);
for (const entity of draft.entities ?? []) {
if (entity.scriptName) scriptNames.add(entity.scriptName);
}
}
const files = await sourceFiles(scriptsRoot);
const documents = await Promise.all(files.map(async (file) => ({
file,
source: await readFile(file, "utf8"),
})));
const scripts = {};
const unresolved = [];
for (const scriptName of [...scriptNames].sort()) {
const declaration = new RegExp(`\\b(?:struct|class)\\s+${scriptName}\\b`);
const document = documents.find((candidate) => declaration.test(candidate.source))
?? documents.find((candidate) => new RegExp(`\\b${scriptName}\\b`).test(candidate.source));
if (!document) {
unresolved.push(scriptName);
continue;
}
const declarationIndex = document.source.search(declaration);
const block = balancedBlock(document.source, declarationIndex >= 0 ? declarationIndex : 0);
const directoryDocuments = documents.filter(
(candidate) => path.dirname(candidate.file) === path.dirname(document.file),
);
const definitions = new Map(
directoryDocuments.flatMap((candidate) => [...spellDefinitions(candidate.source)]),
);
const usedSymbols = [...new Set([...block.matchAll(/\bSPELL_[A-Z0-9_]+\b/g)]
.map((match) => match[0]))];
const spells = usedSymbols
.filter((symbol) => definitions.has(symbol))
.map((symbol) => ({
symbol,
spellId: definitions.get(symbol),
combat: isCombatSpell(symbol),
...(cooldownFor(block, symbol)
? { cooldownMs: cooldownFor(block, symbol) }
: {}),
}));
scripts[scriptName] = {
sourceFile: path.relative(projectRoot, document.file).replace(/\\/g, "/"),
sourceHash: sha256(document.source),
spells,
};
}
const output = {
schemaVersion: 1,
source: {
repository,
commit: DEFAULT_AZEROTHCORE_COMMIT,
roots: sourceRoots,
},
counts: {
requestedScripts: scriptNames.size,
resolvedScripts: Object.keys(scripts).length,
unresolvedScripts: unresolved.length,
combatSpells: Object.values(scripts)
.flatMap((script) => script.spells)
.filter((spell) => spell.combat).length,
},
unresolved,
scripts,
};
await writeFile(outputFile, `${JSON.stringify(output, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
status: unresolved.length ? "review-required" : "green",
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
...output.counts,
}, null, 2));