Files
healer-man/src/game/enemyCasting.test.ts
T
2026-08-14 15:56:39 -04:00

145 lines
5.6 KiB
TypeScript

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,
});
});
});