#!/usr/bin/env node import { spawn } from "node:child_process"; import { access, mkdir, readFile, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const campaignFile = path.join(projectRoot, "dungeon-pipeline", "dungeon-campaign.json"); const registryFile = path.join( projectRoot, "src", "game", "generated", "fivePlayerDungeonImports.json", ); const reportJsonFile = path.join( projectRoot, "dungeon-pipeline", "FIVE_PLAYER_IMPORT_RUN_REPORT.json", ); const reportMarkdownFile = path.join( projectRoot, "dungeon-pipeline", "FIVE_PLAYER_IMPORT_RUN_REPORT.md", ); const logsRoot = path.join( projectRoot, "dungeon-pipeline", "work", "five-player-import-logs", ); const epochClient = "C:\\Program Files\\Ascension Launcher\\resources\\epoch_live"; async function exists(file) { try { await access(file); return true; } catch { return false; } } async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function writeJson(file, value) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function elapsedSeconds(startedAt, finishedAt) { return Math.round( (new Date(finishedAt).getTime() - new Date(startedAt).getTime()) / 100, ) / 10; } function markdownFor(report) { const green = report.results.filter((entry) => entry.status === "green"); const failed = report.results.filter((entry) => entry.status === "error"); const pending = report.requested.filter( (slug) => !report.results.some((entry) => entry.slug === slug), ); const lines = [ "# Five-player dungeon import run report", "", `Last updated: ${report.updatedAt}`, "", `Command template: \`${report.commandTemplate}\``, "", `Requested: ${report.requested.length} · Green: ${green.length} · Failed: ${failed.length} · Pending: ${pending.length}`, "", "| Status | Dungeon | Seconds | Command log | Blocker |", "| --- | --- | ---: | --- | --- |", ]; for (const slug of report.requested) { const result = report.results.find((entry) => entry.slug === slug); if (!result) { lines.push(`| pending | ${slug} | | | |`); continue; } const log = result.log.replace(/\\/g, "/"); const blocker = String(result.error ?? "") .replace(/\|/g, "\\|") .replace(/\r?\n/g, " "); lines.push( `| ${result.status} | ${slug} | ${result.seconds} | \`${log}\` | ${blocker} |`, ); } lines.push( "", "A green import is not checked off until the combined campaign catalog and verification suite pass.", "", ); return `${lines.join("\n")}\n`; } async function persist(report) { report.updatedAt = new Date().toISOString(); await writeJson(reportJsonFile, report); await writeFile(reportMarkdownFile, markdownFor(report), "utf8"); } function lastJsonError(output) { const match = output.match(/"error"\s*:\s*"((?:\\.|[^"])*)"/g)?.at(-1); if (!match) return ""; try { return JSON.parse(`{${match}}`).error; } catch { return match.slice(9).replace(/^"|"$/g, ""); } } async function runOne(slug) { const startedAt = new Date().toISOString(); const chunks = []; const executable = process.platform === "win32" ? process.env.ComSpec || "C:\\Windows\\System32\\cmd.exe" : "npm"; const argumentsList = process.platform === "win32" ? ["/d", "/s", "/c", `npm run dungeon:five-player:import -- ${slug} --refresh`] : ["run", "dungeon:five-player:import", "--", slug, "--refresh"]; const child = spawn( executable, argumentsList, { cwd: projectRoot, env: { ...process.env, EPOCH_CLIENT_ROOT: process.env.EPOCH_CLIENT_ROOT || epochClient, WOW_SERVER_DATA: process.env.WOW_SERVER_DATA || path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", "server-data"), }, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }, ); for (const stream of [child.stdout, child.stderr]) { stream.on("data", (chunk) => { const text = chunk.toString(); chunks.push(text); process.stdout.write(`[${slug}] ${text}`); }); } const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", resolve); }); const finishedAt = new Date().toISOString(); const output = chunks.join(""); await mkdir(logsRoot, { recursive: true }); const logFile = path.join(logsRoot, `${slug}.log`); await writeFile(logFile, output, "utf8"); return { slug, status: exitCode === 0 ? "green" : "error", exitCode, command: `npm run dungeon:five-player:import -- ${slug} --refresh`, startedAt, finishedAt, seconds: elapsedSeconds(startedAt, finishedAt), log: path.relative(projectRoot, logFile).replace(/\\/g, "/"), ...(exitCode === 0 ? {} : { error: lastJsonError(output) || `Importer exited with code ${exitCode}.` }), }; } const force = process.argv.includes("--force"); const campaign = await readJson(campaignFile); const registry = await readJson(registryFile); const alreadyImported = new Set((registry.imports ?? []).map((entry) => entry.slug)); const managedElsewhere = new Set(["wailing-caverns", "shadowfang-keep"]); const requested = campaign.dungeons .map((entry) => entry.slug) .filter((slug) => !managedElsewhere.has(slug)); const previous = await exists(reportJsonFile) ? await readJson(reportJsonFile) : null; const report = { schemaVersion: 1, commandTemplate: "npm run dungeon:five-player:import -- --refresh", startedAt: previous?.startedAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), requested, results: (previous?.results ?? []).filter((entry) => requested.includes(entry.slug)), }; for (const slug of requested.filter((entry) => alreadyImported.has(entry))) { if (report.results.some((entry) => entry.slug === slug && entry.status === "green")) continue; report.results = [ ...report.results.filter((entry) => entry.slug !== slug), { slug, status: "green", exitCode: 0, command: `npm run dungeon:five-player:import -- ${slug} --refresh`, startedAt: report.startedAt, finishedAt: report.updatedAt, seconds: 0, log: path.relative( projectRoot, path.join(logsRoot, `${slug}.log`), ).replace(/\\/g, "/"), verifiedFromRegistry: true, }, ]; } report.results.sort( (left, right) => requested.indexOf(left.slug) - requested.indexOf(right.slug), ); await persist(report); for (const slug of requested) { const prior = report.results.find((entry) => entry.slug === slug); if (!force && (alreadyImported.has(slug) || prior?.status === "green")) { console.log(`[${slug}] already green in the run report; skipping.`); continue; } console.log(`[${slug}] starting single-dungeon import.`); const result = await runOne(slug); report.results = [ ...report.results.filter((entry) => entry.slug !== slug), result, ].sort( (left, right) => requested.indexOf(left.slug) - requested.indexOf(right.slug), ); await persist(report); console.log(`[${slug}] ${result.status} (${result.seconds}s).`); } const failed = report.results.filter((entry) => entry.status === "error"); console.log(JSON.stringify({ status: failed.length ? "completed-with-errors" : "green", requested: requested.length, green: report.results.filter((entry) => entry.status === "green").length, failed: failed.map((entry) => ({ slug: entry.slug, error: entry.error })), report: path.relative(projectRoot, reportMarkdownFile).replace(/\\/g, "/"), }, null, 2)); if (failed.length) process.exitCode = 1;