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