diff --git a/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md b/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md new file mode 100644 index 00000000..685782e8 --- /dev/null +++ b/docs/COMBAT_FINDING_19_DEATH_PACT_BLOOD_GORGED.md @@ -0,0 +1,37 @@ +# Death Pact and Blood Gorged finding corrections + +Tracked in [shared issue #19](https://git.whoagland.com/phenom/healer-man/issues/19). +Coordinator: Root `/root`, session `01a12399-cfab-7c72-8455-e0e4cb7e156c`. + +The local Death Pact row (48743) supplies a 40% heal, a 100-yard minion-selection +radius, a 40 runic-power cost, a 120-second cooldown and a 1.5-second GCD. +Its hostile instant-kill conversion and tooltip-derived flat heal were wrong. +The explicit sacrifice effect now consumes exactly one living, unexpired undead +minion owned by the caster inside that radius and heals only that caster. +The heal cannot crit. Missing minions fail before resource, cooldown or GCD use. +Sacrifice produces a death record rather than damage-meter or experience credit. +Player and companion dispatch share minion selection and death-state mutation. + +The [AzerothCore Death Pact handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp) +corroborates living, owned undead selection within 100 yards and a single victim. +This runtime admits only the two undead ghoul entries it already models through +Raise Dead (26125) and Army of the Dead (24207). Unknown summon families are not +assumed undead. Other undead minion types require a modeled source identity. +Automated non-healer sacrifices use the existing defensive health threshold; +this is simulator AI policy, not a spell-source trigger. + +Blood Gorged's five local ranks contain only learn-spell effects (36), linking +the conditional damage and armor-penetration children. The catalog no longer +invents an active attack from that passive tooltip. Talent wrappers with only +learn effects and no activation cost, cast time or cooldown remain passive; +active and mixed-effect spells remain executable. The generator preserves the +same classification for future imports. Existing sourced Blood Gorged damage +and armor handling remains in `wow335DeathKnightBloodGorgedRuntime.ts`; see +`docs/WOW_DEATH_KNIGHT_BLOOD_GORGED.md` for its health and family-mask boundaries. + +Focused validation covers real Raise Dead then Death Pact casts, hostile trash +and bosses, owner isolation, dead/expired/distant/non-undead minions, exact +cost/cooldown/GCD, overheal and noncritical event accounting, actual automatic +companion dispatch, and all five Blood Gorged wrapper ranks. Existing player and +companion Blood Gorged health/mask tests preserve the legitimate passive bonus. +The historical 873-cell campaign and source snapshots are unchanged. diff --git a/docs/ISSUE_18_MAINTENANCE_CAST_SELECTION.md b/docs/ISSUE_18_MAINTENANCE_CAST_SELECTION.md new file mode 100644 index 00000000..0db5762c --- /dev/null +++ b/docs/ISSUE_18_MAINTENANCE_CAST_SELECTION.md @@ -0,0 +1,22 @@ +# Issue 18: maintenance cast selection + +The benchmark controller scores a generic candidate list alongside the shared +rotation. A maintained aura rejected by the shared selector must also be +removed from that fallback list, or the next benchmark decision can cast it +again. + +Current, unexpired auras are passed to the selector with their actual target +and aura ID. The selector suppresses the same applied aura only on the target +the candidate would use. An aura on another actor does not block the cast, and +a different aura in the same exclusive group remains eligible to replace it. +Expired and dispelled auras are absent from the active snapshot. Periodic +effects retain their source, target, and remaining-duration checks. An attack +or heal that also applies an aura remains eligible for its primary effect. + +Focused regressions cover Protection Paladin L60 Greater Blessing of Sanctuary, +RoM Scout L30 Detection, target-scoped aura selection, and exclusive-group +replacement. Run them with: + +```sh +./node_modules/.bin/vitest run src/game/benchmark/benchmarkController.maintenance.test.ts src/game/combatRotation.test.ts --reporter=dot +``` diff --git a/scripts/generate-wow335-abilities.mjs b/scripts/generate-wow335-abilities.mjs index cf9d7ea3..81158b9d 100644 --- a/scripts/generate-wow335-abilities.mjs +++ b/scripts/generate-wow335-abilities.mjs @@ -396,7 +396,11 @@ try { // with the same name on the class skill line. That proc has no player // activation mechanics, so retain it for the spellbook but classify it // as passive instead of exposing it as a castable action. - const passive = passiveTalentNames.has(name) && ranks.every((spell) => ( + const passive = (passiveTalentNames.has(name) || (Boolean(talentNodeId) + && ranks.every((spell) => { + const effects = spell.effects.filter((effect) => effect.effectType !== 0); + return effects.length > 0 && effects.every((effect) => effect.effectType === 36); + }))) && ranks.every((spell) => ( spell.castTimeMs === 0 && spell.recoveryTimeMs === 0 && spell.categoryRecoveryTimeMs === 0 diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts index e1805b17..b0518dbc 100644 --- a/src/game/abilityCatalog.ts +++ b/src/game/abilityCatalog.ts @@ -196,6 +196,7 @@ export type AbilityEffect = | ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource) | ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients) | { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number } + | { readonly kind: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number } | { readonly kind: "summon"; readonly creatureId: number; diff --git a/src/game/abilityRules.ts b/src/game/abilityRules.ts index 1b3a8252..cd5a22f6 100644 --- a/src/game/abilityRules.ts +++ b/src/game/abilityRules.ts @@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints { for (const effect of ability.effects) { if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage"); if (effect.kind === "execute") categories.add("execute"); - if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal"); + if (effect.kind === "heal" || effect.kind === "hot" || effect.kind === "sacrifice-minion") categories.add("heal"); if (effect.kind === "shield") categories.add("mitigation"); if (effect.kind === "taunt") categories.add("taunt"); if (effect.kind === "interrupt") categories.add("interrupt"); diff --git a/src/game/benchmark/benchmarkController.maintenance.test.ts b/src/game/benchmark/benchmarkController.maintenance.test.ts new file mode 100644 index 00000000..2e9c8c23 --- /dev/null +++ b/src/game/benchmark/benchmarkController.maintenance.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityAtLevel, abilitiesForClass } from "../abilityCatalog"; +import { PLAYER_AGGRO_ID } from "../aggro"; +import { useCombatStore } from "../combatStore"; +import { clearMobRuntimeRegistry } from "../mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "../partyRuntimeRegistry"; +import { BenchmarkController } from "./benchmarkController"; +import { BENCHMARK_SCENARIOS } from "./benchmarkScenarios"; + +const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 }; + +describe("benchmark maintenance aura fallback", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + }); + + it("does not re-admit an active self buff after shared rotation rejects it", () => { + useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 }); + const sanctuary = abilityAtLevel( + abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!, + 60, + ); + useCombatStore.setState({ abilities: [sanctuary] }); + expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.id === sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true); + + const decision = new BenchmarkController().act(2_000, "tank", scenario, false); + + expect(decision.cast).toBe(false); + expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id + === sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1); + }); + + it("allows the same maintenance cast after its aura expires or is removed", () => { + useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 }); + const sanctuary = abilityAtLevel( + abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!, + 60, + ); + const auraId = sanctuary.effects.find((effect) => effect.kind === "apply-aura")!.aura.id; + useCombatStore.setState({ abilities: [sanctuary] }); + expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.definition.id === auraId ? { ...aura, expiresAt: 1_999 } : aura), + })); + + expect(new BenchmarkController().act(2_000, "tank", scenario, false).cast).toBe(true); + + useCombatStore.setState({ auras: [] }); + expect(new BenchmarkController().act(4_000, "tank", scenario, false).cast).toBe(true); + }); + + it("does not re-admit an active RoM self debuff through generic candidate scoring", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-scout", statRuleset: "rom", role: "damage", level: 30 }); + const detection = abilityAtLevel( + abilitiesForClass("rom-scout").find((ability) => ability.id === "rom-scout-primary-8")!, + 30, + ); + useCombatStore.setState({ abilities: [detection] }); + expect(useCombatStore.getState().castAbility(detection.id, { now: 100 }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.id === detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true); + + const decision = new BenchmarkController().act(2_000, "damage", scenario, false); + + expect(decision.cast).toBe(false); + expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id + === detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1); + }); +}); diff --git a/src/game/benchmark/benchmarkController.ts b/src/game/benchmark/benchmarkController.ts index 3c6a7788..a6dcc804 100644 --- a/src/game/benchmark/benchmarkController.ts +++ b/src/game/benchmark/benchmarkController.ts @@ -25,6 +25,7 @@ import type { BenchmarkScenarioDefinition } from "./benchmarkTypes"; import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation"; import { COMBAT_ROTATION_VERSION, + abilityAuraMaintainedOnTarget, evaluateCombatRotation, nativeEarlyPriestHealReserveBlocksAbility, type RotationContext, @@ -52,6 +53,15 @@ interface ScoredAbility { readonly friendlyTarget: FriendlyTarget | null; } +function scoredAbilityAuraTargetId(candidate: ScoredAbility, combat: CombatState): string | null { + const { ability, friendlyTarget } = candidate; + if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) { + return friendlyTarget?.id ?? combat.selectedFriendlyActorId ?? PLAYER_AGGRO_ID; + } + if (ability.target === "hostile" || ability.target === "any") return combat.selectedTargetId; + return PLAYER_AGGRO_ID; +} + const TRANSIENT_FAILURES = new Set([ "already-casting", "global-cooldown", @@ -287,13 +297,10 @@ export class BenchmarkController { maintainedAbilityIds: new Set([ ...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0) .map((effect) => effect.sourceAbilityId), - ...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID - && (aura.expiresAt === null || aura.expiresAt > now)) - .flatMap((aura) => availableAbilities - .filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura" - && effect.aura.id === aura.definition.id)) - .map((ability) => ability.id)), ]), + maintainedAuras: combat.auras + .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) + .map((aura) => ({ auraId: aura.definition.id, targetId: aura.targetId })), maintainedPeriodicEffects: combat.effects.map((effect) => ({ abilityId: effect.sourceAbilityId, sourceActorId: effect.sourceActorId, @@ -315,9 +322,11 @@ export class BenchmarkController { }; const sharedDecision = evaluateCombatRotation(rotationContext); for (let index = candidates.length - 1; index >= 0; index -= 1) { - const ability = candidates[index].ability; + const candidate = candidates[index]; + const ability = candidate.ability; if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext) - || (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) { + || (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext)) + || abilityAuraMaintainedOnTarget(ability, rotationContext, scoredAbilityAuraTargetId(candidate, combat))) { candidates.splice(index, 1); } } diff --git a/src/game/combatEvents.ts b/src/game/combatEvents.ts index c3a2f1b4..51d7be26 100644 --- a/src/game/combatEvents.ts +++ b/src/game/combatEvents.ts @@ -18,6 +18,8 @@ export interface StructuredCombatEvent { readonly sourceActorId: string; readonly targetActorId: string; readonly abilityId?: string; + /** Source ability that caused an interruption; abilityId remains the canceled cast. */ + readonly causeAbilityId?: string; readonly school?: DamageSchool; readonly rawAmount: number; readonly effectiveAmount: number; diff --git a/src/game/combatRotation.test.ts b/src/game/combatRotation.test.ts index 63a9b5fa..b53cb1b1 100644 --- a/src/game/combatRotation.test.ts +++ b/src/game/combatRotation.test.ts @@ -11,6 +11,7 @@ import { import { abilityIsPrecombatPreparation, abilityHasRotationSemantics, + abilityAuraMaintainedOnTarget, CLASSIC_ROTATION_PROFILES, COMBAT_ROTATION_VERSION, evaluateCombatRotation, @@ -764,6 +765,52 @@ describe("shared combat rotations", () => { .toBe(lightningBolt.id); }); + it("matches maintained auras by recipient and exact effect while allowing a group override", () => { + const auraAbility = (id: string, auraId: string, recipient: "caster" | "ability-target" = "caster") => semanticAbility(id, [{ + kind: "apply-aura", + aura: { + id: auraId, + name: id, + disposition: "buff", + durationMs: 300_000, + exclusiveGroup: "test:stance", + modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }], + }, + recipient, + sourceEffectType: 6, + sourceAuraType: 22, + miscValue: 0, + triggerSpellId: 0, + }], { target: "self" }); + const stance = auraAbility("stance", "stance-aura"); + const replacementStance = auraAbility("replacement-stance", "replacement-aura"); + const allyAura = { + ...auraAbility("ally-aura", "ally-aura-id", "ability-target"), + target: "friendly" as const, + }; + const activeOnSelf = context({ + selfId: "player", + maintainedAuras: [{ auraId: "stance-aura", targetId: "player" }], + }); + + expect(abilityAuraMaintainedOnTarget(stance, activeOnSelf, "player")).toBe(true); + expect(abilityAuraMaintainedOnTarget(allyAura, activeOnSelf, "ally")).toBe(false); + expect(abilityAuraMaintainedOnTarget(stance, { + ...activeOnSelf, + maintainedAuras: [{ auraId: "stance-aura", targetId: "ally" }], + }, "player")).toBe(false); + expect(abilityAuraMaintainedOnTarget(allyAura, { + ...activeOnSelf, + maintainedAuras: [{ auraId: "ally-aura-id", targetId: "player" }], + }, "player")).toBe(true); + expect(abilityAuraMaintainedOnTarget(replacementStance, activeOnSelf, "player")).toBe(false); + expect(abilityAuraMaintainedOnTarget({ + ...stance, + effects: [...stance.effects, { kind: "damage", coefficient: 1 }], + }, activeOnSelf, "player")).toBe(false); + expect(abilityAuraMaintainedOnTarget(stance, { ...activeOnSelf, maintainedAuras: [] }, "player")).toBe(false); + }); + it("maintains Earth Shield on a valid full-health ally", () => { const earthShield = ability("shaman", "Earth Shield"); const shaman = context({ diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index 1e2cad55..8b30699e 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -94,6 +94,11 @@ export interface RotationContext { readonly targetId: string; readonly remainingMs: number; }[]; + /** Active applied auras with their actual recipient, filtered for expiry. */ + readonly maintainedAuras?: readonly { + readonly auraId: string; + readonly targetId: string; + }[]; readonly activeAuraExclusiveGroups?: ReadonlySet; readonly activeSummonAbilityIds?: ReadonlySet; readonly hasActivePet?: boolean; @@ -378,6 +383,36 @@ function targetForAbility( return null; } +/** Whether this applied-aura action is already active on the recipient it would use. */ +export function abilityAuraMaintainedOnTarget( + ability: AbilityDefinition, + context: RotationContext, + targetId: string | null, +): boolean { + // An aura can be a side effect of an attack. Keep those casts available for + // their damage or healing; this guard only handles maintenance actions. + if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot" + || effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield" + || effect.kind === "finisher-damage" || effect.kind === "execute")) return false; + const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura"); + if (auraEffects.length === 0) return false; + const maintainedAuras = context.maintainedAuras; + if (maintainedAuras !== undefined) { + return auraEffects.some((effect) => { + const recipientId = effect.recipient === "caster" + ? context.selfId ?? null + : effect.recipient === "pet" ? null : targetId; + return recipientId !== null && maintainedAuras.some((aura) => ( + aura.targetId === recipientId && aura.auraId === effect.aura.id + )); + }); + } + // Older selector callers only expose caster-owned exclusive groups. Keep + // their conservative behavior until they provide recipient-scoped auras. + return auraEffects.some((effect) => Boolean(effect.aura.exclusiveGroup + && context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup))); +} + interface ScoredRotationAbility { readonly ability: AbilityDefinition; readonly score: number; @@ -410,13 +445,20 @@ function scoreAbility( && ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot"); const coaPeriodic = context.maintainedPeriodicEffects !== undefined && Boolean(context.selfId) && usesCoaTargetedPeriodicMaintenance(ability, context); + const maintenanceTargetId = targetForAbility(ability, context, lowest) + ?? (ability.target === "self" ? context.selfId ?? null : null); const periodicMaintained = coaPeriodic - ? coaPeriodicActionMaintainedOnTarget(ability, context, targetForAbility(ability, context, lowest)) - : !nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false); + ? coaPeriodicActionMaintainedOnTarget(ability, context, maintenanceTargetId) + : !nativePeriodic && (context.maintainedPeriodicEffects !== undefined + ? context.maintainedPeriodicEffects.some((effect) => ( + effect.abilityId === ability.id + && effect.sourceActorId === context.selfId + && effect.targetId === maintenanceTargetId + && effect.remainingMs > 0 + )) + : (context.maintainedAbilityIds?.has(ability.id) ?? false)); const maintained = periodicMaintained - || ability.effects.some((effect) => effect.kind === "apply-aura" - && Boolean(effect.aura.exclusiveGroup - && context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup))); + || abilityAuraMaintainedOnTarget(ability, context, maintenanceTargetId); const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false; const petSummon = isPetSummon(ability); const offensiveSummon = isOffensiveSummon(ability); @@ -679,6 +721,12 @@ function scoreAbility( score += injured.length ? -3_000 : 300; } } else { + if (ability.effects.some((effect) => effect.kind === "sacrifice-minion")) { + // Automated minion sacrifices are emergency self-heals; the cast gate + // still requires a living owned source minion before resources are spent. + if (selfHealth >= cooldownPolicy.generic.defensiveHealthPercent / 100) return null; + score += 140_000; + } if (categories.has("mitigation")) { const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100; const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100; diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index 0a81bd4e..49f95fac 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -1536,7 +1536,7 @@ describe("combat store", () => { registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0); useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 }); useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); - expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000)).toMatchObject({ ok: false, reason: "condition-not-met" }); expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 }); }); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 05c2b450..de32e050 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -1,3 +1,4 @@ +import { eligibleSacrificeMinion } from "./sacrificeMinion"; import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery"; import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount"; import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult, @@ -461,6 +462,7 @@ import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem import { PLAYER_EQUIPMENT_OWNER_ID, activeEquipmentStatsForClass, + classCanEquipItem, equipInventoryItem, normalizeEquipment, unequipInventorySlot, @@ -1712,6 +1714,7 @@ export interface CombatState { controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean; dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number; healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution; + sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract, origin: CombatPosition, now?: number) => boolean; /** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */ triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number; reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number; @@ -1822,6 +1825,30 @@ let summonSequence = 0; let areaEffectSequence = 0; let combatEventSequence = 0; +function sacrificeMinionInWork( + work: Pick, + ownerActorId: string, + abilityId: string, + effect: Extract, + origin: CombatPosition, + now: number, +): boolean { + const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now); + if (!minion) return false; + work.summons = work.summons.map((summon) => summon.id === minion.id + ? { ...summon, health: 0, targetId: null, cast: null } : summon); + const actor = work.actors[minion.id]; + if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null }; + work.auras = work.auras.filter((aura) => aura.targetId !== minion.id); + work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id); + recordCombatEventInWork(work, { + occurredAt: now, kind: "death", sourceActorId: ownerActorId, + targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0, + overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed", + }); + return true; +} + function initialSummonCombatRuntime(): Pick { @@ -1908,7 +1935,7 @@ function recordCombatEvent( return appendCombatEvent(events, input, combatEventSequence); } -function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void { +function recordCombatEventInWork(work: Pick, input: CombatEventInput): void { work.combatEvents = [...recordCombatEvent(work.combatEvents, input)]; } @@ -1973,10 +2000,16 @@ function emitPlayerAbilityPresentation( if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" }); } -function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void { +function emitPlayerAbilityCancellation( + abilityId: string, + occurredAt: number, + origin: CombatPosition, + targetId: string | null = null, + targetPosition: CombatPosition | null = null, +): void { const ability = abilityById(abilityId); if (!ability) return; - emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin); + emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, targetId, targetPosition ?? origin); } function emitPlayerAbilityResolutionPresentation( @@ -2384,6 +2417,33 @@ function playerEquippedMeleeWeapon( return item?.weaponType ? item.stats.meleeWeapon : null; } +function playerHasExplicitWoWWarriorDualWield( + state: Pick, +): boolean { + if (state.classId !== "warrior" || state.level < 20 + || normalizeCharacterStatRuleset(state.statRuleset, state.classId) !== "wow") return false; + + const itemForSlot = (slot: "main-hand" | "off-hand"): InventoryItem | null => { + const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot]; + return assignedId ? state.inventory.find((item) => item.instanceId === assignedId) ?? null : null; + }; + const mainHand = itemForSlot("main-hand"); + const offHand = itemForSlot("off-hand"); + const isClassLegalMeleeWeapon = (item: InventoryItem | null, allowedSlots: readonly string[]): boolean => Boolean( + item + && allowedSlots.includes(item.inventorySlot) + && item.armorType !== "shield" + && item.weaponType + && item.stats.meleeWeapon + && classCanEquipItem(state.classId, item, { level: state.level }) + ); + + // WoW Warrior spell 674 is learned at level 20. Only actual, legal one-hand + // assignments count; generated heirloom stats never grant a second swing. + return isClassLegalMeleeWeapon(mainHand, ["one-hand", "main-hand"]) + && isClassLegalMeleeWeapon(offHand, ["one-hand", "off-hand"]); +} + function playerEquippedRangedWeapon( state: Pick now) { @@ -7745,12 +7806,14 @@ function damageMobInWork( const primaryApplied = secondaryDamage ? Math.min(applied, Math.max(0, Math.round(primaryRemainingDamage))) : applied; + // This override labels the primary event only; the ThreatSource stays authoritative for mechanics. + const combatEventAbilityId = reportedAbilityId ?? source.abilityId; recordCombatEventInWork(work, { occurredAt: now, kind: "damage", sourceActorId: source.actorId, targetActorId: id, - ...(source.abilityId ? { abilityId: source.abilityId } : {}), + ...(combatEventAbilityId ? { abilityId: combatEventAbilityId } : {}), school, ...(attackHand ? { attackHand } : {}), rawAmount: combatResult?.rawDamage !== undefined @@ -9439,6 +9502,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork( source, school, work.gearStats.spellPenetration, + undefined, + undefined, + false, + undefined, + undefined, + WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, ); } @@ -9552,6 +9621,7 @@ function resolveAndDamageMobInWork( criticalChanceBonusPercent = 0, weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, selectedDbcSpellId?: number, + reportedAbilityId?: string, ): DamageResult { const mob = work.mobs[id]; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) { @@ -9919,6 +9989,7 @@ function resolveAndDamageMobInWork( : sourceWeaponChannel === "off-hand" ? "offhand" : sourceWeaponChannel === "ranged" ? "ranged" : undefined : undefined, + reportedAbilityId, ); maybeProcShamanFlurryInWork( work, @@ -13665,6 +13736,8 @@ export const useCombatStore = create((set, get) => ({ && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (ability.name === "Enraged Regeneration" && !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met"; + else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion" + && !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met"; else if (state.classId === "hunter" && ability.name === "Revive Pet" && !state.summons.some((summon) => summon.health <= 0 && (summon.id.startsWith("player-pet-") @@ -16879,6 +16952,22 @@ export const useCombatStore = create((set, get) => ({ ]); } } + } else if (effect.kind === "sacrifice-minion") { + if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue; + const amount = Math.round(resolveAuraHealingValue( + resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth), + aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"), + aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken", + )); + const healed = healPlayerInWork(work, amount); + work.totalHealing += healed; + recordCombatEventInWork(work, { + occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool, + rawAmount: amount, effectiveAmount: healed, overheal: amount - healed, + absorbed: 0, critical: false, + }); + addHealingThreatInWork(work, castThreatSource, healed, now); } else if (effect.kind === "execute") { for (const id of affectedMobIds) { const mob = work.mobs[id]; @@ -18676,6 +18765,15 @@ export const useCombatStore = create((set, get) => ({ return applied; }, + sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => { + const state = get(); + const work = { summons: [...state.summons], actors: { ...state.actors }, + auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] }; + if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false; + set(work); + return true; + }, + healActor: (id, amount, request) => { const now = request.now ?? combatNow(); const state = get(); @@ -20568,7 +20666,12 @@ export const useCombatStore = create((set, get) => ({ if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt }); } const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = []; - let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null; + let playerControl: { + readonly mechanic: EnemyControlMechanic; + readonly endsAt: number; + readonly sourceActorId: string; + readonly causeAbilityId: string; + } | null = null; const memberControls: Array<{ readonly memberId: string; readonly mechanic: EnemyControlMechanic; @@ -21452,7 +21555,9 @@ export const useCombatStore = create((set, get) => ({ const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now); if (!control) continue; if (actorId === PLAYER_AGGRO_ID) { - if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; + if (!playerControl || control.endsAt >= playerControl.endsAt) { + playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id }; + } } else memberControls.push({ memberId: actorId, ...control }); } } else if (effect.kind === "control") { @@ -21513,7 +21618,9 @@ export const useCombatStore = create((set, get) => ({ : auraMasteryDurationMs; const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs }; if (actorId === PLAYER_AGGRO_ID) { - if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control; + if (!playerControl || control.endsAt >= playerControl.endsAt) { + playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id }; + } } else memberControls.push({ memberId: actorId, ...control }); namedAuras = [...applyCombatAura(namedAuras, { @@ -21724,11 +21831,31 @@ export const useCombatStore = create((set, get) => ({ }; changed = true; } + const controlInterruptedCast = playerControl && state.activeCast + ? { cast: state.activeCast, control: playerControl } + : null; if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) { set({ mobs, enemyPeriodicEffects, auras: namedAuras, + ...(controlInterruptedCast ? { + combatEvents: recordCombatEvent(state.combatEvents, { + occurredAt: now, + kind: "interrupt", + sourceActorId: controlInterruptedCast.control.sourceActorId, + targetActorId: controlInterruptedCast.cast.targetId ?? PLAYER_AURA_ENTITY_ID, + abilityId: controlInterruptedCast.cast.abilityId, + causeAbilityId: controlInterruptedCast.control.causeAbilityId, + school: controlInterruptedCast.cast.school, + rawAmount: 0, + effectiveAmount: 0, + overheal: 0, + absorbed: 0, + critical: false, + outcome: `control:${controlInterruptedCast.control.mechanic}`, + }), + } : {}), ...(playerControl ? { ...(state.activeCast @@ -21748,6 +21875,18 @@ export const useCombatStore = create((set, get) => ({ : {}), }); } + if (controlInterruptedCast) { + emitPlayerAbilityCancellation( + controlInterruptedCast.cast.abilityId, + now, + state.playerPosition, + controlInterruptedCast.cast.targetId, + controlInterruptedCast.cast.targetId + ? getMobPosition(controlInterruptedCast.cast.targetId) + ?? actorPosition(state, controlInterruptedCast.cast.targetId) + : null, + ); + } let appliedDamage = 0; for (const hit of incomingHits) { if (hit.actorId === PLAYER_AURA_ENTITY_ID @@ -24337,13 +24476,15 @@ export const useCombatStore = create((set, get) => ({ const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom" ? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined; + const explicitWarriorDualWield = playerHasExplicitWoWWarriorDualWield(state); const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue" - || state.classId === "shaman" || state.classId === "death-knight" + || state.classId === "shaman" || state.classId === "death-knight" || explicitWarriorDualWield ? playerEquippedMeleeWeapon(state, "off-hand") : null; const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue" || state.classId === "shaman" - || (state.classId === "death-knight" && Boolean(offHandWeapon)); + || (state.classId === "death-knight" && Boolean(offHandWeapon)) + || explicitWarriorDualWield; const mainHandWeapon = supportsPlayerOffhandSwings ? playerEquippedMeleeWeapon(state, "main-hand") : null; @@ -24407,6 +24548,8 @@ export const useCombatStore = create((set, get) => ({ { isAutoAttack: true, isDualWielding }, 0, mainHandSpeedMs, + undefined, + "player-auto-attack", ); const runeWeaponMainHand = state.classId === "death-knight" && result.combatResult?.landed diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index e6cdc3b4..fe5c4a00 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -1,3 +1,4 @@ +import { eligibleSacrificeMinion } from "./sacrificeMinion"; import { healerManaRecoveryAmount } from "./healerManaRecovery"; import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount"; import { romNativePlayerHitProfile } from "./romNativeCombatResolution"; @@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid( || (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false; } const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; + if (ability.effects.some((effect) => effect.kind === "sacrifice-minion" + && !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false; const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement( member.classId, member.secondaryClassId, @@ -3190,7 +3193,7 @@ function choosePartySupportIntent( lowestHealthFraction: target.health / Math.max(1, target.maxHealth), friendlyTargets, maintainedAbilityIds, - canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind) + canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].includes(effect.kind) || (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length))) && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) @@ -4587,6 +4590,17 @@ function executePartyAbility( ability.id, now, ); + } else if (effect.kind === "sacrifice-minion") { + if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue; + const result = useCombatStore.getState().healActor(member.id, + Math.round(member.maxHealth * effect.percentMaxHealth), { + sourceActorId: member.id, abilityId: ability.id, school: spellSchool, + criticalChance: 0, now, + }); + effectiveAmount += result.effectiveAmount; + directHealingRawAmount += result.rawAmount; + combat = useCombatStore.getState(); + runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]); } else if (effect.kind === "execute") { for (const hostileTargetId of hostileTargets) { const target = useCombatStore.getState().mobs[hostileTargetId]; @@ -5147,7 +5161,7 @@ function executePartyAbility( } emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted); emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted); - if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) { + if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) { useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount); } if (suddenDoomTrigger && depth === 0) { diff --git a/src/game/playerAutoAttackEventLineage.integration.test.ts b/src/game/playerAutoAttackEventLineage.integration.test.ts new file mode 100755 index 00000000..29def3c0 --- /dev/null +++ b/src/game/playerAutoAttackEventLineage.integration.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { applyAura as applyCombatAura } from "./combatAuras"; +import { useCombatStore } from "./combatStore"; +import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; +import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; +import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import type { InventoryItem } from "./lootTypes"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { resolvedEquipmentLoadout } from "./heirloomEquipment"; +import { + WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, + WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA, +} from "./wow335DeathKnightDancingRuneWeaponRuntime"; + +const TARGET_ID = "player-auto-event-lineage-target"; + +let presentationEvents: CombatPresentationEvent[] = []; +let publishedCombatEvents: StructuredCombatEvent[] = []; +let unsubscribePresentation = () => {}; +let unsubscribeCombatEvents = () => {}; + +function weapon( + instanceId: string, + inventorySlot: InventoryItem["inventorySlot"], + overrides: Partial> = {}, +) { + return { + contentCategory: "wow" as const, + instanceId, + itemId: 1, + itemKey: instanceId, + name: instanceId, + quality: "uncommon" as const, + inventorySlot: overrides.inventorySlot ?? inventorySlot, + armorType: overrides.armorType ?? null, + weaponType: overrides.weaponType === undefined ? "dagger" : overrides.weaponType, + itemLevel: 1, + sourceItemLevel: 1, + stats: { + ...EMPTY_ITEM_STATS, + hitRating: 10_000, + meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 }, + }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; +} + +function warriorAutoAttackEvents(options: { + level?: number; + mainHand?: InventoryItem; + offHand?: InventoryItem; + assignMainHand?: boolean; + assignOffHand?: boolean; + virtualDualWieldHeirloom?: boolean; +} = {}) { + const mainHand = options.mainHand ?? weapon("warrior-main-hand", "one-hand", { weaponType: "dagger" }); + const offHand = options.offHand ?? weapon("warrior-off-hand", "off-hand", { weaponType: "dagger" }); + const assignments = { + ...(options.assignMainHand === false ? {} : { "main-hand": mainHand.instanceId }), + ...(options.assignOffHand === false ? {} : { "off-hand": offHand.instanceId }), + }; + useCombatStore.getState().initializeCharacter({ + classId: "warrior", + level: options.level ?? 20, + statRuleset: "wow", + specializationId: options.virtualDualWieldHeirloom ? "warrior:fury" : undefined, + inventory: [mainHand, offHand], + equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: assignments }, + }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player auto event lineage target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); + useCombatStore.setState({ + autoAttackTargetId: TARGET_ID, + nextAutoAttackAt: 1_000, + nextOffhandAutoAttackAt: 1_000, + }); + useCombatStore.getState().tick(0, 1_000); + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID + && event.targetActorId === TARGET_ID && event.occurredAt === 1_000 + && (event.attackHand === "mainhand" || event.attackHand === "offhand") + )); +} + +beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player auto event lineage target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); + presentationEvents = []; + publishedCombatEvents = []; + unsubscribePresentation = subscribeCombatPresentation((message) => { + if (message.kind === "event") presentationEvents.push(message.event); + }); + unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event)); +}); + +afterEach(() => { + unsubscribePresentation(); + unsubscribeCombatEvents(); +}); + +describe("player auto-attack event lineage", () => { + it("enables WoW Warrior off-hand swings only with two assigned legal one-hand weapons at level 20", () => { + const validWarriorSwings = warriorAutoAttackEvents(); + expect(validWarriorSwings).toEqual(expect.arrayContaining([ + expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }), + expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }), + ])); + expect(validWarriorSwings).toHaveLength(2); + + const belowTrainingLevel = warriorAutoAttackEvents({ level: 19 }); + expect(belowTrainingLevel.some((event) => event.attackHand === "offhand")).toBe(false); + + const noMainHandAssignment = warriorAutoAttackEvents({ assignMainHand: false }); + expect(noMainHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false); + + const noOffHandAssignment = warriorAutoAttackEvents({ assignOffHand: false }); + expect(noOffHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false); + + const noExplicitLoadout = warriorAutoAttackEvents({ assignMainHand: false, assignOffHand: false }); + expect(noExplicitLoadout.some((event) => event.attackHand === "offhand")).toBe(false); + + const virtualDualWieldHeirloom = warriorAutoAttackEvents({ + assignMainHand: false, + assignOffHand: false, + virtualDualWieldHeirloom: true, + }); + const state = useCombatStore.getState(); + const generatedLoadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: state.classId, + secondaryClassId: state.secondaryClassId, + specializationId: state.specializationId, + role: state.gearRole, + level: state.level, + }); + expect(state.specializationId).toBe("warrior:fury"); + expect(generatedLoadout["off-hand"]).toMatchObject({ inventorySlot: "off-hand", weaponType: "dagger" }); + expect(virtualDualWieldHeirloom.some((event) => event.attackHand === "offhand")).toBe(false); + }); + + it("rejects shields, two-hand mains, and class-illegal off-hand weapons", () => { + const shield = weapon("warrior-offhand-shield", "off-hand", { armorType: "shield", weaponType: "dagger" }); + const shieldedSwings = warriorAutoAttackEvents({ offHand: shield }); + expect(shieldedSwings.some((event) => event.attackHand === "offhand")).toBe(false); + + const twoHandMain = weapon("warrior-two-hand-main", "two-hand", { weaponType: "dagger" }); + const twoHandSwings = warriorAutoAttackEvents({ mainHand: twoHandMain }); + expect(twoHandSwings.some((event) => event.attackHand === "offhand")).toBe(false); + + const illegalOffHand = weapon("warrior-illegal-offhand", "off-hand", { weaponType: "wand" }); + const illegalSwings = warriorAutoAttackEvents({ offHand: illegalOffHand }); + expect(illegalSwings.some((event) => event.attackHand === "offhand")).toBe(false); + }); + + it("joins both hand events to their releases without relabeling the triggering melee ability", () => { + const mainHand = weapon("event-lineage-main-hand", "one-hand"); + const offHand = weapon("event-lineage-off-hand", "off-hand"); + useCombatStore.getState().initializeCharacter({ + classId: "rogue", + level: 80, + inventory: [mainHand, offHand], + equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: { + "main-hand": mainHand.instanceId, + "off-hand": offHand.instanceId, + } }, + }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player auto event lineage target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); + const triggeringAbility = useCombatStore.getState().abilities.find((ability) => ability.name === "Sinister Strike"); + expect(triggeringAbility).toBeDefined(); + expect(useCombatStore.getState().castAbility(triggeringAbility!.id, undefined, 1_000).ok).toBe(true); + const healthBeforeSwings = useCombatStore.getState().mobs[TARGET_ID]!.health; + + useCombatStore.getState().tick(0, 1_000); + + const swingEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID + && event.targetActorId === TARGET_ID && event.occurredAt === 1_000 + && (event.attackHand === "mainhand" || event.attackHand === "offhand") + )); + expect(swingEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }), + expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }), + ])); + expect(swingEvents).toHaveLength(2); + expect(swingEvents.reduce((sum, event) => sum + event.effectiveAmount, 0)) + .toBe(healthBeforeSwings - useCombatStore.getState().mobs[TARGET_ID]!.health); + expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ + kind: "damage", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: TARGET_ID, + abilityId: triggeringAbility!.id, + })); + expect(presentationEvents).toContainEqual(expect.objectContaining({ + occurredAt: 1_000, + phase: "release", + targetActorId: TARGET_ID, + abilityId: "player-auto-attack", + })); + expect(presentationEvents).toContainEqual(expect.objectContaining({ + occurredAt: 1_000, + phase: "release", + targetActorId: TARGET_ID, + abilityId: "player-auto-attack-offhand", + })); + expect(publishedCombatEvents.filter((event) => event.kind === "damage" + && event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === TARGET_ID + && (event.abilityId === "player-auto-attack" || event.abilityId === "player-auto-attack-offhand"))) + .toHaveLength(2); + }); + + it("keeps Dancing Rune Weapon's mirrored damage under its proc identity", () => { + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player auto event lineage target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + const aura = applyCombatAura(useCombatStore.getState().auras, { + definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA, + sourceId: PLAYER_AGGRO_ID, + targetId: PLAYER_AGGRO_ID, + now: 1_000, + }); + useCombatStore.setState({ + auras: [...aura.auras], + autoAttackTargetId: TARGET_ID, + nextAutoAttackAt: 1_000, + }); + const healthBeforeSwing = useCombatStore.getState().mobs[TARGET_ID]!.health; + + useCombatStore.getState().tick(0, 1_000); + + const playerDamage = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID + && event.targetActorId === TARGET_ID && event.occurredAt === 1_000 + )); + expect(playerDamage).toEqual(expect.arrayContaining([ + expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }), + expect.objectContaining({ + abilityId: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, + }), + ])); + expect(playerDamage.find((event) => event.abilityId + === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).not.toHaveProperty("attackHand"); + expect(playerDamage).toHaveLength(2); + expect(playerDamage.reduce((sum, event) => sum + event.effectiveAmount, 0)) + .toBe(healthBeforeSwing - useCombatStore.getState().mobs[TARGET_ID]!.health); + expect(presentationEvents.filter((event) => event.phase === "release" + && event.abilityId === "player-auto-attack")).toHaveLength(1); + expect(presentationEvents.some((event) => event.phase === "release" + && event.abilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).toBe(false); + }); +}); diff --git a/src/game/playerControlCastCancellation.integration.test.ts b/src/game/playerControlCastCancellation.integration.test.ts new file mode 100755 index 00000000..e893f5c8 --- /dev/null +++ b/src/game/playerControlCastCancellation.integration.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; +import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; +import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import type { EnemyAttackDefinition } from "./enemyAttacks"; + +const TARGET_ID = "player-control-cancellation-target"; +const CONTROL_MOB_ID = "player-control-cancellation-source"; + +let presentationEvents: CombatPresentationEvent[] = []; +let publishedCombatEvents: StructuredCombatEvent[] = []; +let unsubscribePresentation = () => {}; +let unsubscribeCombatEvents = () => {}; + +function prepareCharacter(classId: "priest" | "warlock"): void { + useCombatStore.getState().initializeCharacter({ classId, level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Player control cancellation target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +beforeEach(() => { + clearMobRuntimeRegistry(); + prepareCharacter("priest"); + presentationEvents = []; + publishedCombatEvents = []; + unsubscribePresentation = subscribeCombatPresentation((message) => { + if (message.kind === "event") presentationEvents.push(message.event); + }); + unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event)); +}); + +afterEach(() => { + unsubscribePresentation(); + unsubscribeCombatEvents(); +}); + +function startTargetingCast(abilityId: string, now = 1_000) { + const result = useCombatStore.getState().castAbility(abilityId, { + targetActorId: TARGET_ID, + origin: [0, 0, 0], + now, + }); + expect(result.ok).toBe(true); + expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId, targetId: TARGET_ID }); + return useCombatStore.getState().activeCast!; +} + +function deliverControl(now: number): void { + if (!useCombatStore.getState().mobs[CONTROL_MOB_ID]) { + const attack: EnemyAttackDefinition = { + id: "lineage-control-stun", + name: "Lineage Control Stun", + spellId: 987_654, + delivery: "projectile", + target: "primary", + school: "shadow", + animation: "cast", + castTimeMs: 0, + range: 30, + cooldownMs: 4_000, + initialCooldownMs: [0, 0], + repeatCooldownMs: [4_000, 4_000], + damageMultiplier: 0, + effect: { kind: "control", mechanic: "stun", durationMs: 2_000 }, + }; + useCombatStore.getState().registerMob(CONTROL_MOB_ID, { + name: "Lineage control source", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + attacks: [attack], + }); + setMobPosition(CONTROL_MOB_ID, [2, 0, 1]); + useCombatStore.getState().damageMob(CONTROL_MOB_ID, 1, now); + } + useCombatStore.getState().advanceMobCombat(now); +} + +function interruptionsFor(abilityId: string): StructuredCombatEvent[] { + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "interrupt" && event.abilityId === abilityId + )); +} + +describe("player cast cancellation from enemy control", () => { + it("records one stun interruption with the source, original target, and canceled cast identity", () => { + const cast = startTargetingCast("priest-smite"); + + deliverControl(1_000); + + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().controlMechanic).toBe("stun"); + expect(interruptionsFor(cast.abilityId)).toEqual([expect.objectContaining({ + occurredAt: 1_000, + sourceActorId: CONTROL_MOB_ID, + targetActorId: cast.targetId, + abilityId: cast.abilityId, + outcome: "control:stun", + causeAbilityId: "lineage-control-stun", + })]); + expect(presentationEvents.filter((event) => event.phase === "cast-cancel" + && event.abilityId === cast.abilityId)).toEqual([expect.objectContaining({ + targetActorId: cast.targetId, + occurredAt: 1_000, + })]); + + deliverControl(1_100); + + expect(interruptionsFor(cast.abilityId)).toHaveLength(1); + expect(publishedCombatEvents.filter((event) => event.kind === "interrupt" + && event.abilityId === cast.abilityId)).toHaveLength(1); + expect(presentationEvents.filter((event) => event.phase === "cast-cancel" + && event.abilityId === cast.abilityId)).toHaveLength(1); + }); + + it("cancels a channel once without creating a release", () => { + prepareCharacter("warlock"); + const cast = startTargetingCast("warlock-drain-life"); + expect(cast.mode).toBe("channel"); + + deliverControl(1_000); + + expect(interruptionsFor(cast.abilityId)).toHaveLength(1); + expect(presentationEvents.filter((event) => event.abilityId === cast.abilityId + && event.phase === "cast-cancel")).toHaveLength(1); + expect(presentationEvents.some((event) => event.abilityId === cast.abilityId + && event.phase === "release")).toBe(false); + }); + + it("does not fabricate a control interruption after normal completion or death", () => { + const completedCast = startTargetingCast("priest-smite"); + useCombatStore.getState().tick(0, completedCast.completesAt); + + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(interruptionsFor(completedCast.abilityId)).toHaveLength(0); + expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId + && event.phase === "release")).toBe(true); + expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId + && event.phase === "cast-cancel")).toBe(false); + + prepareCharacter("priest"); + const doomedCast = startTargetingCast("priest-smite", 4_000); + const health = useCombatStore.getState().health; + useCombatStore.getState().damagePlayer(health * 10, "physical", undefined, 4_001); + expect(useCombatStore.getState().health).toBe(0); + expect(useCombatStore.getState().activeCast).toBeNull(); + deliverControl(4_001); + expect(interruptionsFor(doomedCast.abilityId)).toHaveLength(0); + expect(presentationEvents.some((event) => event.abilityId === doomedCast.abilityId + && event.phase === "cast-cancel")).toBe(false); + }); +}); diff --git a/src/game/sacrificeMinion.ts b/src/game/sacrificeMinion.ts new file mode 100644 index 00000000..d4780365 --- /dev/null +++ b/src/game/sacrificeMinion.ts @@ -0,0 +1,18 @@ +import type { AbilityEffect } from "./abilityCatalog"; +import type { CombatSummon } from "./combatStore"; +import type { CombatPosition } from "./mobRuntimeRegistry"; + +/** Select one living, owned, modeled undead minion; hostile actors never qualify. */ +export function eligibleSacrificeMinion( + summons: readonly CombatSummon[], + ownerActorId: string, + effect: Extract, + origin: CombatPosition, + now: number, +): CombatSummon | undefined { + return summons.find((summon) => summon.ownerActorId === ownerActorId + && summon.health > 0 && summon.expiresAt > now + && effect.creatureIds.includes(summon.creatureId) + && Math.hypot(summon.position[0] - origin[0], summon.position[1] - origin[1], + summon.position[2] - origin[2]) <= effect.radius); +} diff --git a/src/game/talentModifiers.test.ts b/src/game/talentModifiers.test.ts index f7803fa2..e7b09f52 100644 --- a/src/game/talentModifiers.test.ts +++ b/src/game/talentModifiers.test.ts @@ -174,6 +174,72 @@ describe("talent runtime modifiers", () => { expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000); }); + it("keeps Blood Frenzy's target-state damage scope broad", () => { + const bloodFrenzy = TALENT_NODES.find((node) => node.id === "talent-1664")!; + expect(bloodFrenzy.rankDescriptions[1]).toBe( + "Increases your melee attack speed by 4%. Rend and Deep Wounds increase all Physical damage caused to that target by 4%. Does not stack with similar effects.", + ); + + const modifiers = talentModifiersForRanks({ [bloodFrenzy.id]: 2 }, "warrior"); + expect(modifiers).toContainEqual(expect.objectContaining({ + kind: "damage-percent", + amount: 4, + scope: "all Physical damage caused to that target", + sourceTalentId: bloodFrenzy.id, + rank: 2, + })); + for (const abilityName of ["Mortal Strike", "Rend", "Deep Wounds"]) { + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName }), abilityName) + .toBeCloseTo(104); + } + }); + + it("keeps Morbidity's Death Coil clauses and Death and Decay cooldown on their named spells", () => { + const morbidity = TALENT_NODES.find((node) => node.id === "talent-1933")!; + expect(morbidity.name).toBe("Morbidity"); + expect(morbidity.rankDescriptions[2]).toBe( + "Increases the damage and healing of Death Coil by 15% and reduces the cooldown on Death and Decay by 15 sec.", + ); + + const modifiers = talentModifiersForRanks({ [morbidity.id]: 3 }, "death-knight"); + expect(modifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage-percent", amount: 15 }), + expect.objectContaining({ kind: "healing-percent", amount: 15 }), + expect.objectContaining({ kind: "cooldown-flat-ms", amount: -15_000, scope: "cooldown on Death and Decay" }), + ])); + + expect(applyTalentModifierValue(30_000, modifiers, "cooldown-flat-ms", { + abilityName: "Death and Decay", + })).toBe(15_000); + for (const [abilityName, baseCooldownMs] of [ + ["Dark Command", 8_000], + ["Death Grip", 35_000], + ["Death Coil", 8_000], + ] as const) { + expect( + applyTalentModifierValue(baseCooldownMs, modifiers, "cooldown-flat-ms", { abilityName }), + abilityName, + ).toBe(baseCooldownMs); + } + + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Death Coil" })).toBeCloseTo(115); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Coil" })).toBeCloseTo(115); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Icy Touch" })).toBe(100); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Strike" })).toBe(100); + + const broadCooldown = parseTalentModifiers("Reduces the cooldown of all your spells by 2 sec.") + .map((modifier) => ({ + ...modifier, + sourceTalentId: "test:broad-cooldown", + sourceTalentName: "Broad Cooldown", + rank: 1, + description: "Reduces the cooldown of all your spells by 2 sec.", + })); + for (const abilityName of ["Fireball", "Icy Touch"]) { + expect(applyTalentModifierValue(10_000, broadCooldown, "cooldown-flat-ms", { abilityName })).toBe(8_000); + } + }); + it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => { const impact = TALENT_NODES.find((node) => node.id === "talent-30")!; diff --git a/src/game/talentModifiers.ts b/src/game/talentModifiers.ts index 4a17bdd4..e1a89fd9 100644 --- a/src/game/talentModifiers.ts +++ b/src/game/talentModifiers.ts @@ -223,8 +223,12 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence }); const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence) ? null - : sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i); - if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence }); + : sentence.match(/\breduces?\s+(?:the\s+)?cooldown(?:\s+(of|on)\s+(.+?))?\s+by\s+(\d+(?:\.\d+)?)\s+secs?\b/i); + if (cooldown) { + const target = cooldown[2]?.trim(); + const scope = target ? `cooldown ${cooldown[1]} ${target}` : "cooldown"; + add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[3]) * 1000, unit: "milliseconds", scope }); + } } return modifiers; @@ -445,6 +449,24 @@ export interface TalentModifierContext { readonly tags?: readonly string[]; } +function namedModifierTarget(scope: string): string | undefined { + const scopeWithoutAmount = scope.replace(/\s+by\s+-?\d+(?:\.\d+)?\s*(?:%|secs?)(?:\s*[.!?])?$/i, ""); + // These are the source tooltip forms used for named abilities (for example, + // "cooldown on Death and Decay" and "damage of your Fire Blast"). Other + // prepositions can describe a target condition instead, as in "damage + // caused to that target", and must leave the modifier broadly applicable. + const target = scopeWithoutAmount.match(/\b(?:of|on)\s+(?:your\s+)?([^,.!?]+)$/)?.[1]?.trim(); + if (!target) return undefined; + + // Generic descriptors describe a category of spells, effects, or targets. + // Keep those modifiers broad; remaining words indicate a named ability. + const remainingTarget = target.toLowerCase() + .replace(/\b(?:a|an|the|your|all|any|each|every|both|other|next|additional|total|maximum|direct|periodic|instant|damaging|healing|helpful|harmful|melee|ranged|magic|physical|arcane|fire|frost|holy|nature|shadow|spell|spells|ability|abilities|attack|attacks|damage|cooldown|cost|cast|casting|time|effect|effects|critical|strike|chance|done|dealt|taken|suffered|received|target|targets|enemy|enemies|ally|allies|friendly|hostile|party|raid|members|you|self|both|and|or|of|on|with|for|to|by)\b/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + return remainingTarget ? target.toLowerCase() : undefined; +} + function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean { const scope = modifier.scope.toLowerCase(); const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); @@ -469,6 +491,8 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex } if (context.abilityName) { const normalizedName = context.abilityName.toLowerCase(); + const namedTarget = namedModifierTarget(scope); + if (namedTarget && !namedTarget.includes(normalizedName)) return false; const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope); const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope) || /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index 848cb5ba..2833e637 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -1633,6 +1633,14 @@ function classScriptEffects( triggerSpellId: 0, durationMs: 0, }]; + case "death-knight:Death Pact": return [{ + kind: "sacrifice-minion", + // These are the undead ghoul entries already modeled by Raise Dead and + // Army of the Dead. Unknown summon families are never assumed undead. + creatureIds: [26125, 24207], + radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0, + percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100, + }]; case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }]; case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({ kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000, @@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling( }; } +function learnOnlyRank(rank: Wow335Rank): boolean { + const effects = rank.effects.filter((effect) => effect.effectType !== 0); + return effects.length > 0 && effects.every((effect) => effect.effectType === 36); +} + function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] { const effects: AbilityEffect[] = []; for (const [effectIndex, effect] of rank.effects.entries()) { @@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str } const auraOnlyStance = classId === "death-knight" && ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName); - if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand") + if (!learnOnlyRank(rank) && !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") { @@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str .filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported") .filter((effect) => !( (classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection") + || (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind)) || (classId === "paladin" && abilityName === "Divine Storm" && (effect.kind === "damage" || effect.kind === "heal")) || (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage") @@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes" ? Math.max(1, firstRaw.sourceLevel || firstRaw.level) : chain.sourceUnlockLevel; - const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects); - const radius = Math.max( + const target = first.effects.some((effect) => effect.kind === "sacrifice-minion") + ? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects); + const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max( abilityNameRadius(chain.name), ...firstRaw.effects.map((effect) => effect.radius), ); @@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef gcdMs: first.gcdMs, cost: first.cost, effects: first.effects, - passive: chain.passive, + passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0 + && rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)), source: "wow335-progression", // Some stock WotLK active talents remain in Spell.dbc after Ascension's // patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava diff --git a/src/game/wow335DeathPact.integration.test.ts b/src/game/wow335DeathPact.integration.test.ts new file mode 100644 index 00000000..2e6603a1 --- /dev/null +++ b/src/game/wow335DeathPact.integration.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { installCombatClock } from "./combatClock"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; + +const PACT = "wow335-death-knight-death-pact"; +const RAISE = "wow335-death-knight-raise-dead"; +const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80); +let restoreClock: (() => void) | undefined; + +function hostile(id: string, boss = false) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss }); + setMobPosition(id, [2, 0, 0]); + useCombatStore.getState().selectMob(id); +} + +function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) { + const source = ability(RAISE); + const effect = source.effects.find(e => e.kind === "summon") as Extract; + return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!; +} + +describe("Death Pact source semantics and learned Blood Gorged", () => { + beforeEach(() => { + restoreClock = installCombatClock(() => 1_000); + clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 }); + usePartyStore.setState({ members: [], command: "attack" }); + useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100, + resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } }); + useCombatStore.getState().synchronizeActors(); + }); + afterEach(() => restoreClock?.()); + + it("does not spend resources, cooldown or GCD without a living owned minion", () => { + hostile("trash"); hostile("boss", true); + const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000); + expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 }); + const s = useCombatStore.getState(); + expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined(); + expect(s.globalCooldownEndsAt).toBe(0); + expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000); + }); + + it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => { + hostile("trash"); hostile("boss", true); + expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true); + const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!; + expect(first).toBeDefined(); + const other = summon(); + const before = useCombatStore.getState(); + const result = before.castAbility(PACT, undefined, 3_000); + expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 }); + const s = useCombatStore.getState(); + expect(s.health).toBe(600); expect(s.resource).toBe(60); + expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500); + expect(s.summons.find(x => x.id === first.id)?.health).toBe(0); + expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false }); + expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0); + expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000); + const events = s.combatEvents.filter(e => e.abilityId === PACT); + expect(events.filter(e => e.kind === "damage")).toHaveLength(0); + expect(events.filter(e => e.kind === "death")).toHaveLength(1); + expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({ + targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false, + })]); + }); + + it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => { + const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125); + useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id + ? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) })); + expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().health).toBe(200); + }); + + it("does not consume another caster's minion; companion automatic use heals only itself", () => { + const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood", + specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party"); + const member = { ...base, health: 100, maxHealth: 1_000, resource: 100, + resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0, + talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null) + .filter(a => a.id !== PACT).map(a => [a.id, 20_000])) }; + usePartyStore.setState({ members: [member], command: "attack" }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const id = summon(member.id); + expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true); + hostile("party-trash"); + useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } })); + advanceDungeonPartyCombat(2_000); + + const s = useCombatStore.getState(); + expect(s.summons.find(x => x.id === id)?.health).toBe(0); + expect(s.health).toBe(200); + expect(usePartyStore.getState().members[0].health).toBe(500); + expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([ + expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }), + ]); + expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false); + }); + + it("caps overhealing and accounts for it once", () => { + summon(); useCombatStore.setState({ health: 950 }); + expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50); + expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT)) + .toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false }); + }); + + it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => { + const source = ability("wow335-death-knight-blood-gorged"); + expect(source.passive).toBe(true); + expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false); + useCombatStore.setState({ talentRanks: { "talent-2034": rank } }); + expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000)) + .toMatchObject({ ok: false, reason: "passive", damage: 0 }); + }); +});