354 lines
16 KiB
JavaScript
354 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { buildLocalReference, resolveSourceRoots } from "./local-source.mjs";
|
|
import { freezeJulyBaseline } from "./baseline.mjs";
|
|
|
|
function makeDbc(records, fieldCount) {
|
|
const stringParts = [Buffer.from([0])];
|
|
let stringSize = 1;
|
|
const rows = records.map((record) => {
|
|
const fields = Array.from({ length: fieldCount }, (_, index) => Number(record.fields?.[index] ?? 0));
|
|
for (const [fieldIndex, value] of Object.entries(record.strings ?? {})) {
|
|
const encoded = Buffer.from(`${value}\0`, "utf8");
|
|
fields[Number(fieldIndex)] = stringSize;
|
|
stringParts.push(encoded);
|
|
stringSize += encoded.length;
|
|
}
|
|
const row = Buffer.alloc(fieldCount * 4);
|
|
for (let index = 0; index < fieldCount; index += 1) {
|
|
const value = fields[index];
|
|
if (record.floatFields?.includes(index)) row.writeFloatLE(value, index * 4);
|
|
else row.writeUInt32LE(value >>> 0, index * 4);
|
|
}
|
|
return row;
|
|
});
|
|
const recordsBuffer = Buffer.concat(rows);
|
|
const stringsBuffer = Buffer.concat(stringParts);
|
|
const header = Buffer.alloc(20);
|
|
header.write("WDBC", 0, "ascii");
|
|
header.writeUInt32LE(records.length, 4);
|
|
header.writeUInt32LE(fieldCount, 8);
|
|
header.writeUInt32LE(fieldCount * 4, 12);
|
|
header.writeUInt32LE(stringsBuffer.length, 16);
|
|
return Buffer.concat([header, recordsBuffer, stringsBuffer]);
|
|
}
|
|
|
|
function spell(id, name, { rank = "Rank 1", description = `${name} description`, trigger = 0, castTimeIndex = 0, durationIndex = 0 } = {}) {
|
|
const fields = Array(234).fill(0);
|
|
fields[0] = id;
|
|
fields[28] = castTimeIndex;
|
|
fields[40] = durationIndex;
|
|
fields[71] = trigger ? 64 : 0;
|
|
fields[116] = trigger;
|
|
fields[205] = 133;
|
|
fields[206] = 1500;
|
|
fields[136] = 0;
|
|
fields[153] = 0;
|
|
fields[170] = 0;
|
|
return { fields, strings: { 136: name, 153: rank, 170: description } };
|
|
}
|
|
|
|
function archiveSpellRow(id, name, { triggerSpellId = 0, effectType = 0, basePoints = 0 } = {}) {
|
|
const row = {};
|
|
for (let index = 0; index < 234; index += 1) row[`f${index}`] = 0;
|
|
row.f0 = id;
|
|
row.f71 = triggerSpellId ? 64 : effectType;
|
|
row.f80 = basePoints;
|
|
row.f86 = effectType ? 6 : 0;
|
|
row.f116 = triggerSpellId;
|
|
row.f136 = name;
|
|
row.f136i = 1;
|
|
row.f153 = "Rank 1";
|
|
row.f153i = 2;
|
|
row.f170 = `August global description for ${id}`;
|
|
row.f170i = 3;
|
|
row.f187 = "";
|
|
row.f187i = 0;
|
|
return row;
|
|
}
|
|
|
|
function writeFile(root, relativePath, value) {
|
|
const target = path.join(root, ...relativePath.split("/"));
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, value);
|
|
return target;
|
|
}
|
|
|
|
function createFixture({ includeCastTimes = true } = {}) {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-local-source-"));
|
|
const projectRoot = path.join(tempRoot, "HealerMan");
|
|
const clientRoot = path.join(tempRoot, "resources", "client");
|
|
const extractedRoot = path.join(tempRoot, "LadiksMPQEditor");
|
|
const dbcRoot = "client-current";
|
|
const classDefinitions = [
|
|
{ classId: 12, className: "Barbarian", tabs: [] },
|
|
{ classId: 14, className: "Felsworn", tabs: [] },
|
|
{ classId: 19, className: "Templar", tabs: [] },
|
|
{ classId: 20, className: "Bloodmage", tabs: [] },
|
|
];
|
|
const snapshot = {
|
|
source: { synchronizedAt: "2026-07-25T05:24:40.596Z" },
|
|
classes: classDefinitions,
|
|
entries: [{
|
|
id: 500,
|
|
classId: 14,
|
|
tabId: 1,
|
|
x: 0,
|
|
y: 0,
|
|
name: "Alias Ability",
|
|
rankDescriptions: [{ rank: 1, spellId: 1002, description: "Baseline rank description" }],
|
|
}],
|
|
progressionChains: [{
|
|
id: "coa-14-alias-1002",
|
|
classId: 14,
|
|
name: "Alias Ability",
|
|
ranks: [{ spellId: 1002, rank: 1, level: 10 }, { spellId: 1003, rank: 2, level: 20 }],
|
|
}],
|
|
};
|
|
writeFile(projectRoot, "src/game/coaLiveData.generated.json", `${JSON.stringify(snapshot)}\n`);
|
|
const advancementRows = [
|
|
{ ID: 500, Class: "DemonHunter", Name: "Alias Ability", Spells: [1002], Realms: "123" },
|
|
{ ID: 501, Class: "Monk", Name: "Templar Client Ability", Spells: [3001], Realms: "456" },
|
|
{ ID: 502, Class: "SonOfArugal", Name: "Bloodmage Client Ability", Spells: [3002], Realms: "789" },
|
|
{ ID: 503, Class: "Felsworn", Name: "Overlay Only", Spells: [4000], Realms: "101" },
|
|
];
|
|
writeFile(clientRoot, "Data/Content/CharacterAdvancementData.json", `${JSON.stringify(advancementRows)}\n`);
|
|
const spellRankRows = [
|
|
{ firstSpellId: 1002, spellId: 1002, rank: 1, level: 10 },
|
|
{ firstSpellId: 1002, spellId: 1003, rank: 2, level: 20 },
|
|
{ firstSpellId: 1002, spellId: 1004, rank: 2, level: 20 },
|
|
{ firstSpellId: 1002, spellId: 1005, rank: 2, level: 20 },
|
|
{ firstSpellId: 3001, spellId: 3001, rank: 1, level: 10 },
|
|
{ firstSpellId: 3002, spellId: 3002, rank: 1, level: 10 },
|
|
];
|
|
writeFile(clientRoot, "Data/Content/SpellRankData.json", `${JSON.stringify(spellRankRows)}\n`);
|
|
|
|
const clientClasses = [
|
|
[12, "Barbarian"], [14, "Felsworn"], [19, "Templar"], [20, "Bloodmage"],
|
|
].map(([id, name]) => ({ fields: [id], strings: { 4: name } }));
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrClasses.dbc`, makeDbc(clientClasses, 60));
|
|
const specs = clientClasses.map((row, index) => ({
|
|
fields: [index + 1],
|
|
strings: { 26: row.strings[4] },
|
|
}));
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/ChrSpecs.dbc`, makeDbc(specs, 65));
|
|
const skillRows = [
|
|
[1002, 14], [1003, 14], [1004, 13], [1005, 14], [3001, 19], [3002, 20],
|
|
].map(([spellId, classId], index) => {
|
|
const fields = Array(14).fill(0);
|
|
fields[0] = index + 1;
|
|
fields[1] = 100 + classId;
|
|
fields[2] = spellId;
|
|
fields[5] = 1 << (classId - 1);
|
|
return { fields };
|
|
});
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-M/DBFilesClient/SkillLineAbility.dbc`, makeDbc(skillRows, 14));
|
|
|
|
const baseSpells = [
|
|
spell(1002, "BASE Alias", { trigger: 2002, castTimeIndex: 1, durationIndex: 1 }),
|
|
spell(1003, "BASE Alias", { rank: "Rank 2", castTimeIndex: 2 }),
|
|
spell(1004, "Foreign Class Rank", { rank: "Rank 2" }),
|
|
spell(1005, "Second Same-Class Rank", { rank: "Rank 2" }),
|
|
spell(2002, "BASE Trigger", { rank: "" }),
|
|
spell(3001, "Templar Spell"),
|
|
spell(3002, "Bloodmage Spell"),
|
|
];
|
|
const baseSpellPath = writeFile(extractedRoot, `${dbcRoot}/patch-T/DBFilesClient/Spell.dbc`, makeDbc(baseSpells, 234));
|
|
const overlaySpells = [spell(1002, "OVERLAY Alias"), spell(4000, "OVERLAY Only")];
|
|
const overlaySpellPath = writeFile(extractedRoot, `${dbcRoot}/area-52/patch-D/DBFilesClient/Spell.dbc`, makeDbc(overlaySpells, 234));
|
|
if (includeCastTimes) {
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellCastTimes.dbc`, makeDbc([{ fields: [1, 125] }, { fields: [2, -1] }], 2));
|
|
}
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellDuration.dbc`, makeDbc([{ fields: [1, 0, 0, -1] }], 4));
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRadius.dbc`, makeDbc([{ fields: [1, 0, 0, 3.5], floatFields: [3] }], 4));
|
|
writeFile(extractedRoot, `${dbcRoot}/patch-S/DBFilesClient/SpellRange.dbc`, makeDbc([{ fields: [1, 0, 0, 0, 40], floatFields: [1, 2, 3, 4] }], 5));
|
|
writeFile(clientRoot, "Data/area-52/listarchive", "patch-D.MPQ\n");
|
|
writeFile(extractedRoot, `${dbcRoot}/README.md`, "area-52/patch-D/DBFilesClient provides the realm override. Global patch-T Spell.dbc is retained as a fallback only.\n");
|
|
return {
|
|
tempRoot,
|
|
projectRoot,
|
|
clientRoot,
|
|
extractedRoot,
|
|
baseSpellPath,
|
|
overlaySpellPath,
|
|
options: { projectRoot, clientRoot, extractedRoot },
|
|
};
|
|
}
|
|
|
|
function cleanup(fixture) {
|
|
fs.rmSync(fixture.tempRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
test("resolveSourceRoots keeps the client and extraction roots paired", () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "coa-roots-"));
|
|
try {
|
|
const projectRoot = path.join(tempRoot, "HealerMan");
|
|
const siblingClient = path.join(tempRoot, "resources", "client");
|
|
const siblingExtracts = path.join(tempRoot, "LadiksMPQEditor");
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(siblingClient, { recursive: true });
|
|
fs.mkdirSync(siblingExtracts, { recursive: true });
|
|
const resolved = resolveSourceRoots({ projectRoot });
|
|
assert.equal(resolved.rootSet, "F");
|
|
assert.equal(resolved.clientRoot, siblingClient);
|
|
assert.equal(resolved.extractedRoot, siblingExtracts);
|
|
const supplied = resolveSourceRoots({ projectRoot, clientRoot: siblingClient, extractedRoot: siblingExtracts });
|
|
assert.equal(supplied.rootSet, "supplied");
|
|
assert.equal(supplied.clientRoot, siblingClient);
|
|
assert.equal(supplied.extractedRoot, siblingExtracts);
|
|
assert.throws(() => resolveSourceRoots({ projectRoot, clientRoot: siblingClient }), /together/);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildLocalReference keeps patch-T as BASE and closes node, CAD, and trigger spell references", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const result = buildLocalReference(fixture.options);
|
|
const bySpellId = new Map(result.spells.map((entry) => [entry.id, entry]));
|
|
assert.equal(result.schemaVersion, 1);
|
|
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
|
|
assert.equal(result.classes.find((definition) => definition.classId === 14).gameId, "felsworn");
|
|
assert.deepEqual(result.classes.find((definition) => definition.classId === 14).aliases, ["DemonHunter"]);
|
|
assert.equal(result.classes.find((definition) => definition.classId === 14).clientClassId, 14);
|
|
assert.equal(bySpellId.get(1002).name, "BASE Alias");
|
|
assert.equal(bySpellId.get(1002).dbc.castTimeMs, 125);
|
|
assert.equal(bySpellId.get(1002).dbc.durationMs, -1);
|
|
assert.equal(bySpellId.get(1003).dbc.castTimeMs, -1);
|
|
assert.equal(bySpellId.get(1002).dbc.startRecoveryTimeMs, 1500);
|
|
assert.equal(bySpellId.get(2002).name, "BASE Trigger");
|
|
assert.ok(bySpellId.get(2002).sourceEvidence.some((evidence) => evidence.kind === "dbc-effect-trigger" && evidence.sourceSpellId === 1002));
|
|
assert.equal(bySpellId.get(4000).dbc, undefined);
|
|
assert.ok(result.diagnostics.some((item) => item.code === "missing-spell-reference" && item.spellId === 4000 && item.overlayOnly));
|
|
assert.deepEqual(result.inventory.spellTables.base.recordCount, 7);
|
|
assert.equal(result.inventory.spellTables.base.sha256, crypto.createHash("sha256").update(fs.readFileSync(fixture.baseSpellPath)).digest("hex"));
|
|
assert.equal(result.inventory.spellTables.overlay.recordCount, 2);
|
|
assert.ok(result.diagnostics.some((item) => item.code === "source-order-conflict"));
|
|
assert.ok(result.sources.every((source) => !path.isAbsolute(source.path)));
|
|
|
|
const ability = result.abilities.find((entry) => entry.name === "Alias Ability");
|
|
assert.equal(ability.classId, 14);
|
|
assert.equal(ability.live, null);
|
|
assert.deepEqual(ability.nodeIds, [500]);
|
|
assert.deepEqual(ability.rankSpellIds, [1002, 1003, 1005]);
|
|
assert.ok(result.diagnostics.some((item) => item.code === "ambiguous-rank-join" && item.abilityKey === ability.key && item.rank === 2));
|
|
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 19).advancementRows.some((row) => row.Class === "Monk"));
|
|
assert.ok(result.classCatalogs.find((catalog) => catalog.classId === 20).advancementRows.some((row) => row.Class === "SonOfArugal"));
|
|
assert.notEqual(fixture.overlaySpellPath, fixture.baseSpellPath);
|
|
} finally {
|
|
cleanup(fixture);
|
|
}
|
|
});
|
|
|
|
test("malformed BASE Spell.dbc is rejected instead of falling back to the overlay", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
fs.writeFileSync(fixture.baseSpellPath, Buffer.from("not a dbc"));
|
|
assert.throws(() => buildLocalReference(fixture.options), /WDBC|truncated/);
|
|
} finally {
|
|
cleanup(fixture);
|
|
}
|
|
});
|
|
|
|
test("missing optional cast-time support leaves a nonzero cast index unresolved", () => {
|
|
const fixture = createFixture({ includeCastTimes: false });
|
|
try {
|
|
const result = buildLocalReference(fixture.options);
|
|
const spell = result.spells.find((entry) => entry.id === 1002);
|
|
assert.equal(spell.dbc.castTimeIndex, 1);
|
|
assert.equal(spell.dbc.castTimeMs, null);
|
|
assert.ok(result.diagnostics.some((item) => item.code === "missing-optional-support-dbc" && item.sourceId === "spell-cast-times"));
|
|
} finally {
|
|
cleanup(fixture);
|
|
}
|
|
});
|
|
|
|
test("August seeds resolve new ranks and effect-168 dependencies without replacing July nodes", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
freezeJulyBaseline(fixture.projectRoot);
|
|
writeFile(fixture.projectRoot, "src/game/coaLiveData.generated.json", '{"source":{"synchronizedAt":"2026-08-12"}}');
|
|
const newSpell = spell(9002, "August Spell");
|
|
newSpell.fields[71] = 168;
|
|
newSpell.fields[110] = 9003;
|
|
fs.writeFileSync(fixture.baseSpellPath, makeDbc([
|
|
...[1002, 1003, 1004, 1005, 2002, 3001, 3002].map((id) => spell(id, `Spell ${id}`)),
|
|
newSpell, spell(9003, "August Payload"),
|
|
], 234));
|
|
const result = buildLocalReference({ ...fixture.options, extraNodes: [
|
|
{ id: 901, classId: 14, rankDescriptions: [{ rank: 1, spellId: 9002 }] },
|
|
] });
|
|
assert.equal(result.baseline.capturedAt, "2026-07-25T05:24:40.596Z");
|
|
assert.deepEqual(result.nodes.map((node) => node.id), [500]);
|
|
assert.equal(result.spells.find((row) => row.id === 9002).dbc.name, "August Spell");
|
|
assert.equal(result.spells.find((row) => row.id === 9003).dbc.name, "August Payload");
|
|
assert.ok(result.spells.find((row) => row.id === 9002).sourceEvidence.some((row) => row.kind === "august-node-rank"));
|
|
} finally {
|
|
cleanup(fixture);
|
|
}
|
|
});
|
|
|
|
test("July CoA spell identity seeds archived BASE rows and transfers ownership to missing trigger payloads", () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const activePath = path.join(fixture.projectRoot, "src/game/coaLiveData.generated.json");
|
|
const july = JSON.parse(fs.readFileSync(activePath, "utf8"));
|
|
july.spells = [{
|
|
id: 630868,
|
|
classIds: [27],
|
|
name: "Old Rejuvenating Venom",
|
|
rank: "Rank 1",
|
|
description: "Old Area52 description",
|
|
dbc: { id: 630868, name: "Area52 Rejuvenating Venom", effects: [] },
|
|
}];
|
|
fs.writeFileSync(activePath, `${JSON.stringify(july)}\n`);
|
|
freezeJulyBaseline(fixture.projectRoot);
|
|
fs.writeFileSync(fixture.overlaySpellPath, makeDbc([spell(630868, "Area52 Rejuvenating Venom")], 234));
|
|
|
|
const withoutArchive = buildLocalReference(fixture.options);
|
|
const unresolved = withoutArchive.spells.find((row) => row.id === 630868);
|
|
assert.deepEqual(unresolved.classIds, [27]);
|
|
assert.equal(unresolved.dbc, undefined);
|
|
assert.notEqual(unresolved.name, "Old Rejuvenating Venom");
|
|
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "july-spell-identity" && item.classId === 27));
|
|
assert.ok(unresolved.sourceEvidence.some((item) => item.kind === "comparative-overlay-presence-only"));
|
|
|
|
const archiveSource = {
|
|
id: "august-global-spell-base",
|
|
path: "raw/tables/Spell/variants/data-patch-t-mpq",
|
|
sha256: "archive-index-fixture",
|
|
bytes: 123,
|
|
};
|
|
const archivedSpells = {
|
|
source: archiveSource,
|
|
fieldTypes: Array.from({ length: 234 }, (_, index) => (
|
|
[47, 77, 78, 79, 102, 103, 119, 120, 121, 216, 217, 218, 229, 230, 231].includes(index)
|
|
? "float"
|
|
: [136, 153, 170, 187].includes(index) ? "string" : "int"
|
|
)),
|
|
records: [
|
|
archiveSpellRow(630868, "August Rejuvenating Venom", { triggerSpellId: 630869 }),
|
|
archiveSpellRow(630869, "August Venom Payload", { effectType: 2, basePoints: 49 }),
|
|
],
|
|
files: [{ path: "00600000-00699999.jsonl.gz" }],
|
|
};
|
|
const withArchive = buildLocalReference({ ...fixture.options, archivedSpells });
|
|
const root = withArchive.spells.find((row) => row.id === 630868);
|
|
const payload = withArchive.spells.find((row) => row.id === 630869);
|
|
assert.equal(root.dbc.name, "August Rejuvenating Venom");
|
|
assert.deepEqual(root.classIds, [27]);
|
|
assert.ok(root.sourceEvidence.some((item) => item.kind === "base-spell-dbc-row" && item.sourceId === archiveSource.id));
|
|
assert.equal(payload.dbc.name, "August Venom Payload");
|
|
assert.deepEqual(payload.classIds, [27]);
|
|
assert.ok(payload.sourceEvidence.some((item) => item.kind === "trigger-payload-owner" && item.classId === 27));
|
|
assert.ok(!JSON.stringify(withArchive.spells).includes("Area52 Rejuvenating Venom"));
|
|
} finally {
|
|
cleanup(fixture);
|
|
}
|
|
});
|