import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useCombatStore } from "./combatStore"; import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks"; import { CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK, enemyFlagDrivenOffhandContract, } from "./enemyAutomaticOffhand"; import { creatureTemplateReference } from "./azerothCoreReference"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { resetCombatSpatialProvider } from "./combatSpatial"; const AUTO_WEAPON_ENTRY = 1696; const AUTO_WEAPON_GUID = 84027; // Pinned spawn: equipment 1, offhand Item.dbc Class 2. const EMPTY_OFFHAND_ENTRY = 10447; const EMPTY_OFFHAND_GUID = 20; const SHIELD_OFFHAND_ENTRY = 26796; const SHIELD_OFFHAND_GUID = 4764; function isolatedMainhand(): EnemyAttackDefinition { return { ...DEFAULT_ENEMY_ATTACK, initialCooldownMs: [60_000, 60_000], repeatCooldownMs: [60_000, 60_000], }; } function registerSourceMob(id: string, entry: number, sourceCreatureGuid?: number, runewakerNpcId?: number): void { useCombatStore.getState().registerMob(id, { name: `Source equipment fixture ${entry}`, serverEntry: entry, ...(sourceCreatureGuid === undefined ? {} : { sourceCreatureGuid }), ...(runewakerNpcId === undefined ? {} : { runewakerNpcId }), level: 30, maxHealth: 50_000, xpReward: 0, attacks: [isolatedMainhand()], }); setMobPosition(id, [2, 0, 0]); } function engageAt(id: string, now: number): void { expect(useCombatStore.getState().selectMob(id)).toBe(true); useCombatStore.getState().damageMob(id, 1, now); useCombatStore.getState().advanceMobCombat(now); } function firstOffhandAt(id: string, engagedAt: number): number { const mob = useCombatStore.getState().mobs[id]!; return engagedAt + Math.round(mob.swingMs * 0.5); } function pinnedFlagsExtra(entry: number): number { const flagsExtra = creatureTemplateReference(entry)?.flagsExtra; if (flagsExtra === undefined) throw new Error(`Missing pinned flags_extra for creature ${entry}`); return flagsExtra; } function offhandHits(id: string) { return useCombatStore.getState().combatEvents.filter((event) => ( event.sourceActorId === id && event.kind === "damage" && event.abilityId?.startsWith("offhand:") )); } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); }); afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); }); describe("pinned spawn-equipment AUTO offhand integration", () => { it("enables an ordinary source offhand swing from a validated weapon without the template flag", () => { const mobId = "enemy-equipment-auto-targorr"; expect(pinnedFlagsExtra(AUTO_WEAPON_ENTRY) & CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK).toBe(0); expect(enemyFlagDrivenOffhandContract(AUTO_WEAPON_ENTRY)).toBeUndefined(); registerSourceMob(mobId, AUTO_WEAPON_ENTRY, AUTO_WEAPON_GUID); let mob = useCombatStore.getState().mobs[mobId]!; expect(mob).toMatchObject({ serverEntry: AUTO_WEAPON_ENTRY, sourceCreatureGuid: AUTO_WEAPON_GUID, sourceEquipment: { entry: AUTO_WEAPON_ENTRY, selector: 1, equipmentId: 1, virtualItemIds: [5305, 5305, 0], offhandItemId: 5305, offhandItemClass: 2, autoOffhand: { source: "auto-offhand-weapon", dualWieldMode: "auto", requiresEquippedWeapon: true, }, }, dualWieldEnabled: true, }); const engagedAt = 1_000; engageAt(mobId, engagedAt); mob = useCombatStore.getState().mobs[mobId]!; const mainhandReadyAt = mob.abilityReadyAt["basic-attack"]; const firstSwingAt = firstOffhandAt(mobId, engagedAt); expect(mob.nextEnemyOffhandAttackAt).toBe(firstSwingAt); expect(mainhandReadyAt).toBeGreaterThan(firstSwingAt); useCombatStore.getState().advanceMobCombat(firstSwingAt - 1); expect(offhandHits(mobId)).toHaveLength(0); useCombatStore.getState().advanceMobCombat(firstSwingAt); const hits = offhandHits(mobId); expect(hits).toHaveLength(1); expect(hits[0]).toMatchObject({ attackHand: "offhand", rawAmount: Math.round(mob.attackDamage * 0.5), occurredAt: firstSwingAt, }); const afterSwing = useCombatStore.getState().mobs[mobId]!; expect(afterSwing.abilityReadyAt["basic-attack"]).toBe(mainhandReadyAt); expect(afterSwing.nextEnemyOffhandAttackAt).toBe(firstSwingAt + mob.swingMs); }); it("preserves the source AUTO capability through evade and revive resets, rearming the half-swing timer", () => { const mobId = "enemy-equipment-auto-lifecycle"; registerSourceMob(mobId, AUTO_WEAPON_ENTRY, AUTO_WEAPON_GUID); const engagedAt = 1_000; engageAt(mobId, engagedAt); expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt) .toBe(firstOffhandAt(mobId, engagedAt)); expect(useCombatStore.getState().resetMobForNavigation(mobId, false, 3_000)).toBe(true); let mob = useCombatStore.getState().mobs[mobId]!; expect(mob.sourceEquipment?.offhandItemClass).toBe(2); expect(mob.dualWieldEnabled).toBe(true); expect(mob.nextEnemyOffhandAttackAt).toBe(0); const afterEvadeAt = 13_001; engageAt(mobId, afterEvadeAt); mob = useCombatStore.getState().mobs[mobId]!; expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandAt(mobId, afterEvadeAt)); mob = useCombatStore.getState().mobs[mobId]!; expect(useCombatStore.getState().damageMob(mobId, mob.health, 14_000).killed).toBe(true); expect(useCombatStore.getState().mobs[mobId]?.dead).toBe(true); useCombatStore.getState().resetEncounter(); mob = useCombatStore.getState().mobs[mobId]!; expect(mob).toMatchObject({ dead: false, sourceCreatureGuid: AUTO_WEAPON_GUID, dualWieldEnabled: true, nextEnemyOffhandAttackAt: 0, }); expect(mob.sourceEquipment?.virtualItemIds).toEqual([5305, 5305, 0]); const afterReviveAt = 20_000; engageAt(mobId, afterReviveAt); expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt) .toBe(firstOffhandAt(mobId, afterReviveAt)); }); it("fails closed for absent or mismatched spawn data, empty and shield offhands, and native registration", () => { const fixtures: readonly { readonly id: string; readonly entry: number; readonly guid?: number; readonly runewakerNpcId?: number; }[] = [ { id: "enemy-equipment-no-guid", entry: AUTO_WEAPON_ENTRY }, { id: "enemy-equipment-mismatched-guid", entry: 1711, guid: AUTO_WEAPON_GUID }, { id: "enemy-equipment-empty-offhand", entry: EMPTY_OFFHAND_ENTRY, guid: EMPTY_OFFHAND_GUID }, { id: "enemy-equipment-shield-offhand", entry: SHIELD_OFFHAND_ENTRY, guid: SHIELD_OFFHAND_GUID }, { id: "enemy-equipment-native-exclusion", entry: AUTO_WEAPON_ENTRY, guid: AUTO_WEAPON_GUID, runewakerNpcId: 7, }, ] as const; for (const fixture of fixtures) { expect(pinnedFlagsExtra(fixture.entry) & CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK).toBe(0); registerSourceMob(fixture.id, fixture.entry, fixture.guid, fixture.runewakerNpcId); } const state = useCombatStore.getState(); for (const id of [ "enemy-equipment-no-guid", "enemy-equipment-mismatched-guid", "enemy-equipment-native-exclusion", ]) { const mob = state.mobs[id]!; expect(mob.sourceEquipment).toBeUndefined(); expect(mob.dualWieldEnabled).toBe(false); } expect(state.mobs["enemy-equipment-empty-offhand"]).toMatchObject({ sourceEquipment: { virtualItemIds: [11323, 0, 0], offhandItemId: 0, offhandItemClass: null }, dualWieldEnabled: false, }); expect(state.mobs["enemy-equipment-empty-offhand"]?.sourceEquipment?.autoOffhand).toBeUndefined(); expect(state.mobs["enemy-equipment-shield-offhand"]).toMatchObject({ sourceEquipment: { virtualItemIds: [28951, 28940, 0], offhandItemId: 28940, offhandItemClass: 4, }, dualWieldEnabled: false, }); expect(state.mobs["enemy-equipment-shield-offhand"]?.sourceEquipment?.autoOffhand).toBeUndefined(); }); });