Files

151 lines
7.6 KiB
JavaScript

import path from "node:path";
import { pathToFileURL } from "node:url";
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs";
import { downloadSupplements, readSupplements } from "./supplements.mjs";
import { lookupReference, publishSnapshot, readSnapshot } from "./snapshot.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const usage = `CoA offline reference
build Build and atomically publish a local-first reference and audit
fetch Explicitly cache the pinned August archive talent supplement
fetch-spells Cache missing global spell records and their linked payloads
audit Verify the current snapshot and repeat offline coverage checks
lookup Search the current snapshot by --class, --tree, --spell, --ability
inventory Inspect the resolved local source pair without changing files
promote Promote the verified August Vol'Jin cache into the active CoA catalog
Options:
--client-root PATH Existing Ascension client directory
--extracted-root PATH Existing LadiksMPQEditor directory (supply both roots)
--output-root PATH Default: data/coa-reference
--cache-root PATH Default: data/coa-reference/cache
--limit NUMBER Lookup results per collection, default 20
--refresh Fetch command: revalidate the pinned supplement
--help Show this help
Build, audit, lookup, inventory and promote never make network requests.
Only promote writes the active gameplay catalog. Original client files stay intact.
`;
export function parseArguments(args) {
const hasCommand = Boolean(args[0] && !args[0].startsWith("--"));
const result = { command: hasCommand ? args[0] : "help" };
const valueFlags = new Set(["client-root", "extracted-root", "output-root", "cache-root", "class", "tree", "spell", "ability", "limit"]);
for (let index = hasCommand ? 1 : 0; index < args.length; index += 1) {
const flag = args[index];
if (flag === "--help") result.command = "help";
else if (flag === "--refresh") result.refresh = true;
else if (flag.startsWith("--") && valueFlags.has(flag.slice(2))) {
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Missing value for ${flag}`);
result[flag.slice(2)] = args[++index];
} else throw new Error(`Unknown argument: ${flag}`);
}
if (!["help", "build", "fetch", "fetch-spells", "audit", "lookup", "inventory", "promote"].includes(result.command)) {
throw new Error(`Unknown command: ${result.command}`);
}
if (Boolean(result["client-root"]) !== Boolean(result["extracted-root"])) {
throw new Error("Supply both --client-root and --extracted-root to avoid mixing source installations");
}
if (result.refresh && result.command !== "fetch") throw new Error("--refresh is only supported by fetch");
return result;
}
/** Return unresolved spell roots owned by CoA plus explicit August rank references. */
export function unresolvedCoaArchiveSpellIds(reference, extraNodes = []) {
const spellById = new Map((reference?.spells ?? []).map((spell) => [Number(spell.id), spell]));
const required = new Set();
const add = (value) => {
const id = Number(value);
if (Number.isSafeInteger(id) && id > 0) required.add(id);
};
for (const node of extraNodes) {
for (const rank of node?.rankDescriptions ?? []) add(rank?.spellId);
}
for (const chain of reference?.progressionChains ?? []) {
for (const rank of chain?.ranks ?? []) add(rank?.spellId);
}
for (const spell of reference?.spells ?? []) {
if ((spell?.classIds ?? []).some((classId) => Number(classId) > 11)) add(spell.id);
}
return [...required]
.filter((id) => !spellById.get(id)?.dbc)
.sort((a, b) => a - b);
}
export async function main(args = process.argv.slice(2)) {
const options = parseArguments(args);
if (options.command === "help") {
process.stdout.write(usage);
return;
}
const outputRoot = path.resolve(options["output-root"] ?? path.join(projectRoot, "data/coa-reference"));
const cacheRoot = path.resolve(options["cache-root"] ?? path.join(outputRoot, "cache"));
const sourceOptions = {
projectRoot, clientRoot: options["client-root"], extractedRoot: options["extracted-root"],
};
if (options.command === "inventory") {
console.log(JSON.stringify(resolveSourceRoots(sourceOptions), null, 2));
return;
}
if (options.command === "fetch") {
console.log(JSON.stringify(await downloadSupplements({ cacheRoot, refresh: options.refresh ?? false }), null, 2));
return;
}
if (options.command === "promote") {
const { promoteCachedCoa } = await import("./promotion-command.mjs");
console.log(JSON.stringify(await promoteCachedCoa({ ...sourceOptions, cacheRoot }), null, 2));
return;
}
if (options.command === "fetch-spells") {
const { downloadArchiveSpells } = await import("./archive-spells.mjs");
const { normalizeArchiveNode } = await import("./promote.mjs");
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified talent cache is required; run coa:reference:fetch first");
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const reference = buildLocalReference({ ...sourceOptions, extraNodes });
const result = await downloadArchiveSpells({ cacheRoot, spellIds: unresolvedCoaArchiveSpellIds(reference, extraNodes) });
console.log(JSON.stringify({ source: result.source, records: result.records.length, files: result.files.length, missingIds: result.missingIds }, null, 2));
return;
}
if (options.command === "build") {
const supplements = await readSupplements({ cacheRoot });
const reference = await buildLocalReference(sourceOptions);
reference.supplementSource = supplements?.source ?? null;
const report = auditReference({ reference, supplements, projectRoot });
const validation = report.validation ?? validateReference(reference);
if (validation.errors?.length) {
throw new Error(`Reference validation failed; previous snapshot retained:\n${validation.errors.map((item) => item.message).join("\n")}`);
}
const result = publishSnapshot({ outputRoot, reference, report, markdown: renderAuditMarkdown(report) });
console.log(JSON.stringify({
snapshotId: result.snapshotId, report: path.join(result.directory, "audit.md"),
classes: reference.classes.length, nodes: reference.nodes.length, spells: reference.spells.length,
sourceFiles: reference.sources.length, supplement: reference.supplementSource,
validationErrors: validation.errors?.length ?? 0, validationWarnings: validation.warnings?.length ?? 0,
}, null, 2));
return;
}
const snapshot = readSnapshot(outputRoot);
if (options.command === "audit") {
const validation = validateReference(snapshot.reference);
console.log(JSON.stringify({
snapshotId: snapshot.manifest.snapshotId, report: path.join(snapshot.directory, "audit.md"),
sourceFiles: snapshot.reference.sources.length,
errors: validation.errors ?? [], warningCount: validation.warnings?.length ?? 0,
warningSample: (validation.warnings ?? []).slice(0, 10),
}, null, 2));
if (validation.errors?.length) throw new Error("Reference coverage validation failed");
return;
}
console.log(JSON.stringify(lookupReference(snapshot.reference, options), null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(`CoA reference: ${error.message}`);
process.exitCode = 1;
});
}