import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura } from "./combatAuras"; import { useCombatStore } from "./combatStore"; import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { clearMobRuntimeRegistry, setMobPosition, type CombatPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { enemyTriggeredShoutMovementSpeedMultiplier } from "./enemyTriggeredShouts"; import { WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG } from "./wow335PaladinAuraMasteryRuntime"; const MOB = "source-triggered-shout"; const positions = new Map(); const sourceAuraId = (spellId: number) => `enemy-control:source-spell-${spellId}`; const auras = (spellId: number) => useCombatStore.getState().auras.filter((aura) => aura.definition.id === sourceAuraId(spellId)); beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); positions.clear(); resetCombatSpatialProvider(); }); afterEach(() => resetCombatSpatialProvider()); function prepare(playerPosition: CombatPosition, memberPositions: readonly CombatPosition[], statRuleset: "wow" | "rom" = "wow") { useCombatStore.getState().initializeCharacter({ classId: statRuleset === "rom" ? "rom-priest" : "warrior", ...(statRuleset === "rom" ? { secondaryClassId: "rom-knight" as const } : {}), level: 30, statRuleset, }); useCombatStore.getState().setPlayerPosition(playerPosition); usePartyStore.getState().initializeParty("source-shout-fixture", 30, useCombatStore.getState().health, "damage", 2, statRuleset === "rom" ? "rom" : "wow"); const original = usePartyStore.getState().members[0]!; // Explicit spatial fixture, including >5 actors to exercise the source cap. const members = memberPositions.map((_, index) => ({ ...original, id: `shout-member-${index}` })); usePartyStore.setState({ members }); memberPositions.forEach((position, index) => { registerPartyRuntimePosition(members[index]!.id, position); positions.set(members[index]!.id, position); }); positions.set(PLAYER_AGGRO_ID, playerPosition); positions.set(MOB, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerCombatSpatialProvider({ actor: (id) => positions.has(id) ? { id, position: positions.get(id)!, yaw: 0 } : null, hasLineOfSight: (_source, target) => target[0] !== 3, projectGroundPoint: (position) => position, }); return members; } function engage(spellId: 5246 | 19134, castTimeMs = 0, sourceProfile: "ascension" | "epoch" = "ascension") { const attack = requireDungeonDefinition("stormwind-stockade").entities["entry-1663"]!.combat!.attacks .find((candidate) => candidate.spellId === spellId)!; expect(attack.effect?.kind).toBe("triggered-shout"); if (attack.effect?.kind !== "triggered-shout") throw new Error("Expected source shout fixture"); useCombatStore.getState().registerMob(MOB, { name: "Dextren source fixture", serverEntry: 1663, level: 30, maxHealth: 100_000, xpReward: 0, attacks: [{ ...attack, effect: { ...attack.effect, sourceProfile }, castTimeMs, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1 }], }); setMobPosition(MOB, [0, 0, 0]); useCombatStore.getState().damageMob(MOB, 1, 1_000); useCombatStore.getState().advanceMobCombat(1_000); if (!castTimeMs) useCombatStore.getState().advanceMobCombat(2_650); } describe("actual source shout victim and area consumers", () => { it.each(["player", "party"] as const)("shortens the whole parent fear/speed aura for a %s duration modifier", (recipient) => { const [member] = prepare([2, 0, 0], [[5, 0, 0]]); const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id; useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: { id: "synthetic-source-duration-modifier", name: "Synthetic sourced duration modifier", disposition: "buff", durationMs: 10_000, utilityTags: [WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG] }, sourceId: actorId, targetId: actorId, now: 1_000, }).auras] })); engage(19134); const parent = auras(19134).find((aura) => aura.targetId === actorId)!; expect(parent.expiresAt! - parent.appliedAt).toBe(2_000); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, parent.expiresAt! - 1)).toBe(1.25); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, parent.expiresAt!)).toBe(1); }); it.each(["player", "party"] as const)("rejects the whole shout and its riders on a fear-immune %s", (recipient) => { const [member] = prepare([2, 0, 0], [[5, 0, 0]]); const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id; useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: { id: "synthetic-fear-immunity", name: "Synthetic fear immunity", disposition: "buff", durationMs: 10_000, controlImmunities: ["fear"] }, sourceId: actorId, targetId: actorId, now: 1_000, }).auras] })); engage(5246); expect(auras(20511).filter((aura) => aura.targetId === actorId)).toHaveLength(0); expect(auras(5246).filter((aura) => aura.targetId === actorId)).toHaveLength(0); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, 2_650)).toBe(1); expect(recipient === "player" ? useCombatStore.getState().controlledUntil : usePartyStore.getState().members.find((entry) => entry.id === actorId)!.controlledUntil).toBe(0); }); it("keeps the Ascension child physical rider under stun immunity and applies parent fear plus speed", () => { prepare([2, 0, 0], []); useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: { id: "synthetic-stun-immunity", name: "Synthetic stun immunity", disposition: "buff", durationMs: 10_000, controlImmunities: ["stun"] }, sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000, }).auras] })); engage(5246); expect(auras(20511)).toHaveLength(1); expect(auras(20511)[0]!.definition.utilityTags).not.toContain("enemy-control-source:stun"); expect(auras(20511)[0]!.definition.modifiers).toContainEqual({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15, }); expect(useCombatStore.getState().controlMechanic).toBe("fear"); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, PLAYER_AGGRO_ID, 2_650)).toBe(1.25); }); it.each([ ["ascension", 5246, 5_000], ["ascension", 19134, 4_000], ["epoch", 5246, 8_000], ["epoch", 19134, 6_000], ] as const)("stores %s spell %i speed for player and party until its %ims expiry", (profile, spellId, durationMs) => { const [member] = prepare([2, 0, 0], [[5, 0, 0]]); engage(spellId, 0, profile); const state = useCombatStore.getState(); const expiresAt = auras(spellId)[0]!.expiresAt!; for (const actorId of [PLAYER_AGGRO_ID, member!.id]) { const parent = auras(spellId).find((aura) => aura.targetId === actorId)!; expect(parent.expiresAt! - parent.appliedAt).toBe(durationMs); expect(enemyTriggeredShoutMovementSpeedMultiplier(state.auras, actorId, parent.appliedAt)).toBe(1.25); expect(enemyTriggeredShoutMovementSpeedMultiplier(state.auras, actorId, parent.expiresAt!)).toBe(1); } useCombatStore.getState().damageMob(MOB, 1_000_000, 2_651); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, member!.id, 2_652)).toBe(1.25); useCombatStore.getState().advanceMobCombat(expiresAt + 1); expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, member!.id, expiresAt + 1)).toBe(1); }); it.each(["wow", "rom"] as const)("keeps the 19134 child on an outside-area %s victim and reaches nearby actors without area LOS", (statRuleset) => { const [nearby, outside] = prepare([9, 0, 0], [[3, 0, 0], [12, 0, 0]], statRuleset); engage(19134); expect(auras(29544).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]); expect(auras(19134).map((aura) => aura.targetId)).toEqual([nearby!.id]); expect(auras(29544)[0]!.expiresAt! - auras(29544)[0]!.appliedAt).toBe(3_000); expect(auras(19134)[0]!.expiresAt! - auras(19134)[0]!.appliedAt).toBe(4_000); expect(usePartyStore.getState().members.find((member) => member.id === nearby!.id)!.controlMechanic).toBe("fear"); expect(usePartyStore.getState().members.find((member) => member.id === outside!.id)!.controlledUntil).toBe(0); expect(useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === MOB && event.kind === "damage")).toHaveLength(0); }); it("keeps the captured child victim when threat changes and stores child stun plus area fear", () => { const [member] = prepare([2, 0, 0], [[5, 0, 0]]); engage(5246, 2_000); expect(useCombatStore.getState().mobs[MOB]!.activeCast?.targetActorId).toBe(PLAYER_AGGRO_ID); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [MOB]: { ...state.mobs[MOB]!, threatByActor: { [PLAYER_AGGRO_ID]: 1, [member!.id]: 1_000 }, targetActorId: member!.id } } })); useCombatStore.getState().advanceMobCombat(3_000); expect(auras(20511).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]); expect(new Set(auras(5246).map((aura) => aura.targetId))).toEqual(new Set([PLAYER_AGGRO_ID, member!.id])); expect(auras(20511)[0]!.definition.modifiers).toContainEqual({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15, }); expect(useCombatStore.getState().controlMechanic).toBe("stun"); expect(auras(20511)[0]!.definition.dispelCategory).toBeUndefined(); useCombatStore.getState().damageMob(MOB, 1_000_000, 3_001); expect(useCombatStore.getState().mobs[MOB]!.dead).toBe(true); expect(auras(20511)).toHaveLength(1); expect(auras(5246)).toHaveLength(2); }); it("caps the shared 5246 area set at five while preserving the independently selected child", () => { prepare([2, 0, 0], Array.from({ length: 6 }, (_, index) => [4, 0, index * 0.2] as CombatPosition)); engage(5246); expect(auras(20511).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]); expect(auras(5246)).toHaveLength(5); expect(new Set(auras(5246).map((aura) => aura.targetId)).size).toBe(5); }); it.each(["player", "party"] as const)("retains the %s source control when a separate magic control is dispelled", (recipient) => { const [member] = prepare([2, 0, 0], [[3, 0, 0]]); engage(19134); const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id; const source = auras(19134).find((aura) => aura.targetId === actorId)!; const now = source.appliedAt + 1; expect(useCombatStore.getState().dispelActor(actorId, ["magic"], 10, now)).toBe(0); useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: { id: "enemy-control:synthetic-extra-magic", name: "Synthetic separate magic control", disposition: "debuff", durationMs: 10_000, dispelCategory: "magic", }, sourceId: "synthetic-source", targetId: actorId, now }).auras], ...(recipient === "player" ? { controlledUntil: now + 10_000, controlMechanic: "stun" as const } : {}), })); expect(useCombatStore.getState().dispelActor(actorId, ["magic"], 1, now + 1)).toBe(1); const state = useCombatStore.getState(); expect(recipient === "player" ? state.controlledUntil : state.actors[actorId]!.controlledUntil).toBe(source.expiresAt); expect(recipient === "player" ? state.controlMechanic : usePartyStore.getState().members.find((entry) => entry.id === actorId)!.controlMechanic).toBe("fear"); }); });