245 lines
7.7 KiB
JavaScript
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;
|