forked from phenom/healer-man
Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -0,0 +1,292 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildCoaPromotion, COA_PROMOTION_ARCHIVE, normalizeArchiveNode } from "./promote.mjs";
|
||||
|
||||
const classNames = [
|
||||
"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 archiveNode({ classId, id, spellId, description = "Archive description", tabId = 30 + classId }) {
|
||||
return {
|
||||
id,
|
||||
classId,
|
||||
tabId,
|
||||
x: 1,
|
||||
y: 2,
|
||||
name: `Class ${classId} node`,
|
||||
iconPath: "Interface\\Icons\\TEST_ICON.blp",
|
||||
nodeType: "SpendSquare",
|
||||
entryType: "Talent",
|
||||
isPassive: 0,
|
||||
maxPoints: 1,
|
||||
aeCost: 0,
|
||||
teCost: 1,
|
||||
reqTabAE: 0,
|
||||
reqTabTE: 0,
|
||||
requiredIds: [],
|
||||
connectedNodeIds: [],
|
||||
requiredLevel: 0,
|
||||
isStartingNode: 0,
|
||||
flags: 0,
|
||||
group: 0,
|
||||
sortOrder: 0,
|
||||
description,
|
||||
rankDescriptions: [{ rank: 1, spellId, description }],
|
||||
};
|
||||
}
|
||||
|
||||
function dbc(id, { description = `Base description ${id}`, effects = [] } = {}) {
|
||||
return { id, name: `Base spell ${id}`, rankText: "Rank 1", description, effects };
|
||||
}
|
||||
|
||||
function spell(id, classIds, { description = `Classic description ${id}`, effects = [], liveTooltip } = {}) {
|
||||
return {
|
||||
id,
|
||||
classIds,
|
||||
name: `Spell ${id}`,
|
||||
iconBasename: "classic_icon",
|
||||
level: 1,
|
||||
rank: "Rank 1",
|
||||
description,
|
||||
...(liveTooltip ? { liveTooltip } : {}),
|
||||
sourceIds: [],
|
||||
advancementType: "",
|
||||
advancementTabId: 0,
|
||||
talented: false,
|
||||
excluded: false,
|
||||
dbc: { ...dbc(id), marker: "Area52 overlay row" },
|
||||
};
|
||||
}
|
||||
|
||||
function fixtures({ missingCoaRanks = [] } = {}) {
|
||||
const previousClasses = classNames.map((className, index) => ({
|
||||
classId: index + 12,
|
||||
className,
|
||||
maxTalentEssence: 25,
|
||||
maxAbilityEssence: 26,
|
||||
tabs: [{ tabId: 30 + index + 12, tabName: "July tab", sortOrder: 1 }],
|
||||
}));
|
||||
const archiveClasses = previousClasses.map((definition, index) => {
|
||||
const classId = definition.classId;
|
||||
const spellId = classId === 12 ? 100 : 200 + classId;
|
||||
const node = archiveNode({
|
||||
classId,
|
||||
id: 12000 + classId,
|
||||
spellId,
|
||||
description: classId === 12 ? "<b>August & improved</b><br>rank" : `August ${classId}`,
|
||||
});
|
||||
return {
|
||||
classId,
|
||||
class: definition.className,
|
||||
builderClassName: definition.className,
|
||||
essence: { maxTalentEssence: 99, maxAbilityEssence: 98 },
|
||||
tabs: [
|
||||
{ tabId: node.tabId, tabName: "August tab", sortOrder: 1, entryCount: 1, isEmpty: false },
|
||||
{ tabId: 900 + classId, tabName: "Empty tab", sortOrder: 2, entryCount: 0, isEmpty: true },
|
||||
],
|
||||
nodes: [node],
|
||||
};
|
||||
});
|
||||
const archiveEntries = archiveClasses.flatMap((definition) => definition.nodes.map(normalizeArchiveNode));
|
||||
const julyEntries = structuredClone(archiveEntries);
|
||||
julyEntries.find((node) => node.classId === 12).description = "July-only description";
|
||||
julyEntries.find((node) => node.classId === 12).rankDescriptions[0].description = "July rank description";
|
||||
const classicChain = {
|
||||
id: "classic-preserved-chain",
|
||||
classId: 1,
|
||||
name: "Classic chain",
|
||||
iconBasename: "classic",
|
||||
talented: false,
|
||||
advancementType: "",
|
||||
trainer: true,
|
||||
ranks: [{ spellId: 100, rank: 1, level: 1 }],
|
||||
};
|
||||
const coaChain = {
|
||||
id: "coa-stable-chain",
|
||||
classId: 12,
|
||||
name: "Stable CoA chain",
|
||||
iconBasename: "coa",
|
||||
talented: false,
|
||||
advancementType: "",
|
||||
trainer: false,
|
||||
ranks: [
|
||||
{ spellId: 91300, rank: 1, level: 1 },
|
||||
...missingCoaRanks.map((spellId, index) => ({ spellId, rank: index + 2, level: index + 2 })),
|
||||
],
|
||||
};
|
||||
const sharedSpell = spell(100, [1, 12], { liveTooltip: "stale Area52 tooltip" });
|
||||
const classicTriggered = spell(101, [5], { liveTooltip: "classic tooltip" });
|
||||
const classicPayload = spell(102, [5]);
|
||||
const classicClosureSeed = spell(500, [2]);
|
||||
const classicEmptyOwnerChild = spell(501, []);
|
||||
const classicEmptyOwnerPayload = spell(502, []);
|
||||
const staleCoa = spell(999, [12], { liveTooltip: "stale CoA tooltip" });
|
||||
const previous = {
|
||||
source: { realmSlug: "voljin", spellDbcPath: "C:/client/area-52/patch-D/DBFilesClient/Spell.dbc" },
|
||||
counts: {},
|
||||
classes: previousClasses,
|
||||
entries: julyEntries,
|
||||
progressionChains: [classicChain, coaChain],
|
||||
spells: [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload, staleCoa],
|
||||
classicTriggeredSpells: [
|
||||
{ id: 500, effects: [{ effectType: 64, triggerSpellId: 501, miscValue: 0 }] },
|
||||
{ id: 501, effects: [{ effectType: 168, triggerSpellId: 0, miscValue: 502 }] },
|
||||
],
|
||||
};
|
||||
const allArchiveRanks = archiveClasses.flatMap((definition) => definition.nodes.flatMap((node) => node.rankDescriptions));
|
||||
const referenceSpellMap = new Map();
|
||||
for (const rank of allArchiveRanks) {
|
||||
if (missingCoaRanks.includes(rank.spellId)) continue;
|
||||
let effects = [];
|
||||
if (rank.spellId === 100) {
|
||||
effects = [
|
||||
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
|
||||
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
|
||||
];
|
||||
}
|
||||
referenceSpellMap.set(rank.spellId, {
|
||||
id: rank.spellId,
|
||||
classIds: rank.spellId === 100 ? [1, 12] : [12],
|
||||
name: `Base spell ${rank.spellId}`,
|
||||
rank: "Rank 1",
|
||||
level: 1,
|
||||
description: `July reference description ${rank.spellId}`,
|
||||
dbc: dbc(rank.spellId, { effects }),
|
||||
});
|
||||
}
|
||||
for (const classicSpell of [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload]) {
|
||||
referenceSpellMap.set(classicSpell.id, {
|
||||
id: classicSpell.id,
|
||||
classIds: classicSpell.classIds,
|
||||
name: `BASE ${classicSpell.id}`,
|
||||
rank: "Rank 1",
|
||||
level: 1,
|
||||
description: `BASE ${classicSpell.id} description`,
|
||||
dbc: dbc(classicSpell.id, {
|
||||
effects: classicSpell.id === 100
|
||||
? [
|
||||
{ effectType: 64, triggerSpellId: 101, miscValue: 0 },
|
||||
{ effectType: 168, triggerSpellId: 0, miscValue: 102 },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
const supplements = {
|
||||
source: {
|
||||
...COA_PROMOTION_ARCHIVE,
|
||||
capturedAt: COA_PROMOTION_ARCHIVE.capturedAt,
|
||||
},
|
||||
classes: archiveClasses,
|
||||
};
|
||||
const reference = {
|
||||
classes: previousClasses,
|
||||
nodes: [],
|
||||
progressionChains: [classicChain, coaChain],
|
||||
spells: [...referenceSpellMap.values()].sort((left, right) => left.id - right.id),
|
||||
sources: [{ id: "global-spell-base", path: "extracts/client-current/patch-T/DBFilesClient/Spell.dbc", role: "base" }],
|
||||
};
|
||||
return { previous, reference, supplements, archiveEntries, classicChain, coaChain };
|
||||
}
|
||||
|
||||
test("normalizeArchiveNode matches the old sync entry shape and cleans archive text", () => {
|
||||
const normalized = normalizeArchiveNode({
|
||||
...archiveNode({ classId: 12, id: 1, spellId: 2, description: "<span>One</span><br><br>Two & three" }),
|
||||
name: "@6New Name",
|
||||
iconPath: "Interface\\Icons\\TEST_ICON.TGA",
|
||||
isPassive: 127,
|
||||
isStartingNode: 0,
|
||||
reqTabAE: undefined,
|
||||
reqTabTE: undefined,
|
||||
requiredClassPoints: 5,
|
||||
requiredSpecPoints: 6,
|
||||
});
|
||||
assert.deepEqual(normalized, {
|
||||
id: 1,
|
||||
classId: 12,
|
||||
tabId: 42,
|
||||
x: 1,
|
||||
y: 2,
|
||||
name: "New Name",
|
||||
iconBasename: "test_icon",
|
||||
nodeType: "SpendSquare",
|
||||
entryType: "Talent",
|
||||
isPassive: true,
|
||||
maxPoints: 1,
|
||||
aeCost: 0,
|
||||
teCost: 1,
|
||||
requiredClassPoints: 5,
|
||||
requiredSpecPoints: 6,
|
||||
requiredIds: [],
|
||||
connectedNodeIds: [],
|
||||
requiredLevel: 0,
|
||||
isStartingNode: false,
|
||||
flags: 0,
|
||||
group: 0,
|
||||
sortOrder: 0,
|
||||
description: "One Two & three",
|
||||
rankDescriptions: [{ rank: 1, spellId: 2, description: "One Two & three" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("promotion retains classic data and old node and chain identities while using August nodes and BASE spells", () => {
|
||||
const { previous, reference, supplements, archiveEntries, classicChain, coaChain } = fixtures();
|
||||
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
|
||||
const oldClassicSpells = previous.spells.filter((item) => item.classIds.some((classId) => classId <= 11));
|
||||
assert.deepEqual(candidate.progressionChains[0], classicChain);
|
||||
assert.deepEqual(candidate.spells.filter((item) => oldClassicSpells.some((old) => old.id === item.id)), oldClassicSpells);
|
||||
for (const id of [500, 501, 502]) {
|
||||
assert.deepEqual(candidate.spells.find((item) => item.id === id), previous.spells.find((item) => item.id === id));
|
||||
}
|
||||
assert.deepEqual(candidate.entries, archiveEntries);
|
||||
assert.deepEqual(candidate.progressionChains[1], coaChain);
|
||||
assert.equal(candidate.classes[0].tabs.length, 1);
|
||||
assert.equal(candidate.classes[0].maxTalentEssence, 25);
|
||||
assert.equal(candidate.entries[0].description, "August & improved rank");
|
||||
assert.equal(candidate.spells.find((item) => item.id === 100).description, "Classic description 100");
|
||||
assert.equal(candidate.spells.find((item) => item.id === 100).dbc.marker, "Area52 overlay row");
|
||||
const overrideById = new Map(candidate.coaSpellOverrides.map((item) => [item.id, item]));
|
||||
for (const id of [100, 101, 102]) {
|
||||
assert.equal(overrideById.has(id), true, `expected CoA-context override for linked spell ${id}`);
|
||||
assert.equal(overrideById.get(id).dbc.id, id);
|
||||
assert.equal(overrideById.get(id).dbc.marker, undefined);
|
||||
assert.deepEqual(overrideById.get(id).classIds, [12]);
|
||||
assert.equal("liveTooltip" in overrideById.get(id), false);
|
||||
}
|
||||
assert.equal(overrideById.get(100).description, "August & improved rank");
|
||||
assert.equal(candidate.source.realmSlug, "voljin");
|
||||
assert.equal(candidate.source.spellDbcSources.coaBase.patch, "T");
|
||||
assert.match(candidate.source.promotion.classicSpellSource, /area-52\/patch-D/);
|
||||
assert.equal(candidate.source.promotion.archiveCapturedAt, "2026-08-12T21:02:51+00:00");
|
||||
assert.equal(report.preserveChecks.classicProgressionChains.passed, true);
|
||||
assert.equal(report.preserveChecks.classicProtectedSpellRecords.passed, true);
|
||||
assert.equal(report.nodeChanges.unexpectedRemovals.length, 0);
|
||||
assert.equal(report.gaps.missingBaseProgressionRankCount, 1);
|
||||
assert.equal(report.gaps.missingBaseProgressionRankIds.includes(91300), true);
|
||||
assert.equal(candidate.spells.find((item) => item.id === 999).dbc, undefined);
|
||||
assert.equal("liveTooltip" in candidate.spells.find((item) => item.id === 999), false);
|
||||
});
|
||||
|
||||
test("promotion reports all missing BASE progression ranks without replacing chain IDs", () => {
|
||||
const missingCoaRanks = Array.from({ length: 13 }, (_, index) => 91400 + index);
|
||||
const { previous, reference, supplements, coaChain } = fixtures({ missingCoaRanks });
|
||||
const { candidate, report } = buildCoaPromotion({ previous, reference, supplements });
|
||||
assert.deepEqual(candidate.progressionChains[1], coaChain);
|
||||
assert.equal(report.gaps.missingBaseProgressionRankCount, 14);
|
||||
assert.deepEqual(report.gaps.missingBaseProgressionRankIds, [91300, ...missingCoaRanks]);
|
||||
assert.equal(candidate.spells.some((item) => missingCoaRanks.includes(item.id)), false);
|
||||
});
|
||||
|
||||
test("promotion blocks July node removal and cross-class rewiring", () => {
|
||||
const missing = fixtures();
|
||||
missing.supplements.classes.find((item) => item.classId === 12).nodes = [];
|
||||
assert.throws(() => buildCoaPromotion(missing), /August archive removes July node IDs/);
|
||||
|
||||
const rewired = fixtures();
|
||||
const moved = rewired.supplements.classes.find((item) => item.classId === 12).nodes[0];
|
||||
rewired.supplements.classes.find((item) => item.classId === 12).nodes = [];
|
||||
rewired.supplements.classes.find((item) => item.classId === 13).nodes.push({ ...moved, classId: 13, tabId: 43 });
|
||||
assert.throws(() => buildCoaPromotion(rewired), /August archive rewires node IDs across classes/);
|
||||
});
|
||||
Reference in new issue
Block a user