import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { useCombatStore } from "./combatStore"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import referenceData from "./generated/azerothCoreReference.json"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { normalizeEnemyAttack, type EnemyAttackDefinition, type EnemyDamageSchool, } from "./enemyAttacks"; interface ImportedWailingAbilities { readonly wailingCaverns: { readonly abilitiesByEntry: Readonly>; }; } const IMPORTED_WAILING = referenceData as unknown as ImportedWailingAbilities; const ANACONDRA_THORNS = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["3671"] .find((attack) => attack.spellId === 8148)!; const ANACONDRA_LIGHTNING_BOLT = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["3671"] .find((attack) => attack.spellId === 9532)!; const CROCOLISK_TENDON_RIP = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["5053"] .find((attack) => attack.spellId === 3604)!; function spell( id: string, school: EnemyDamageSchool, castTimeMs = 1_000, ): EnemyAttackDefinition { return { id, name: id, spellId: id === "shadow-bolt" ? 686 : undefined, delivery: "projectile", target: "primary", school, animation: "cast", castTimeMs, range: 30, cooldownMs: 10_000, initialCooldownMs: [0, 0], repeatCooldownMs: [10_000, 10_000], damageMultiplier: 1, effect: { kind: "damage", multiplier: 1 }, }; } function registerCaster(attacks: readonly EnemyAttackDefinition[], id = "caster"): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0, attacks, }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().selectMob(id); useCombatStore.getState().damageMob(id, 1, 1_000); } function silenceMob(id: string, endsAt: number): void { useCombatStore.setState((state) => { const mob = state.mobs[id]; if (!mob) return state; return { mobs: { ...state.mobs, [id]: { ...mob, statuses: [...mob.statuses, { kind: "silence", sourceAbilityId: "vow-of-silence", sourceActorId: PLAYER_AGGRO_ID, endsAt, }], }, }, }; }); } function setMobAttackReadyAt(id: string, attackId: string, readyAt: number): void { useCombatStore.setState((state) => { const mob = state.mobs[id]; if (!mob) return state; return { mobs: { ...state.mobs, [id]: { ...mob, abilityReadyAt: { ...mob.abilityReadyAt, [attackId]: readyAt }, }, }, }; }); } describe("enemy casting and interrupts", () => { beforeEach(() => { clearMobRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); }); it("infers cast time only for authored cast animations without an explicit value", () => { const authoredCast = spell("authored-cast", "fire"); const { castTimeMs: _omitted, ...withoutCastTime } = authoredCast; expect(normalizeEnemyAttack(withoutCastTime).castTimeMs).toBe(1_500); expect(normalizeEnemyAttack({ ...withoutCastTime, castTimeMs: 0 }).castTimeMs).toBe(0); expect(normalizeEnemyAttack({ ...withoutCastTime, animation: "attack" }).castTimeMs).toBe(0); }); it("reduces modeled enemy melee damage by the active Vindication AP share", () => { const swingDamage = (vindicated: boolean): number => { clearMobRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); useCombatStore.getState().registerMob("ap-attacker", { name: "AP attacker", serverEntry: 598, level: 80, maxHealth: 10_000, xpReward: 0, attacks: [DEFAULT_ENEMY_ATTACK], }); setMobPosition("ap-attacker", [2, 0, 0]); useCombatStore.getState().selectMob("ap-attacker"); useCombatStore.getState().damageMob("ap-attacker", 1, 1_000); if (vindicated) { useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "ap-attacker": { ...state.mobs["ap-attacker"], auras: [{ id: "wow335:paladin:vindication", sourceAbilityId: "wow335-paladin-exorcism", sourceActorId: "paladin-caster", endsAt: 11_000, magnitude: 357, }], }, }, })); } const healthBeforeSwing = useCombatStore.getState().health; useCombatStore.getState().advanceMobCombat(1_450); return healthBeforeSwing - useCombatStore.getState().health; }; const unmodified = swingDamage(false); const reduced = swingDamage(true); expect(unmodified).toBeGreaterThan(0); expect(reduced).toBeGreaterThan(0); expect(reduced).toBeLessThan(unmodified); }); it("delays an enemy spell effect until its cast completes", () => { registerCaster([spell("shadow-bolt", "shadow")]); const startingHealth = useCombatStore.getState().health; expect(useCombatStore.getState().advanceMobCombat(1_000)).toBe(0); expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({ attackId: "shadow-bolt", name: "shadow-bolt", spellId: 686, school: "shadow", interruptible: true, targetActorId: "__player__", startedAt: 1_000, completesAt: 2_000, }); expect(useCombatStore.getState().health).toBe(startingHealth); expect(useCombatStore.getState().advanceMobCombat(1_999)).toBe(0); expect(useCombatStore.getState().health).toBe(startingHealth); expect(useCombatStore.getState().advanceMobCombat(2_000)).toBeGreaterThan(0); expect(useCombatStore.getState().health).toBeLessThan(startingHealth); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); }); it("cancels a live imported Lightning Bolt cast when Silence lands", () => { expect(ANACONDRA_LIGHTNING_BOLT).toMatchObject({ spellId: 9532, school: "nature", delivery: "projectile", source: { entry: 3671, comment: "Lady Anacondra - In Combat - Cast Lightning Bolt" }, }); registerCaster([ANACONDRA_LIGHTNING_BOLT]); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({ attackId: "spell-9532", spellId: 9532, school: "nature", startedAt: 1_000, completesAt: 2_500, }); silenceMob("caster", 9_500); useCombatStore.getState().advanceMobCombat(1_100); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); for (const now of [2_500, 3_500, 7_500, 9_499]) { useCombatStore.getState().advanceMobCombat(now); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); } useCombatStore.getState().advanceMobCombat(9_500); expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({ attackId: "spell-9532", spellId: 9532, startedAt: 9_500, completesAt: 11_000, }); }); it("blocks Anacondra's imported Nature Thorns aura for the full Silence window", () => { expect(ANACONDRA_THORNS).toMatchObject({ spellId: 8148, school: "nature", delivery: "melee", animation: "cast", effect: { kind: "aura", aura: "thorns" }, }); expect(ANACONDRA_THORNS.attackKind).toBeUndefined(); expect(ANACONDRA_THORNS.damageClass).toBeUndefined(); registerCaster([ANACONDRA_THORNS]); silenceMob("caster", 9_500); const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === "caster") { events.push(message.event); } }); for (const now of [1_000, 2_500, 4_000, 7_500, 9_499]) { useCombatStore.getState().advanceMobCombat(now); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); } expect(events).toEqual([]); useCombatStore.getState().advanceMobCombat(9_500); expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({ attackId: "spell-8148", spellId: 8148, school: "nature", startedAt: 9_500, completesAt: 11_000, }); expect(events.map((event) => event.phase)).toEqual(["cast-start"]); useCombatStore.getState().advanceMobCombat(11_000); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); expect(events.map((event) => event.phase)).toEqual(["cast-start", "release", "aura-start"]); unsubscribe(); }); it("keeps physical melee and ranged attacks active under Silence but blocks physical spells", () => { expect(CROCOLISK_TENDON_RIP).toMatchObject({ school: "physical", delivery: "melee" }); const physicalRanged = { ...spell("physical-ranged", "physical", 1_500), attackKind: "ranged" as const, }; const physicalSpell = { ...spell("physical-spell", "physical", 1_500), damageClass: 1, }; const physicalAttackKindSpell = { ...spell("physical-kind-spell", "physical", 1_500), attackKind: "spell" as const, }; registerCaster([CROCOLISK_TENDON_RIP], "physical-melee"); registerCaster([physicalRanged], "physical-ranged"); registerCaster([physicalSpell], "physical-spell"); registerCaster([physicalAttackKindSpell], "physical-kind-spell"); silenceMob("physical-melee", 9_500); silenceMob("physical-ranged", 9_500); silenceMob("physical-spell", 9_500); silenceMob("physical-kind-spell", 9_500); setMobAttackReadyAt("physical-melee", "spell-3604", 0); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs["physical-melee"].activeCast).toMatchObject({ attackId: "spell-3604", school: "physical", }); expect(useCombatStore.getState().mobs["physical-ranged"].activeCast).toMatchObject({ attackId: "physical-ranged", school: "physical", }); expect(useCombatStore.getState().mobs["physical-spell"].activeCast).toBeNull(); expect(useCombatStore.getState().mobs["physical-kind-spell"].activeCast).toBeNull(); }); it("marks an instant source spell release without publishing a synthetic cast event", () => { registerCaster([spell("shadow-bolt", "shadow", 0)]); const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === "caster") { events.push(message.event); } }); expect(useCombatStore.getState().advanceMobCombat(1_000)).toBeGreaterThan(0); expect(events.map((event) => event.phase)).toEqual(["release", "impact"]); expect(events[0]).toMatchObject({ sourceSpellId: 686, instantCast: true, }); unsubscribe(); }); it("interrupts the active cast and locks only that spell school", () => { useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 }); registerCaster([ spell("shadow-bolt", "shadow", 2_000), spell("fireball", "fire", 1_000), ]); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs.caster.activeCast?.school).toBe("shadow"); const kick = useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100); expect(kick).toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs.caster).toMatchObject({ activeCast: null, schoolLockedUntil: { shadow: 6_100 }, }); expect(useCombatStore.getState().advanceMobCombat(1_101)).toBe(0); expect(useCombatStore.getState().mobs.caster.activeCast?.school).toBe("fire"); useCombatStore.getState().advanceMobCombat(2_101); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); useCombatStore.getState().advanceMobCombat(6_099); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); useCombatStore.getState().advanceMobCombat(6_100); expect(useCombatStore.getState().mobs.caster.activeCast?.school).toBe("shadow"); expect(useCombatStore.getState().mobs.caster.schoolLockedUntil).toEqual({}); }); it("grants Improved Kick's capstone only for a successful Kick interrupt", () => { useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 }); registerCaster([spell("shadow-bolt", "shadow", 2_000)]); useCombatStore.getState().advanceMobCombat(1_000); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-206": 2 }, resource: 70, resourcePools: { ...state.resourcePools, energy: 70 }, })); expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); expect(useCombatStore.getState().resourcePools.energy).toBe(85); const bonus = useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") )); expect(bonus).toMatchObject({ appliedAt: 1_100, expiresAt: 7_100 }); expect(bonus?.definition.modifiers).toEqual([ { kind: "stat", stat: "melee-haste", operation: "flat", value: 35 }, { kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 }, ]); useCombatStore.setState((state) => ({ autoAttackTargetId: "caster", nextAutoAttackAt: 1_101, })); useCombatStore.getState().tick(0, 1_101); const buffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 1_101; useCombatStore.setState((state) => ({ nextAutoAttackAt: 7_101 })); useCombatStore.getState().tick(0, 7_101); const unbuffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 7_101; expect(buffedSwingInterval).toBeLessThan(unbuffedSwingInterval); expect(useCombatStore.getState().auras.some((aura) => ( aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") ))).toBe(false); }); it("does not grant Improved Kick's interrupt rider when Kick finds no cast", () => { useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 }); registerCaster([spell("shadow-bolt", "shadow", 2_000)]); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-206": 2 }, resource: 70, resourcePools: { ...state.resourcePools, energy: 70 }, })); expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); expect(useCombatStore.getState().resourcePools.energy).toBe(45); expect(useCombatStore.getState().auras.some((aura) => ( aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") ))).toBe(false); }); it("cancels casts immediately when crowd control lands", () => { useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 4 }); registerCaster([spell("shadow-bolt", "shadow", 2_000)]); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().castAbility("rogue-gouge", [0, 0, 0], 1_100).ok).toBe(true); expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); expect(useCombatStore.getState().mobs.caster.statuses).toContainEqual({ kind: "stun", sourceAbilityId: "rogue-gouge", sourceActorId: PLAYER_AGGRO_ID, endsAt: 5_100, }); }); it("clears pending enemy casts on death and encounter reset", () => { registerCaster([spell("shadow-bolt", "shadow", 2_000)], "doomed"); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs.doomed.activeCast).not.toBeNull(); useCombatStore.getState().damageMob("doomed", 10_000, 1_100); expect(useCombatStore.getState().mobs.doomed).toMatchObject({ dead: true, activeCast: null }); registerCaster([spell("fireball", "fire", 2_000)], "reset-caster"); useCombatStore.getState().advanceMobCombat(2_000); expect(useCombatStore.getState().mobs["reset-caster"].activeCast).not.toBeNull(); useCombatStore.getState().resetEncounter(); expect(useCombatStore.getState().mobs["reset-caster"]).toMatchObject({ activeCast: null, schoolLockedUntil: {}, abilityReadyAt: {}, engaged: false, }); }); it("resolves composite drains as damage, healing, control, and one impact cue", () => { const deathCoil: EnemyAttackDefinition = { ...spell("death-coil", "shadow", 0), effect: { kind: "composite", effects: [ { kind: "leech", damageMultiplier: 1, healMultiplier: 1 }, { kind: "control", mechanic: "fear", durationMs: 4_000 }, ], }, }; registerCaster([deathCoil]); useCombatStore.getState().damageMob("caster", 400, 999); const bossHealth = useCombatStore.getState().mobs.caster.health; const playerHealth = useCombatStore.getState().health; const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === "caster") { events.push(message.event); } }); expect(useCombatStore.getState().advanceMobCombat(1_000)).toBeGreaterThan(0); expect(useCombatStore.getState().health).toBeLessThan(playerHealth); expect(useCombatStore.getState().mobs.caster.health).toBeGreaterThan(bossHealth); expect(useCombatStore.getState()).toMatchObject({ controlMechanic: "fear", controlledUntil: 5_000, }); expect(events.map((event) => event.phase)).toEqual(["release", "impact"]); unsubscribe(); }); it("anchors defensive aura indicators on the caster and applies their gameplay payload", () => { const barrier: EnemyAttackDefinition = { ...spell("shock-barrier", "arcane", 0), delivery: "area", target: "self", range: 0, effect: { kind: "aura", aura: "shock-barrier", durationMs: 10_000, absorbMultiplier: 2.5, reflectSpellChance: 0.5, }, }; registerCaster([barrier]); const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === "caster") { events.push(message.event); } }); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs.caster.auras).toContainEqual(expect.objectContaining({ id: "shock-barrier", endsAt: 11_000, })); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ targetId: "caster", definition: expect.objectContaining({ reflectSpellChance: 0.5, absorbs: [expect.objectContaining({ id: "shock-barrier:absorb" })], }), })); expect(events.map((event) => [event.phase, event.targetActorId])).toEqual([ ["release", "caster"], ["aura-start", "caster"], ]); unsubscribe(); }); it("resolves teleport-to-caster effects as forced movement with an impact cue", () => { const summonPlayer: EnemyAttackDefinition = { ...spell("summon-player", "arcane", 0), effect: { kind: "pull", destination: "source" }, }; registerCaster([summonPlayer]); const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === "caster") { events.push(message.event); } }); useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().playerPosition).toEqual([2, 0, 0]); expect(events.map((event) => event.phase)).toEqual(["release", "impact"]); unsubscribe(); }); });