145 lines
5.6 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|