import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { resolveDamageValue } from "./combatAuras"; import { auraAdjustedCharacterStats, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { deriveCharacterStats } from "./wotlkStats"; const sourceAttack = (dungeonId: string, entry: number, spellId: number): EnemyAttackDefinition => { const found = requireDungeonDefinition(dungeonId).entities[`entry-${entry}`]?.combat?.attacks .find((attack) => attack.spellId === spellId); if (!found) throw new Error(`Missing imported attack ${dungeonId}:${entry}:${spellId}`); return found; }; const sourceCast = ( attack: EnemyAttackDefinition, initialCooldownMs = 0, ): EnemyAttackDefinition => ({ ...attack, initialCooldownMs: [initialCooldownMs, initialCooldownMs], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1, }); function registerMob( id: string, serverEntry: number, attacks: readonly EnemyAttackDefinition[], position: readonly [number, number, number], ): void { useCombatStore.getState().registerMob(id, { name: id, serverEntry, level: 30, maxHealth: 50_000, xpReward: 0, attacks, }); setMobPosition(id, position); } function engageMob(id: string, now: number): void { useCombatStore.getState().selectMob(id); useCombatStore.getState().damageMob(id, 1, now); } function createPartyPositions( positions: readonly (readonly [number, number, number])[], downIndex?: number, ) { useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty( "enemy-source-aura-integration", 30, useCombatStore.getState().health, "damage", 5, "wow", ); const members = usePartyStore.getState().members.map((member, index) => ({ ...member, ...(index === downIndex ? { health: 0, status: "down" as const } : {}), })); usePartyStore.setState({ members }); members.forEach((member, index) => { registerPartyRuntimePosition(member.id, positions[index] ?? [40 + index, 0, 0]); }); useCombatStore.getState().synchronizeActors(); return members; } function runSingleImportedCast(id: string, startAt = 1_000): number { engageMob(id, startAt); const mob = useCombatStore.getState().mobs[id]!; const cast = mob.attacks[0]!; useCombatStore.getState().advanceMobCombat(startAt); const releaseAt = startAt + (cast.castTimeMs ?? 0) + 1; useCombatStore.getState().advanceMobCombat(releaseAt); return releaseAt; } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); }); afterEach(() => resetCombatSpatialProvider()); describe("imported enemy source auras in combat", () => { it("applies Stockade Enrage to physical damage and basic swing phase only", () => { useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); const enrage = sourceCast(sourceAttack("stormwind-stockade", 1696, 8599)); expect(enrage.effect).toMatchObject({ kind: "aura", wowAuraSource: { spellId: 8599, sourceProfile: "ascension" }, }); const unrelatedSpell: EnemyAttackDefinition = { ...DEFAULT_ENEMY_ATTACK, id: "unrelated-spell", name: "Unrelated Spell", delivery: "projectile", target: "primary", school: "fire", attackKind: "spell", range: 30, cooldownMs: 4_000, initialCooldownMs: [10_000, 10_000], repeatCooldownMs: [4_000, 4_000], }; const basicAttack: EnemyAttackDefinition = { ...DEFAULT_ENEMY_ATTACK, cooldownMs: 2_000, initialCooldownMs: [10_000, 10_000], repeatCooldownMs: [2_000, 2_000], }; const mobId = "stockade-enraged-mob"; registerMob(mobId, 1696, [basicAttack, unrelatedSpell, enrage], [2, 0, 0]); engageMob(mobId, 1_000); const mob = useCombatStore.getState().mobs[mobId]!; useCombatStore.setState({ mobs: { ...useCombatStore.getState().mobs, [mobId]: { ...mob, health: 15_000, abilityReadyAt: { "basic-attack": 3_600, "unrelated-spell": 4_000, [enrage.id]: 1_100, }, }, }, }); useCombatStore.getState().advanceMobCombat(1_100); const sourceCastTime = useCombatStore.getState().mobs[mobId]!.attacks .find((attack) => attack.id === enrage.id)!.castTimeMs ?? 0; const appliedAt = 1_100 + sourceCastTime; useCombatStore.getState().advanceMobCombat(appliedAt); let state = useCombatStore.getState(); const enrageAura = state.auras.find((aura) => ( aura.definition.id === "enemy-source-spell-8599" && aura.targetId === mobId )); expect(enrageAura).toBeDefined(); expect(enrageAura?.expiresAt).toBe(appliedAt + 120_000); expect(enrageAura?.definition.dispelCategory).toBe("enrage"); expect(resolveDamageValue(100, [enrageAura!], { direction: "dealt", school: "physical", attackKind: "melee", })).toBe(130); expect(resolveDamageValue(100, [enrageAura!], { direction: "dealt", school: "fire", attackKind: "spell", })).toBe(100); expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBeCloseTo(1 / 1.3); expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]) .toBe(appliedAt + Math.round((3_600 - appliedAt) / 1.3)); expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(4_000); const firstHastedSwingAt = state.mobs[mobId]!.abilityReadyAt["basic-attack"]; useCombatStore.getState().advanceMobCombat(firstHastedSwingAt); state = useCombatStore.getState(); expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]) .toBe(firstHastedSwingAt + Math.round(2_000 / 1.3)); const navigationResetAt = firstHastedSwingAt + 1; expect(useCombatStore.getState().resetMobForNavigation(mobId, true, navigationResetAt)).toBe(true); state = useCombatStore.getState(); expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBe(1); expect(state.mobs[mobId]?.abilityReadyAt).toEqual({}); expect(state.auras.some((aura) => ( aura.definition.id === "enemy-source-spell-8599" && (aura.sourceId === mobId || aura.targetId === mobId) ))).toBe(false); const reengageAt = navigationResetAt + 10_001; engageMob(mobId, reengageAt); const resetMob = useCombatStore.getState().mobs[mobId]!; useCombatStore.setState({ mobs: { ...useCombatStore.getState().mobs, [mobId]: { ...resetMob, health: 15_000, abilityReadyAt: { "basic-attack": reengageAt + 5_000, "unrelated-spell": reengageAt + 6_000, [enrage.id]: reengageAt, }, }, }, }); useCombatStore.getState().advanceMobCombat(reengageAt); const reengageCastTime = useCombatStore.getState().mobs[mobId]!.attacks .find((attack) => attack.id === enrage.id)!.castTimeMs ?? 0; const reengageAppliedAt = reengageAt + reengageCastTime; useCombatStore.getState().advanceMobCombat(reengageAppliedAt); state = useCombatStore.getState(); expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBeCloseTo(1 / 1.3); expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(reengageAt + 6_000); const reengageExpiresAt = reengageAppliedAt + 120_000; useCombatStore.setState((current) => { const currentMob = current.mobs[mobId]!; return { mobs: { ...current.mobs, [mobId]: { ...currentMob, abilityReadyAt: { ...currentMob.abilityReadyAt, "basic-attack": reengageExpiresAt + 400, "unrelated-spell": reengageExpiresAt + 600, }, }, }, }; }); useCombatStore.getState().advanceMobCombat(reengageExpiresAt); state = useCombatStore.getState(); expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBe(1); expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]).toBe(reengageExpiresAt + 520); expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(reengageExpiresAt + 600); const epochAttack = sourceCast(sourceAttack("the-nexus", 26723, 8599)); expect(epochAttack.effect).toMatchObject({ kind: "aura", wowAuraSource: { spellId: 8599, sourceProfile: "epoch" }, }); const epochId = "nexus-epoch-enraged-mob"; registerMob(epochId, 26723, [epochAttack], [2, 0, 0]); useCombatStore.setState((current) => ({ mobs: { ...current.mobs, [epochId]: { ...current.mobs[epochId]!, health: 10_000 } }, })); const epochReleaseAt = runSingleImportedCast(epochId, 200_000); const epochAura = useCombatStore.getState().auras.find((aura) => ( aura.definition.id === "enemy-source-spell-8599" && aura.targetId === epochId )); expect(epochAura?.expiresAt).toBe(epochReleaseAt + 120_000); expect(resolveDamageValue(100, [epochAura!], { direction: "dealt", school: "physical", attackKind: "melee", })).toBeCloseTo(110); expect(resolveDamageValue(100, [epochAura!], { direction: "dealt", school: "fire", attackKind: "spell", })).toBe(100); }); it("applies Stockade Battle Shout to living nearby friendly mobs with LOS and suppresses duplicate casters", () => { const members = createPartyPositions([[30, 0, 0], [31, 0, 0], [32, 0, 0], [33, 0, 0]]); registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: (_source, target) => target[0] !== 3, projectGroundPoint: (position) => position, }); const shout = sourceAttack("stormwind-stockade", 1716, 9128); const casterA = "stockade-shout-a"; const casterB = "stockade-shout-b"; registerMob(casterA, 1716, [sourceCast(shout)], [0, 0, 0]); registerMob(casterB, 1716, [sourceCast(shout, 5_000)], [5, 0, 0]); registerMob("shout-friend", 1716, [], [10, 0, 0]); registerMob("shout-near-friend", 1716, [], [14, 0, 0]); registerMob("shout-blocked-friend", 1716, [], [3, 0, 0]); registerMob("shout-far-friend", 1716, [], [27, 0, 0]); registerMob("shout-dead-friend", 1716, [], [12, 0, 0]); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "shout-dead-friend": { ...state.mobs["shout-dead-friend"]!, health: 0, dead: true }, }, })); engageMob(casterA, 1_000); engageMob(casterB, 1_000); useCombatStore.getState().advanceMobCombat(1_000); const castDuration = useCombatStore.getState().mobs[casterA]!.attacks[0]!.castTimeMs ?? 0; const firstReleaseAt = 1_000 + castDuration + 1; useCombatStore.getState().advanceMobCombat(firstReleaseAt); useCombatStore.getState().advanceMobCombat(6_000); const secondReleaseAt = 6_000 + castDuration + 1; useCombatStore.getState().advanceMobCombat(secondReleaseAt); const activeShouts = useCombatStore.getState().auras.filter((aura) => ( aura.definition.id === "enemy-source-spell-9128" )); const targetIds = new Set(activeShouts.map((aura) => aura.targetId)); expect(targetIds).toEqual(new Set([casterA, casterB, "shout-friend", "shout-near-friend"])); for (const targetId of targetIds) { expect(activeShouts.filter((aura) => aura.targetId === targetId)).toHaveLength(1); } const friendAura = activeShouts.find((aura) => aura.targetId === "shout-friend")!; expect(friendAura.sourceId).toBe(casterA); expect(friendAura.appliedAt).toBe(firstReleaseAt); expect(friendAura.expiresAt).toBe(firstReleaseAt + 120_000); expect(resolveDamageValue(100, [friendAura], { direction: "dealt", school: "physical", attackKind: "melee", })).toBeCloseTo(115); expect(resolveDamageValue(100, [friendAura], { direction: "dealt", school: "fire", attackKind: "spell", })).toBe(100); expect(activeShouts.some((aura) => ( aura.targetId === PLAYER_AGGRO_ID || members.some((member) => member.id === aura.targetId) ))).toBe(false); expect(useCombatStore.getState().combatEvents.some((event) => ( (event.sourceActorId === casterA || event.sourceActorId === casterB) && event.kind === "damage" ))).toBe(false); }); it("applies Demoralizing Shout only to living visible actors in the 10-yard area and adjusts AP", () => { const members = createPartyPositions([ [7, 0, 0], [12, 0, 0], [8, 0, 0], [4, 0, 0], ], 3); registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: (_source, target) => target[0] !== 5, projectGroundPoint: (position) => position, }); const caster = "stockade-demo-caster"; registerMob(caster, 1711, [sourceCast(sourceAttack("stormwind-stockade", 1711, 16244))], [0, 0, 1]); const releaseAt = runSingleImportedCast(caster); const demoralized = useCombatStore.getState().auras.filter((aura) => ( aura.definition.id === "enemy-source-spell-16244" )); expect(new Set(demoralized.map((aura) => aura.targetId))).toEqual(new Set([ PLAYER_AGGRO_ID, members[0]!.id, members[2]!.id, ])); const playerDebuff = demoralized.find((aura) => aura.targetId === PLAYER_AGGRO_ID)!; expect(playerDebuff.expiresAt).toBe(releaseAt + 15_000); expect(resolveDamageValue(100, [playerDebuff], { direction: "dealt", school: "physical", attackKind: "melee", })).toBe(90); expect(resolveDamageValue(100, [playerDebuff], { direction: "dealt", school: "fire", attackKind: "spell", })).toBe(100); expect(useCombatStore.getState().combatEvents.some((event) => ( event.sourceActorId === caster && event.kind === "damage" ))).toBe(false); const apCaster = "stockade-ap-debuff-caster"; registerMob(apCaster, 1715, [sourceCast(sourceAttack("stormwind-stockade", 1715, 13730))], [0, 0, 1]); const apReleaseAt = runSingleImportedCast(apCaster, 10_000); const apAura = useCombatStore.getState().auras.find((aura) => ( aura.definition.id === "enemy-source-spell-13730" && aura.targetId === PLAYER_AGGRO_ID )); expect(apAura?.expiresAt).toBe(apReleaseAt + 30_000); const baseStats = deriveCharacterStats("warrior", 30, EMPTY_ITEM_STATS, "human"); const adjustedStats = auraAdjustedCharacterStats( baseStats, useCombatStore.getState().auras, PLAYER_AURA_ENTITY_ID, apReleaseAt, ); expect(adjustedStats.attackPower).toBeCloseTo(baseStats.attackPower * 0.85); }); it("does not use the home leash as immunity to a sourced hostile area shout", () => { const members = createPartyPositions([[10, 0, 0], [50, 0, 0], [60, 0, 0], [70, 0, 0]]); useCombatStore.getState().setPlayerPosition([20, 0, 0]); const caster = "stockade-demo-leash"; registerMob(caster, 1711, [sourceCast(sourceAttack("stormwind-stockade", 1711, 16244))], [12, 0, 0]); useCombatStore.getState().damageMob(caster, 1, 1_000, { actorId: members[0]!.id, role: "damage", abilityId: "shout-leash-fixture", }); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [caster]: { ...state.mobs[caster]!, homePosition: [0, 0, 0], leashRange: 11, } } })); useCombatStore.getState().advanceMobCombat(1_000); const castTime = useCombatStore.getState().mobs[caster]!.attacks[0]!.castTimeMs ?? 0; useCombatStore.getState().advanceMobCombat(1_001 + castTime); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "enemy-source-spell-16244" && aura.targetId === PLAYER_AGGRO_ID)).toBe(true); }); });