import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition } from "./enemySourceAuras"; import { useCombatStore, type MobCombatState } from "./combatStore"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { resetCombatSpatialProvider } from "./combatSpatial"; const stockade = requireDungeonDefinition("stormwind-stockade"); function importedAttack(entry: number, spellId: number): EnemyAttackDefinition { const attack = stockade.entities[`entry-${entry}`]?.combat?.attacks .find((candidate) => candidate.spellId === spellId); if (!attack) throw new Error(`Missing imported Stockade attack ${entry}:${spellId}`); return attack; } function registerMob(id: string, entry: number, attacks: readonly EnemyAttackDefinition[]): void { useCombatStore.getState().registerMob(id, { name: `Imported Stockade entry ${entry}`, serverEntry: entry, level: 30, maxHealth: 50_000, xpReward: 0, attacks, }); setMobPosition(id, [2, 0, 0]); } function engageMob(id: string, now: number): void { useCombatStore.getState().selectMob(id); useCombatStore.getState().damageMob(id, 1, now); } function delayedMainhand(): EnemyAttackDefinition { return { ...DEFAULT_ENEMY_ATTACK, initialCooldownMs: [60_000, 60_000], repeatCooldownMs: [2_000, 2_000], }; } function bloodlustAura(casterId: string, appliedAt: number) { const contract = enemySourceAuraContract(6742, "ascension"); if (!contract) throw new Error("Missing pinned Bloodlust source contract"); const definition = supportedEnemySourceAuraDefinition(contract); if (!definition) throw new Error("Missing supported Bloodlust source definition"); const aura = applyEnemySourceAura([], definition, casterId, casterId, appliedAt)[0]; if (!aura) throw new Error("Could not create sourced Bloodlust aura"); return aura; } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); }); afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); }); describe("imported Stockade Dual Wield and Thrash", () => { it("preserves the three sourced 674 self casts and Bazil's 29651 capability row", () => { for (const [entry, eventType] of [[1696, 0], [1711, 1], [1716, 1]] as const) { const attack = importedAttack(entry, 674); expect(attack).toMatchObject({ target: "self", source: { entry, eventType, actionType: 11, targetType: 1 }, effect: { kind: "weapon-capability", capability: "dual-wield", sourceSpellId: 674, sourceProfile: "ascension" }, }); expect(attack.repeatCooldownMs).toEqual([0, 0]); } expect(importedAttack(1716, 29651)).toMatchObject({ effect: { kind: "weapon-capability", sourceSpellId: 29651, sourceProfile: "ascension" }, }); }); it("enables Targorr's offhand from the imported in-combat cast and uses a separate half-delay swing clock", () => { const mobId = "enemy-extra-targorr-dual-wield"; const importedDualWield = importedAttack(1696, 674); // Synthetic clock knobs make the exact imported SmartAI effect testable in a short fixture. const testDualWield: EnemyAttackDefinition = { ...importedDualWield, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }; registerMob(mobId, 1696, [delayedMainhand(), testDualWield]); engageMob(mobId, 1_000); useCombatStore.getState().advanceMobCombat(1_000); const normalizedCast = useCombatStore.getState().mobs[mobId]!.attacks .find((attack) => attack.id === testDualWield.id)!; const releaseAt = 1_000 + (normalizedCast.castTimeMs ?? 0); useCombatStore.getState().advanceMobCombat(releaseAt + 1); let state = useCombatStore.getState(); const mob = state.mobs[mobId]!; expect(mob.dualWieldEnabled).toBe(true); const firstOffhandAt = releaseAt + 201; expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandAt); expect(mob.abilityReadyAt[testDualWield.id]).toBe(Number.POSITIVE_INFINITY); useCombatStore.getState().advanceMobCombat(firstOffhandAt - 1); expect(useCombatStore.getState().combatEvents.filter((event) => ( event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("offhand:") ))).toHaveLength(0); useCombatStore.getState().advanceMobCombat(firstOffhandAt); state = useCombatStore.getState(); const offhandHits = state.combatEvents.filter((event) => ( event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("offhand:") )); expect(offhandHits).toHaveLength(1); expect(offhandHits[0]?.attackHand).toBe("offhand"); expect(offhandHits[0]?.extraAttackSourceSpellId).toBeUndefined(); expect(offhandHits[0]?.rawAmount).toBe(Math.round(mob.attackDamage * 0.5)); expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]).toBe(61_000); expect(state.mobs[mobId]?.nextEnemyOffhandAttackAt).toBeGreaterThan(firstOffhandAt); }); it("starts Bazil's sourced out-of-combat capability with a half-swing initial offhand delay", () => { const mobId = "enemy-extra-bazil-dual-wield-ooc"; const importedDualWield = importedAttack(1716, 674); expect(importedDualWield.activation).toBe("out-of-combat"); const testDualWield: EnemyAttackDefinition = { ...importedDualWield, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }; registerMob(mobId, 1716, [delayedMainhand(), testDualWield]); useCombatStore.getState().advanceMobCombat(5_000); expect(useCombatStore.getState().mobs[mobId]?.dualWieldEnabled).toBe(true); expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt).toBe(0); engageMob(mobId, 6_000); useCombatStore.getState().advanceMobCombat(6_000); const mob = useCombatStore.getState().mobs[mobId]!; expect(mob.nextEnemyOffhandAttackAt).toBe(6_000 + Math.round(mob.swingMs * 0.5)); }); it("retimes only in-flight melee clocks for Bloodlust; Thrash drains next update and spell cooldown stays fixed", () => { const mobId = "enemy-extra-targorr-haste-clock"; const thrash: EnemyAttackDefinition = { ...importedAttack(1696, 3391), initialCooldownMs: [60_000, 60_000], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }; registerMob(mobId, 1696, [delayedMainhand(), thrash]); engageMob(mobId, 10_000); const current = useCombatStore.getState().mobs[mobId]!; const sourceSpellReadyAt = 17_000; useCombatStore.setState((state) => ({ auras: [bloodlustAura(mobId, 10_999)], mobs: { ...state.mobs, [mobId]: { ...current, dualWieldEnabled: true, nextEnemyOffhandAttackAt: 12_000, pendingExtraAttacks: [{ id: `thrash:${mobId}:source-cast`, sourceSpellId: 3391, targetActorId: PLAYER_AGGRO_ID, queuedAt: 11_000, remaining: 2, }], abilityReadyAt: { ...current.abilityReadyAt, "basic-attack": 14_000, [thrash.id]: sourceSpellReadyAt, }, meleeAttackSpeedMultiplier: 1, } }, })); useCombatStore.getState().advanceMobCombat(11_000); let state = useCombatStore.getState(); let mob = state.mobs[mobId]!; expect(mob.nextEnemyOffhandAttackAt).toBe(11_000 + Math.round(1_000 / 1.3)); expect(mob.pendingExtraAttacks).toHaveLength(1); expect(mob.pendingExtraAttacks?.[0]?.queuedAt).toBe(11_000); expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt); expect(state.combatEvents.filter((event) => event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("thrash:") )).toHaveLength(0); useCombatStore.getState().advanceMobCombat(11_001); state = useCombatStore.getState(); mob = state.mobs[mobId]!; const queuedHits = state.combatEvents.filter((event) => event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("thrash:")); expect(queuedHits).toHaveLength(2); expect(queuedHits.every((event) => event.attackHand === "extra-mainhand" && event.extraAttackSourceSpellId === 3391)).toBe(true); expect(queuedHits.every((event) => event.occurredAt === 11_001)).toBe(true); expect(mob.pendingExtraAttacks).toEqual([]); expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt); useCombatStore.setState({ auras: [] }); useCombatStore.getState().advanceMobCombat(11_100); state = useCombatStore.getState(); mob = state.mobs[mobId]!; expect(mob.nextEnemyOffhandAttackAt).toBe(11_100 + Math.round((11_000 + Math.round(1_000 / 1.3) - 11_100) * 1.3)); expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt); }); it("drops queued Thrash outside melee range and clears weapon state for dead targets", () => { const rangedMobId = "enemy-extra-thrash-out-of-range"; registerMob(rangedMobId, 1696, [delayedMainhand()]); engageMob(rangedMobId, 20_000); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [rangedMobId]: { ...state.mobs[rangedMobId]!, dualWieldEnabled: true, nextEnemyOffhandAttackAt: 25_000, pendingExtraAttacks: [{ id: `thrash:${rangedMobId}:range`, sourceSpellId: 3391, targetActorId: PLAYER_AGGRO_ID, queuedAt: 20_000, remaining: 2, }], } }, })); useCombatStore.getState().setPlayerPosition([20, 0, 0]); useCombatStore.getState().advanceMobCombat(20_001); expect(useCombatStore.getState().mobs[rangedMobId]?.pendingExtraAttacks).toEqual([]); expect(useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === rangedMobId && event.kind === "damage" && event.abilityId?.startsWith("thrash:"))).toHaveLength(0); const deadTargetMobId = "enemy-extra-thrash-dead-target"; useCombatStore.getState().setPlayerPosition([0, 0, 0]); registerMob(deadTargetMobId, 1696, [delayedMainhand()]); engageMob(deadTargetMobId, 21_000); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [deadTargetMobId]: { ...state.mobs[deadTargetMobId]!, dualWieldEnabled: true, nextEnemyOffhandAttackAt: 30_000, pendingExtraAttacks: [{ id: `thrash:${deadTargetMobId}:dead`, sourceSpellId: 3391, targetActorId: PLAYER_AGGRO_ID, queuedAt: 21_000, remaining: 2, }], lastDamagedActorId: PLAYER_AGGRO_ID, } }, health: 0, })); useCombatStore.getState().advanceMobCombat(21_001); const deadState = useCombatStore.getState().mobs[deadTargetMobId]!; expect(deadState.pendingExtraAttacks).toEqual([]); expect(deadState.dualWieldEnabled).toBe(false); expect(deadState.nextEnemyOffhandAttackAt).toBe(0); expect(deadState.lastDamagedActorId).toBeNull(); }); it("clears queued attacks and offhand timers on navigation/leash and full encounter resets", () => { const mobId = "enemy-extra-thrash-navigation-reset"; registerMob(mobId, 1696, [delayedMainhand()]); engageMob(mobId, 30_000); const prime = (id: string) => useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [id]: { ...state.mobs[id]!, dualWieldEnabled: true, nextEnemyOffhandAttackAt: 45_000, pendingExtraAttacks: [{ id: `thrash:${id}:reset`, sourceSpellId: 3391, targetActorId: PLAYER_AGGRO_ID, queuedAt: 30_000, remaining: 2, }], lastDamagedActorId: PLAYER_AGGRO_ID, meleeAttackSpeedMultiplier: 1 / 1.3, } }, })); prime(mobId); expect(useCombatStore.getState().resetMobForNavigation(mobId, false, 31_000)).toBe(true); let mob = useCombatStore.getState().mobs[mobId]!; expect(mob.pendingExtraAttacks).toEqual([]); expect(mob.dualWieldEnabled).toBe(false); expect(mob.nextEnemyOffhandAttackAt).toBe(0); expect(mob.lastDamagedActorId).toBeNull(); expect(mob.meleeAttackSpeedMultiplier).toBe(1); prime(mobId); useCombatStore.getState().resetEncounter(); mob = useCombatStore.getState().mobs[mobId]!; expect(mob.pendingExtraAttacks).toEqual([]); expect(mob.dualWieldEnabled).toBe(false); expect(mob.nextEnemyOffhandAttackAt).toBe(0); expect(mob.lastDamagedActorId).toBeNull(); expect(mob.meleeAttackSpeedMultiplier).toBe(1); }); it("does not grant source Dual Wield capability to RuneWaker, summoned, or character-defender mobs", () => { const importedDualWield = importedAttack(1716, 674); const testDualWield: EnemyAttackDefinition = { ...importedDualWield, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }; const fixtures = [ ["enemy-extra-rw-exclusion", { runewakerNpcId: 7 }], ["enemy-extra-summon-exclusion", { summonedBy: "summoner" }], ["enemy-extra-defender-exclusion", { characterDefender: {} as MobCombatState["characterDefender"] }], ] as const; for (const [id] of fixtures) registerMob(id, 1716, [testDualWield]); useCombatStore.setState((state) => { const mobs = { ...state.mobs }; for (const [id, excludedState] of fixtures) { mobs[id] = { ...mobs[id]!, ...excludedState, dualWieldEnabled: true, pendingExtraAttacks: [], nextEnemyOffhandAttackAt: 0 }; } return { mobs }; }); useCombatStore.getState().advanceMobCombat(40_000); for (const [id] of fixtures) expect(useCombatStore.getState().mobs[id]?.dualWieldEnabled).toBe(false); }); it("queues Targorr's imported Thrash for two independent next-update mainhand packets", () => { const mobId = "enemy-extra-targorr-thrash"; const importedThrash = importedAttack(1696, 3391); expect(importedThrash).toMatchObject({ id: "spell-3391", effect: { kind: "extra-attacks", sourceSpellId: 3391, sourceProfile: "ascension", count: 2 }, }); // The source has no Stockade SmartAI cast row for Thrash; these are synthetic test-only schedule values. const syntheticThrashClock: EnemyAttackDefinition = { ...importedThrash, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }; registerMob(mobId, 1696, [delayedMainhand(), syntheticThrashClock]); engageMob(mobId, 2_000); useCombatStore.getState().advanceMobCombat(2_000); const normalizedThrash = useCombatStore.getState().mobs[mobId]!.attacks .find((attack) => attack.id === syntheticThrashClock.id)!; const releaseAt = 2_000 + (normalizedThrash.castTimeMs ?? 0); useCombatStore.getState().advanceMobCombat(releaseAt + 1); let mob = useCombatStore.getState().mobs[mobId]!; expect(mob.pendingExtraAttacks).toEqual([{ id: `thrash:${mobId}:spell-3391:0:${releaseAt + 1}`, sourceSpellId: 3391, targetActorId: PLAYER_AGGRO_ID, queuedAt: releaseAt + 1, remaining: 2, }]); expect(useCombatStore.getState().combatEvents.filter((event) => ( event.sourceActorId === mobId && event.kind === "damage" ))).toHaveLength(0); useCombatStore.getState().advanceMobCombat(releaseAt + 2); mob = useCombatStore.getState().mobs[mobId]!; const swings = useCombatStore.getState().combatEvents.filter((event) => ( event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("thrash:") )); expect(swings).toHaveLength(2); expect(new Set(swings.map((event) => event.abilityId)).size).toBe(2); expect(swings.every((event) => event.targetActorId === PLAYER_AGGRO_ID)).toBe(true); expect(mob.pendingExtraAttacks).toEqual([]); expect(mob.abilityCastCounts[syntheticThrashClock.id]).toBe(1); }); });