Files
healer-man/scripts/coa-reference/promotion-command.mjs
T

55 lines
3.6 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { activeCatalogPath, decodeJsonBytes, freezeJulyBaseline } from "./baseline.mjs";
import { buildLocalReference } from "./local-source.mjs";
import { buildCoaPromotion, normalizeArchiveNode } from "./promote.mjs";
import { readSupplements } from "./supplements.mjs";
import { validateReference } from "./audit.mjs";
import { stableJson } from "./snapshot.mjs";
import { readArchiveSpells } from "./archive-spells.mjs";
/** Explicit offline promotion, distinct from the historical reference build. */
export async function promoteCachedCoa({ projectRoot, cacheRoot, ...sourceOptions }) {
const supplements = await readSupplements({ cacheRoot });
if (!supplements) throw new Error("Verified August cache is required; run coa:reference:fetch once");
const baselinePath = freezeJulyBaseline(projectRoot);
const previous = JSON.parse(decodeJsonBytes(fs.readFileSync(baselinePath), baselinePath).toString("utf8"));
const triggered = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/generated/coaTriggeredSpellCatalog.json"), "utf8"));
previous.classicTriggeredSpells = triggered.classicSpells ?? triggered.spells;
const extraNodes = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
const archivedSpells = await readArchiveSpells({ cacheRoot });
const reference = buildLocalReference({ projectRoot, ...sourceOptions, extraNodes, archivedSpells });
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
const spellById = new Map([...candidate.spells, ...(candidate.coaSpellOverrides ?? [])].map((spell) => [spell.id, spell]));
const validation = validateReference({
...reference,
classes: reference.classes.map((definition) => ({ ...definition, tabs: candidate.classes.find((item) => item.classId === definition.classId).tabs })),
nodes: candidate.entries,
spells: [...spellById.values()],
});
if (validation.errors.length) throw new Error(`Promotion validation failed; active catalog retained: ${validation.errors.map((item) => item.message).join("; ")}`);
for (const entry of candidate.entries) {
for (const rank of entry.rankDescriptions) {
if (!spellById.get(rank.spellId)?.dbc) throw new Error(`August node ${entry.id} rank ${rank.rank} lacks global Spell.dbc evidence; active catalog retained`);
}
}
const candidateBytes = `${JSON.stringify(candidate, null, 2)}\n`;
report.validation = validation;
report.candidateSha256 = createHash("sha256").update(candidateBytes).digest("hex");
const reportPath = path.join(projectRoot, "data/coa-reference/promotion-report.json");
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, stableJson(report));
const activePath = path.join(projectRoot, activeCatalogPath);
const temporaryPath = `${activePath}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporaryPath, candidateBytes, { flag: "wx" });
if (createHash("sha256").update(fs.readFileSync(temporaryPath)).digest("hex") !== report.candidateSha256) throw new Error("Staged promotion checksum mismatch");
fs.renameSync(temporaryPath, activePath);
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
}
return { reportPath, candidateSha256: report.candidateSha256, counts: candidate.counts, validationWarnings: validation.warnings.length,
gaps: { missingBaseSpellCount: report.gaps.missingBaseSpellCount, missingBaseProgressionRankIds: report.gaps.missingBaseProgressionRankIds } };
}