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; }); }