284 lines
9.5 KiB
JavaScript
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));
|