forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc21cbca9b |
No files matched your search
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { preloadRuntimeTalentCatalog, runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||||
|
import generatedData from "./wow335AbilityData.generated.json";
|
||||||
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||||
|
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||||
|
|
||||||
|
const legalNodeIds = new Set(WOW335_TALENT_NODES.map((node) => node.id));
|
||||||
|
const sourceTalentChains = Object.values(generatedData.classes)
|
||||||
|
.flat()
|
||||||
|
.filter((chain) => chain.talentNodeId !== undefined);
|
||||||
|
const absentSourceNodeIds = sourceTalentChains
|
||||||
|
.map((chain) => chain.talentNodeId!)
|
||||||
|
.filter((nodeId) => !legalNodeIds.has(nodeId))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
assert.equal(runtimeTalentNodeById("talent-484"), null, "native bridge starts without the asynchronous talent snapshot");
|
||||||
|
assert.equal(sourceTalentChains.length, 145);
|
||||||
|
assert.equal(sourceTalentChains.filter((chain) => legalNodeIds.has(chain.talentNodeId!)).length, 142);
|
||||||
|
assert.deepEqual(absentSourceNodeIds, ["talent-1666", "talent-1714", "talent-2249"]);
|
||||||
|
|
||||||
|
function assertCatalogTalentGates(): void {
|
||||||
|
const abilitiesById = new Map(Object.values(WOW335_ABILITIES_BY_CLASS)
|
||||||
|
.flat()
|
||||||
|
.map((ability) => [ability.id, ability]));
|
||||||
|
for (const chain of sourceTalentChains) {
|
||||||
|
const ability = abilitiesById.get(chain.id);
|
||||||
|
assert.ok(ability, `source talent chain ${chain.id} remains exported`);
|
||||||
|
assert.equal(
|
||||||
|
ability.talentNodeId,
|
||||||
|
legalNodeIds.has(chain.talentNodeId!) ? chain.talentNodeId : undefined,
|
||||||
|
`source talent gate for ${chain.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priestVe = abilitiesById.get("wow335-priest-vampiric-embrace");
|
||||||
|
const warriorDevastate = abilitiesById.get("wow335-warrior-devastate");
|
||||||
|
const rogueShadowstep = abilitiesById.get("wow335-rogue-shadowstep");
|
||||||
|
const shamanLavaLash = abilitiesById.get("wow335-shaman-lava-lash");
|
||||||
|
assert.ok(priestVe);
|
||||||
|
assert.ok(warriorDevastate);
|
||||||
|
assert.ok(rogueShadowstep);
|
||||||
|
assert.ok(shamanLavaLash);
|
||||||
|
assert.equal(priestVe.talentNodeId, "talent-484");
|
||||||
|
assert.equal(warriorDevastate.talentNodeId, undefined);
|
||||||
|
assert.equal(rogueShadowstep.talentNodeId, undefined);
|
||||||
|
assert.equal(shamanLavaLash.talentNodeId, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCatalogTalentGates();
|
||||||
|
await preloadRuntimeTalentCatalog();
|
||||||
|
assert.ok(runtimeTalentNodeById("talent-484"));
|
||||||
|
assert.equal(runtimeTalentNodeById("talent-1666"), null);
|
||||||
|
assertCatalogTalentGates();
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
abilityAtLevel,
|
abilityAtLevel,
|
||||||
abilityById,
|
abilityById,
|
||||||
isAbilityUnlocked,
|
isAbilityUnlocked,
|
||||||
|
learnedAbilityForBuild,
|
||||||
preloadAbilityCatalogsForClasses,
|
preloadAbilityCatalogsForClasses,
|
||||||
type AbilityDefinition,
|
type AbilityDefinition,
|
||||||
type AbilityEffect,
|
type AbilityEffect,
|
||||||
@@ -183,6 +187,85 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
expect(livingBomb?.ranks.find((rank) => rank.spellId === 55_359)?.description).toContain("512 Fire damage");
|
expect(livingBomb?.ranks.find((rank) => rank.spellId === 55_359)?.description).toContain("512 Fire damage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps DBC aura and trigger healing tooltips out of direct-heal semantics", () => {
|
||||||
|
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
|
||||||
|
const sourceVampiricEmbrace = generatedData.classes.priest.find((chain) => chain.name === "Vampiric Embrace");
|
||||||
|
const sourceRank = sourceVampiricEmbrace?.ranks.find((rank) => rank.spellId === 15_286);
|
||||||
|
expect(sourceRank?.effects).toContainEqual(expect.objectContaining({
|
||||||
|
effectType: 6,
|
||||||
|
auraType: 4,
|
||||||
|
implicitTargetA: 6,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}));
|
||||||
|
expect(vampiricEmbrace).toMatchObject({ target: "hostile", talentNodeId: "talent-484" });
|
||||||
|
expect(effectsOfKind(vampiricEmbrace, "heal")).toEqual([]);
|
||||||
|
expect(effectsOfKind(vampiricEmbrace, "damage")).toEqual([]);
|
||||||
|
expect(effectsOfKind(vampiricEmbrace, "apply-aura")).toContainEqual(expect.objectContaining({
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 4,
|
||||||
|
aura: expect.objectContaining({ disposition: "debuff" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const judgementOfLight = byId("wow335-paladin-judgement-of-light");
|
||||||
|
const sourceJudgementOfLight = generatedData.classes.paladin.find((chain) => chain.name === "Judgement of Light");
|
||||||
|
expect(sourceJudgementOfLight?.ranks[0]?.effects).toContainEqual(expect.objectContaining({
|
||||||
|
effectType: 6,
|
||||||
|
auraType: 4,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}));
|
||||||
|
expect(effectsOfKind(judgementOfLight, "heal")).toEqual([]);
|
||||||
|
expect(effectsOfKind(judgementOfLight, "damage")).toEqual([]);
|
||||||
|
expect(effectsOfKind(judgementOfLight, "apply-aura")).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
aura: expect.objectContaining({
|
||||||
|
procs: expect.arrayContaining([expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ kind: "heal" }),
|
||||||
|
})]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves source-backed direct and party healing alongside aura tooltip filtering", () => {
|
||||||
|
const flashHeal = byId("wow335-priest-flash-heal");
|
||||||
|
const rawFlashHeal = generatedData.classes.priest.find((chain) => chain.name === "Flash Heal");
|
||||||
|
expect(rawFlashHeal?.ranks.every((rank) => rank.effects.some((effect) => effect.effectType === 10))).toBe(true);
|
||||||
|
expect(flashHeal.ranks?.every((rank) => effectsOfKind(rank, "heal").length === 1)).toBe(true);
|
||||||
|
|
||||||
|
const divineHymn = byId("wow335-priest-divine-hymn");
|
||||||
|
expect(effectsOfKind(divineHymn, "heal")).toEqual([
|
||||||
|
expect.objectContaining({ scope: "party", maximumTargets: 3, radius: 40 }),
|
||||||
|
]);
|
||||||
|
const divineStorm = byId("wow335-paladin-divine-storm");
|
||||||
|
expect(effectsOfKind(divineStorm, "heal")).toContainEqual(expect.objectContaining({ scope: "party" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves talent gates across a cold native import and a later talent preload", () => {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const viteNode = path.join(projectRoot, "node_modules", "vite-node", "vite-node.mjs");
|
||||||
|
const fixture = fileURLToPath(new URL("./wow335AbilityCatalog.cold-start.fixture.ts", import.meta.url));
|
||||||
|
expect(() => execFileSync(process.execPath, [viteNode, "--mode", "godot-bridge", fixture], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: "pipe",
|
||||||
|
})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses legal talent gates for learned builds and leaves source-only nodes unlocked", () => {
|
||||||
|
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
|
||||||
|
expect(learnedAbilityForBuild(vampiricEmbrace, 80, {})).toBeNull();
|
||||||
|
expect(learnedAbilityForBuild(vampiricEmbrace, 80, { "talent-484": 1 })).not.toBeNull();
|
||||||
|
|
||||||
|
for (const id of [
|
||||||
|
"wow335-warrior-devastate",
|
||||||
|
"wow335-rogue-shadowstep",
|
||||||
|
"wow335-shaman-lava-lash",
|
||||||
|
]) {
|
||||||
|
const ability = byId(id);
|
||||||
|
expect(ability.talentNodeId).toBeUndefined();
|
||||||
|
expect(learnedAbilityForBuild(ability, 80, {})).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the same legal talent-node set as the full catalog", () => {
|
it("uses the same legal talent-node set as the full catalog", () => {
|
||||||
const talentNodeIds = new Set(Object.values(generatedData.classes)
|
const talentNodeIds = new Set(Object.values(generatedData.classes)
|
||||||
.flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : [])));
|
.flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : [])));
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||||
ROGUE_ENVENOM_BUFF_TAG,
|
ROGUE_ENVENOM_BUFF_TAG,
|
||||||
} from "./roguePoison";
|
} from "./roguePoison";
|
||||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
|
||||||
import { baseResourcesFor } from "./wotlkStats";
|
import { baseResourcesFor } from "./wotlkStats";
|
||||||
import {
|
import {
|
||||||
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
|
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
|
||||||
@@ -43,6 +42,9 @@ import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
|||||||
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
|
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
|
||||||
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
|
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
|
||||||
|
|
||||||
|
// This generated source list is available synchronously when the abilities below are constructed.
|
||||||
|
const SOURCE_WOW335_TALENT_NODE_IDS = new Set(WOW335_TALENT_NODES.map((node) => node.id));
|
||||||
|
|
||||||
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
||||||
|
|
||||||
interface Wow335Effect {
|
interface Wow335Effect {
|
||||||
@@ -1851,6 +1853,13 @@ export function hunterExplosiveTrapDirectScaling(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAuraOrTriggeredSourceEffect(rank: Wow335Rank): boolean {
|
||||||
|
return rank.effects.some((effect) => (
|
||||||
|
(APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0)
|
||||||
|
|| (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
||||||
const effects: AbilityEffect[] = [];
|
const effects: AbilityEffect[] = [];
|
||||||
for (const [effectIndex, effect] of rank.effects.entries()) {
|
for (const [effectIndex, effect] of rank.effects.entries()) {
|
||||||
@@ -2150,15 +2159,19 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
}
|
}
|
||||||
const auraOnlyStance = classId === "death-knight"
|
const auraOnlyStance = classId === "death-knight"
|
||||||
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||||
|
// Tooltip prose can describe later aura/proc payloads; DBC aura/trigger rows
|
||||||
|
// are not evidence of direct damage or healing from the cast itself.
|
||||||
|
const hasAuraOrTriggeredEffect = hasAuraOrTriggeredSourceEffect(rank);
|
||||||
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
||||||
&& !(classId === "mage" && abilityName === "Molten Armor")
|
&& !(classId === "mage" && abilityName === "Molten Armor")
|
||||||
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||||
if (classId === "death-knight" && abilityName === "Death Coil") {
|
if (classId === "death-knight" && abilityName === "Death Coil") {
|
||||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||||
} else if (!/^Create\b/i.test(abilityName)
|
} else if (!hasAuraOrTriggeredEffect && !/^Create\b/i.test(abilityName)
|
||||||
&& /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) {
|
&& /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) {
|
||||||
effects.unshift({ kind: "heal", coefficient: 1 });
|
effects.unshift({ kind: "heal", coefficient: 1 });
|
||||||
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
} else if (!hasAuraOrTriggeredEffect
|
||||||
|
&& /\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
||||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2586,7 +2599,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
// Lash). They cannot be allocated by any legal local build, so expose
|
// Lash). They cannot be allocated by any legal local build, so expose
|
||||||
// those retained class actions at their source level instead of making
|
// those retained class actions at their source level instead of making
|
||||||
// them permanently unlearnable behind a dangling talent ID.
|
// them permanently unlearnable behind a dangling talent ID.
|
||||||
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
|
talentNodeId: chain.talentNodeId && SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)
|
||||||
? chain.talentNodeId
|
? chain.talentNodeId
|
||||||
: undefined,
|
: undefined,
|
||||||
ranks: mappedRanks,
|
ranks: mappedRanks,
|
||||||
|
|||||||
Reference in new issue
Block a user