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