forked from phenom/healer-man
55 lines
3.6 KiB
JavaScript
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 } };
|
|
}
|