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

245 lines
7.7 KiB
JavaScript

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