Files

208 lines
10 KiB
JavaScript

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: "<span>A test mechanic.</span>" }] },
{ ...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 });
}
});