forked from phenom/healer-man
151 lines
7.6 KiB
JavaScript
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;
|
|
});
|
|
}
|