Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+353
View File
@@ -0,0 +1,353 @@
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);
}
});