From 89b6f4f79dea2a0377ea80cd4c775990eda2d239 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 12:54:46 -0400 Subject: [PATCH] fix: label player auto attack damage events (#15) --- src/game/combatStore.ts | 15 +- ...AutoAttackEventLineage.integration.test.ts | 181 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100755 src/game/playerAutoAttackEventLineage.integration.test.ts diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 6f5eaf28..67902adb 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -7682,6 +7682,7 @@ function damageMobInWork( primarySaintsBlessingMultiplierAlreadyApplied = false, directPhysicalArmorAttackerLevel?: number, attackHand?: import("./combatEvents").StructuredCombatEvent["attackHand"], + reportedAbilityId?: string, ): DamageResult { const mob = work.mobs[id]; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) { @@ -7771,12 +7772,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 @@ -9465,6 +9468,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork( source, school, work.gearStats.spellPenetration, + undefined, + undefined, + false, + undefined, + undefined, + WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, ); } @@ -9578,6 +9587,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) { @@ -9945,6 +9955,7 @@ function resolveAndDamageMobInWork( : sourceWeaponChannel === "off-hand" ? "offhand" : sourceWeaponChannel === "ranged" ? "ranged" : undefined : undefined, + reportedAbilityId, ); maybeProcShamanFlurryInWork( work, @@ -24460,6 +24471,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/playerAutoAttackEventLineage.integration.test.ts b/src/game/playerAutoAttackEventLineage.integration.test.ts new file mode 100755 index 00000000..32334af3 --- /dev/null +++ b/src/game/playerAutoAttackEventLineage.integration.test.ts @@ -0,0 +1,181 @@ +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 { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +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: "one-hand" | "off-hand") { + return { + instanceId, + itemId: 1, + itemKey: instanceId, + name: instanceId, + quality: "uncommon" as const, + inventorySlot, + armorType: null, + weaponType: "dagger", + 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, + }; +} + +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("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); + }); +});