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); }); });