import { beforeEach, describe, expect, it } from "vitest"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { useCombatStore } from "./combatStore"; import { normalizeEnemyAttack, type EnemyAttackDefinition, type EnemyDamageSchool, } from "./enemyAttacks"; 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); } 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("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("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("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", 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, }); }); });