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