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