diff --git a/src/game/wow335AbilityCatalog.cold-start.fixture.ts b/src/game/wow335AbilityCatalog.cold-start.fixture.ts new file mode 100644 index 00000000..b18f72e2 --- /dev/null +++ b/src/game/wow335AbilityCatalog.cold-start.fixture.ts @@ -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(); diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 33b49efb..0c8dd109 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -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 { abilityAtLevel, abilityById, isAbilityUnlocked, + learnedAbilityForBuild, preloadAbilityCatalogsForClasses, type AbilityDefinition, 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"); }); + 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", () => { const talentNodeIds = new Set(Object.values(generatedData.classes) .flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : []))); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index 848cb5ba..167b882d 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -32,7 +32,6 @@ import { ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_ENVENOM_BUFF_TAG, } from "./roguePoison"; -import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; import { baseResourcesFor } from "./wotlkStats"; import { 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_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; 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[] { const effects: AbilityEffect[] = []; 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" && ["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") && !(classId === "mage" && abilityName === "Molten Armor") && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) { if (classId === "death-knight" && abilityName === "Death Coil") { 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)) { 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 }); } } @@ -2586,7 +2599,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef // Lash). They cannot be allocated by any legal local build, so expose // those retained class actions at their source level instead of making // 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 : undefined, ranks: mappedRanks,