531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
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<Record<string, readonly EnemyAttackDefinition[]>>;
|
|
};
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|