import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { auditReference, renderAuditMarkdown, validateReference } from "./audit.mjs"; const gameIds = [ "barbarian", "witch-doctor", "felsworn", "witch-hunter", "stormbringer", "knight-of-xoroth", "guardian", "templar", "bloodmage", "ranger", "chronomancer", "necromancer", "pyromancer", "cultist", "starcaller", "sun-cleric", "tinker", "venomancer", "reaper", "primalist", "runemaster", ]; function sourceRow(id = "base-spell") { return { id, path: "src/spells.dbc", sha256: "a".repeat(64), bytes: 100, kind: "dbc" }; } function spell(id, triggerSpellId = 0, name = "Spell " + id) { const dbc = { id, name, rankText: "Rank 1", description: "A test spell.", castTimeMs: 0, procChance: 100, effects: [{ index: 0, effectType: triggerSpellId ? 64 : 2, auraType: 0, basePoints: 1, triggerSpellId }], }; return { id, name, rank: "Rank 1", description: dbc.description, dbc, source: "base", classIds: [12] }; } function node(id, overrides = {}) { return { id, classId: 12, tabId: 1, x: 0, y: 0, name: "Node " + id, nodeType: "SpendSquare", entryType: "Talent", isPassive: false, maxPoints: 1, aeCost: 0, teCost: 1, requiredClassPoints: 0, requiredSpecPoints: 0, requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: false, iconBasename: "test", flags: 0, group: 7, sortOrder: id, description: "A test mechanic.", rankDescriptions: [{ rank: 1, spellId: id === 100 ? 200 : 201, description: "A test mechanic." }], ...overrides, }; } function referenceFixture() { return { schemaVersion: 1, baseline: { kind: "local-july", capturedAt: "2026-07-01T00:00:00Z" }, classes: gameIds.map((gameId, index) => ({ classId: index + 12, gameId, className: gameId, tabs: index === 0 ? [{ tabId: 1, tabName: "First", sortOrder: 1 }] : [], })), nodes: [node(100), node(101, { group: 7 })], spells: [spell(200, 201), spell(201)], progressionChains: [{ id: "chain-a", classId: 12, name: "Test chain", iconBasename: "test", talented: false, advancementType: "Ability", trainer: false, ranks: [{ rank: 1, spellId: 200, level: 1 }], }], sources: [sourceRow()], diagnostics: [], abilities: [{ key: "alias-map", classId: "DemonHunter", name: "Alias fixture", spellIds: [200], nodeIds: [], rankSpellIds: [201], live: null, evidence: "DBC class alias maps to CoA ID 14.", }], }; } function writeJson(root, relative, value) { const full = path.join(root, relative); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, JSON.stringify(value) + "\n"); } function makeProjectRoot() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "coa-reference-audit-")); writeJson(root, "src/game/coaLiveData.generated.json", { spells: [ { id: 200, dbc: { ...spell(200).dbc, castTimeMs: 1500 } }, { id: 201, dbc: spell(201).dbc }, { id: 203, dbc: spell(203).dbc }, ], }); writeJson(root, "src/game/generated/coaAbilityRuntimeCatalog.json", { schemaVersion: 1, abilitiesByClass: { barbarian: [{ id: "coa-tree-100", classId: "barbarian", talentEntryId: 100, dbcSpellId: 200 }], }, resourcesByClass: { barbarian: { primary: "energy" } }, triggeredAbilitiesBySpellId: { "201": { id: "coa-trigger-201", classId: "barbarian", dbcSpellId: 201 } }, }); writeJson(root, "src/game/generated/talentRuntimeCatalog.json", { schemaVersion: 1, trees: [{ id: "coa-barbarian-1", classId: "barbarian" }], nodes: [{ id: "coa-entry-100", index: 100, treeId: "coa-barbarian-1", system: "coa" }], }); writeJson(root, "src/game/generated/coaTriggeredSpellCatalog.json", { schemaVersion: 1, spells: [spell(201).dbc], }); return root; } test("validation resolves DBC class aliases and flags unresolved references as incomplete warnings", () => { const reference = referenceFixture(); reference.nodes[0].requiredIds = [999]; reference.nodes[0].rankDescriptions[0].spellId = 999; reference.spells[1].dbc.effects[0].triggerSpellId = 998; const result = validateReference(reference); assert.equal(result.valid, true); assert.equal(result.complete, false); assert.equal(result.counts.missingRankSpells, 1); assert.equal(result.counts.missingPrerequisites, 1); assert.equal(result.counts.missingTriggeredSpells, 1); assert(result.warnings.some((item) => item.code === "missing-rank-spell" && item.spellId === 999)); assert(result.warnings.some((item) => item.code === "missing-prerequisite" && item.targetId === 999)); assert(result.warnings.some((item) => item.code === "unknown-triggered-spell" && item.spellId === 998)); assert(!result.errors.some((item) => item.code === "invalid-ability-class-id")); }); test("validation reports malformed and duplicate keys, cross-class edges, and invalid choice groups as errors", () => { const reference = referenceFixture(); reference.classes[1].tabs = [{ tabId: 2, tabName: "Second", sortOrder: 2 }]; reference.nodes[0].requiredIds = [300]; reference.nodes.push(node(300, { classId: 13, tabId: 2, group: 7 })); reference.nodes.push(node(100)); reference.nodes[1].tabId = 2; reference.nodes[1].requiredIds = [0]; reference.sources.push(sourceRow()); const result = validateReference(reference); assert.equal(result.valid, false); assert(result.errors.some((item) => item.code === "duplicate-node-key")); assert(result.errors.some((item) => item.code === "cross-class-node-reference" && item.targetId === 300)); assert(result.errors.some((item) => item.code === "choice-group-cross-tab")); assert(result.errors.some((item) => item.code === "invalid-node-reference-id")); assert(result.errors.some((item) => item.code === "duplicate-source-key")); }); test("audit compares July and August without merging, checks DBC fields, hashes inputs, and avoids implementation claims", () => { const projectRoot = makeProjectRoot(); try { const reference = referenceFixture(); reference.spells.push({ id: 202, name: "Placeholder", rank: "", description: "", source: "skill-line", classIds: [12] }); reference.sources[0].path = "src/game/coaLiveData.generated.json"; const supplements = { source: { revision: "5078f9ac22541c45c17a866c23aac6c12073de23", capturedAt: "2026-08-12T21:02:51+00:00" }, classes: [{ classId: 12, tabs: [{ tabId: 1, tabName: "First", sortOrder: 1, maxReqTabAE: 4, maxReqTabTE: 5, nodes: [ { ...node(100), name: "Changed name", reqTabAE: 0, reqTabTE: 1, isPassive: 0, isStartingNode: 0, spellId: 200, spellIds: [200], iconPath: "Interface/Icons/TEST.blp", rankDescriptions: [{ rank: 1, spellId: 200, description: "A test mechanic." }] }, { ...node(102), spellId: 202, spellIds: [202] }, ], }], }], manifest: { files: [{ path: "src/game/coaLiveData.generated.json", sha256: "b".repeat(64) }] }, metadata: { provenance: { source: "archive" }, clientSnapshot: { capturedAt: "2026-08-01T00:00:00Z" }, spells: { revision: "spell-set-1" } }, }; const report = auditReference({ reference, supplements, projectRoot }); const julyAugust = report.comparisons.julyToAugust; assert.deepEqual(julyAugust.counts, { treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 1, nodesRemoved: 1, nodesChanged: 1, }); assert.deepEqual(julyAugust.changes.nodes[0].changedFields, ["name"]); assert.equal(report.counts.nodes, 2); assert.equal(report.classes.find((item) => item.classId === 12).counts.nodes, 2); assert.equal(report.classes.find((item) => item.classId === 12).counts.spells, 3); assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).runtimeAbilityMappings, 1); assert.equal(report.runtimeEvidence.classes.find((item) => item.classId === 12).talentRuntimeNodes, 1); assert.equal(report.comparisons.baseDbcToArea52.counts.baseRecords, 2); assert.equal(report.comparisons.baseDbcToArea52.counts.baseMissingDbcRecords, 1); assert.equal(report.comparisons.baseDbcToArea52.counts.area52Records, 3); assert.equal(report.comparisons.baseDbcToArea52.counts.area52Only, 1); assert.equal(report.comparisons.baseDbcToArea52.counts.changedRecords, 1); assert.deepEqual(report.comparisons.baseDbcToArea52.changedFields, { castTimeMs: 1, effects: 1 }); assert.deepEqual(report.rankIdentityCoverage.nodeRanks, { missingSpellRecords: 0, missingBaseDbcRows: 0 }); assert.equal(report.supplementalFreshness.sourceHashes.changed, 1); assert.equal(report.runtimeEvidence.behavioralMechanics.status, "not-assessed"); assert.deepEqual(report.runtimeEvidence.behavioralMechanics.provenImplementedClaims, []); assert(report.classes.every((item) => item.backlog.some((backlog) => backlog.priority === "player-companion-mechanics"))); const markdown = renderAuditMarkdown(report); assert(markdown.includes("1 reference spell records do not include a base DBC row")); assert(markdown.includes("player or companion")); assert(markdown.includes("No August supplemental cache") === false); assert(markdown.indexOf("Changed spell samples:\n\n-") >= 0); assert(markdown.indexOf("July/August tree and node samples:\n\n-") >= 0); assert(!markdown.includes("implemented: yes")); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } }); test("missing supplements are explicitly reported and preserve baseline data", () => { const projectRoot = makeProjectRoot(); try { const reference = referenceFixture(); const report = auditReference({ reference, supplements: null, projectRoot }); assert.equal(report.comparisons.julyToAugust.status, "absent"); assert.equal(report.supplementalCache.status, "absent"); assert.deepEqual(report.comparisons.julyToAugust.counts, { treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0, }); assert.equal(renderAuditMarkdown(report).includes("No supplemental cache was supplied"), true); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } });