208 lines
10 KiB
JavaScript
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 });
|
|
}
|
|
});
|