451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import {
|
|
deterministicEnemyCooldown,
|
|
useCombatStore,
|
|
} from "./combatStore";
|
|
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
|
|
import {
|
|
clearMobRuntimeRegistry,
|
|
getMobPosition,
|
|
setMobPosition,
|
|
} from "./mobRuntimeRegistry";
|
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
|
|
|
describe("server-backed enemy ability runtime", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
useWailingEncounterStore.getState().reset();
|
|
});
|
|
|
|
it("selects seeded cooldowns reproducibly inside the exact SmartAI range", () => {
|
|
const first = Array.from({ length: 12 }, (_, activation) => (
|
|
deterministicEnemyCooldown("cobrahn", "spell-8040", activation, [10_000, 20_000])
|
|
));
|
|
const second = Array.from({ length: 12 }, (_, activation) => (
|
|
deterministicEnemyCooldown("cobrahn", "spell-8040", activation, [10_000, 20_000])
|
|
));
|
|
expect(second).toEqual(first);
|
|
expect(first.every((cooldown) => cooldown >= 10_000 && cooldown <= 20_000)).toBe(true);
|
|
expect(new Set(first).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("honors imported fear immunities while leaving Kresh controllable", () => {
|
|
useCombatStore.getState().registerMob("kresh", {
|
|
name: "Kresh",
|
|
serverEntry: 3653,
|
|
boss: true,
|
|
maxHealth: 100,
|
|
});
|
|
useCombatStore.getState().registerMob("vancleef", {
|
|
name: "Edwin VanCleef",
|
|
serverEntry: 639,
|
|
boss: true,
|
|
maxHealth: 100,
|
|
});
|
|
setMobPosition("kresh", [3, 0, 0]);
|
|
setMobPosition("vancleef", [5, 0, 0]);
|
|
|
|
expect(useCombatStore.getState().castAbility(
|
|
"priest-psychic-scream",
|
|
[0, 0, 0],
|
|
1_000,
|
|
).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs.kresh.statuses).toContainEqual({
|
|
kind: "fear",
|
|
sourceAbilityId: "priest-psychic-scream",
|
|
endsAt: 9_000,
|
|
});
|
|
expect(useCombatStore.getState().mobs.vancleef.statuses).toEqual([]);
|
|
});
|
|
|
|
it("heals the lowest-health eligible friendly in range only after the SQL threshold", () => {
|
|
const combat = WAILING_CAVERNS_DEFINITION.entities["lord-cobrahn"].combat!;
|
|
useCombatStore.getState().registerMob("cobrahn", {
|
|
name: "Lord Cobrahn",
|
|
serverEntry: combat.serverEntry,
|
|
boss: true,
|
|
maxHealth: 1_000,
|
|
attacks: combat.attacks,
|
|
});
|
|
useCombatStore.getState().registerMob("eligible", {
|
|
name: "Eligible Ally",
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
});
|
|
useCombatStore.getState().registerMob("lower-but-distant", {
|
|
name: "Distant Ally",
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("cobrahn", [2, 0, 0]);
|
|
setMobPosition("eligible", [8, 0, 0]);
|
|
setMobPosition("lower-but-distant", [60, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
eligible: { ...state.mobs.eligible, health: 500 },
|
|
"lower-but-distant": { ...state.mobs["lower-but-distant"], health: 100 },
|
|
},
|
|
}));
|
|
useCombatStore.getState().damageMob("cobrahn", 1, 1_000);
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs.eligible.health).toBeGreaterThan(500);
|
|
expect(useCombatStore.getState().mobs["lower-but-distant"].health).toBe(100);
|
|
});
|
|
|
|
it("casts a non-repeatable health-triggered form exactly once", () => {
|
|
const combat = WAILING_CAVERNS_DEFINITION.entities["lord-cobrahn"].combat!;
|
|
useCombatStore.getState().registerMob("cobrahn-form", {
|
|
name: "Lord Cobrahn",
|
|
serverEntry: combat.serverEntry,
|
|
boss: true,
|
|
maxHealth: 1_000,
|
|
attacks: combat.attacks.filter((attack) => attack.id === "spell-7965"),
|
|
});
|
|
setMobPosition("cobrahn-form", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("cobrahn-form", 750, 1_000);
|
|
|
|
for (const now of [1_000, 1_000, 2_000, 20_000, 40_000, 80_000]) {
|
|
useCombatStore.getState().advanceMobCombat(now, [0, 0, 0]);
|
|
}
|
|
const cobrahn = useCombatStore.getState().mobs["cobrahn-form"];
|
|
expect(cobrahn.auras).toContainEqual({
|
|
id: "form:serpent",
|
|
sourceAbilityId: "spell-7965",
|
|
endsAt: 0,
|
|
});
|
|
expect(cobrahn.abilityCastCounts["spell-7965"]).toBe(1);
|
|
expect(cobrahn.abilityReadyAt["spell-7965"]).toBe(Number.POSITIVE_INFINITY);
|
|
});
|
|
|
|
it("executes data-driven summon effects and removes summons on encounter reset", () => {
|
|
useCombatStore.getState().registerMob("summoner", {
|
|
name: "Summoner",
|
|
maxHealth: 1_000,
|
|
attacks: [{
|
|
id: "summon-vipers",
|
|
name: "Summon Vipers",
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "nature",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 60_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
maximumCasts: 1,
|
|
damageMultiplier: 0,
|
|
effect: { kind: "summon", creatureEntry: 5048, count: 2 },
|
|
}],
|
|
});
|
|
setMobPosition("summoner", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("summoner", 1, 1_000);
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
|
|
|
const summonIds = Object.keys(useCombatStore.getState().mobs)
|
|
.filter((id) => id.startsWith("summoner:summon-vipers:summon:1:"));
|
|
expect(summonIds).toHaveLength(2);
|
|
for (const id of summonIds) {
|
|
expect(useCombatStore.getState().mobs[id]).toMatchObject({
|
|
serverEntry: 5048,
|
|
summonedBy: "summoner",
|
|
engaged: true,
|
|
xpReward: 0,
|
|
});
|
|
expect(getMobPosition(id)).not.toBeNull();
|
|
}
|
|
|
|
useCombatStore.getState().resetEncounter();
|
|
expect(Object.keys(useCombatStore.getState().mobs)).toEqual(["summoner"]);
|
|
expect(summonIds.every((id) => getMobPosition(id) === null)).toBe(true);
|
|
});
|
|
|
|
it("uses content-authored summon namespaces and spawn-group ownership", () => {
|
|
useCombatStore.getState().registerMob("wave-controller", {
|
|
name: "Wave Controller",
|
|
maxHealth: 1_000,
|
|
attacks: [{
|
|
id: "release-wave",
|
|
name: "Release Wave",
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
range: 0,
|
|
cooldownMs: 60_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
maximumCasts: 1,
|
|
damageMultiplier: 0,
|
|
effect: {
|
|
kind: "summon",
|
|
creatureEntry: 5048,
|
|
count: 2,
|
|
entityId: "forsaken-abbey:necromage:add",
|
|
spawnGroupId: "forsaken-abbey:necromage:wave-one",
|
|
},
|
|
}],
|
|
});
|
|
setMobPosition("wave-controller", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("wave-controller", 1, 1_000);
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
|
|
|
const summonIds = Object.keys(useCombatStore.getState().mobs)
|
|
.filter((id) => id.startsWith("forsaken-abbey:necromage:add:"));
|
|
expect(summonIds).toEqual([
|
|
"forsaken-abbey:necromage:add:1:1",
|
|
"forsaken-abbey:necromage:add:1:2",
|
|
]);
|
|
expect(summonIds.every((id) => (
|
|
useCombatStore.getState().mobs[id].spawnGroupId === "forsaken-abbey:necromage:wave-one"
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("applies a modular target aura as a dispellable player debuff", () => {
|
|
useCombatStore.getState().registerMob("runewaker-debuffer", {
|
|
name: "RuneWaker Debuffer",
|
|
maxHealth: 1_000,
|
|
attacks: [{
|
|
id: "conductivity",
|
|
name: "Conductivity",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "arcane",
|
|
animation: "cast",
|
|
castTimeMs: 1_000,
|
|
range: 20,
|
|
cooldownMs: 10_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [10_000, 10_000],
|
|
damageMultiplier: 0,
|
|
effect: { kind: "target-aura", aura: "conductivity", durationMs: 6_000 },
|
|
}],
|
|
});
|
|
setMobPosition("runewaker-debuffer", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("runewaker-debuffer", 1, 1_000);
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
|
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
sourceId: "runewaker-debuffer",
|
|
targetId: PLAYER_AGGRO_ID,
|
|
expiresAt: 8_000,
|
|
definition: expect.objectContaining({
|
|
disposition: "debuff",
|
|
dispelCategory: "magic",
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("resolves compound enemy damage and control against the same target", () => {
|
|
useCombatStore.getState().registerMob("compound-hammer", {
|
|
name: "Compound Hammer",
|
|
maxHealth: 1_000,
|
|
attacks: [{
|
|
id: "hammer-pair",
|
|
name: "Hammer Pair",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "holy",
|
|
animation: "cast",
|
|
castTimeMs: 0,
|
|
range: 20,
|
|
cooldownMs: 25_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [25_000, 25_000],
|
|
damageMultiplier: 1.3,
|
|
effect: {
|
|
kind: "control",
|
|
mechanic: "stun",
|
|
durationMs: 2_500,
|
|
damageMultiplier: 1.3,
|
|
},
|
|
}],
|
|
});
|
|
setMobPosition("compound-hammer", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("compound-hammer", 1, 1_000);
|
|
const healthBefore = useCombatStore.getState().health;
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
|
|
expect(useCombatStore.getState().health).toBeLessThan(healthBefore);
|
|
expect(useCombatStore.getState()).toMatchObject({
|
|
controlMechanic: "stun",
|
|
controlledUntil: 3_500,
|
|
});
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
sourceId: "compound-hammer",
|
|
targetId: PLAYER_AGGRO_ID,
|
|
expiresAt: 3_500,
|
|
}));
|
|
});
|
|
|
|
it("arms, limits, and consumes a next-melee critical proc only from main-hand autos", () => {
|
|
useCombatStore.getState().registerMob("melee-proc-boss-1", {
|
|
name: "Melee Proc Boss",
|
|
level: 20,
|
|
maxHealth: 1_000,
|
|
damageMultiplier: 0.05,
|
|
attacks: [
|
|
{
|
|
id: "vengeance-driver",
|
|
name: "Vengeance",
|
|
delivery: "area",
|
|
target: "self",
|
|
school: "holy",
|
|
animation: "cast",
|
|
castTimeMs: 0,
|
|
range: 0,
|
|
cooldownMs: 1,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [1, 1],
|
|
maximumCasts: 1,
|
|
damageMultiplier: 0,
|
|
effect: {
|
|
kind: "aura",
|
|
aura: "vengeance",
|
|
durationMs: 0,
|
|
meleeAutoCriticalProc: {
|
|
chance: 1,
|
|
triggeredAura: "vengeance-next-melee-critical",
|
|
durationMs: 6_000,
|
|
criticalChancePercent: 100,
|
|
charges: 1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "melee-special",
|
|
name: "Melee Special",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
castTimeMs: 0,
|
|
range: 5,
|
|
cooldownMs: 1_000,
|
|
initialCooldownMs: [500, 500],
|
|
repeatCooldownMs: [1_000, 1_000],
|
|
damageMultiplier: 1,
|
|
},
|
|
{
|
|
id: "basic-attack",
|
|
name: "Basic Attack",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
castTimeMs: 0,
|
|
range: 4.5,
|
|
cooldownMs: 2_000,
|
|
initialCooldownMs: [1_000, 1_000],
|
|
repeatCooldownMs: [2_000, 2_000],
|
|
damageMultiplier: 1,
|
|
},
|
|
],
|
|
});
|
|
setMobPosition("melee-proc-boss-1", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("melee-proc-boss-1", 1, 1_000);
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(1_500, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.map((aura) => aura.id))
|
|
.toEqual(["vengeance"]);
|
|
|
|
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
|
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
|
|
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(5);
|
|
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual({
|
|
id: "vengeance-next-melee-critical",
|
|
sourceAbilityId: "vengeance-driver",
|
|
endsAt: 8_000,
|
|
nextMeleeCriticalChancePercent: 100,
|
|
charges: 1,
|
|
});
|
|
|
|
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
|
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 8_000, charges: 1 }),
|
|
);
|
|
|
|
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
outcome: "crit",
|
|
critical: true,
|
|
landed: true,
|
|
});
|
|
expect(useCombatStore.getState().lastCombatResult?.damageBeforeMitigation)
|
|
.toBe((useCombatStore.getState().lastCombatResult?.rawDamage ?? 0) * 2);
|
|
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
|
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 10_000, charges: 1 }),
|
|
);
|
|
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
"melee-proc-boss-1": {
|
|
...state.mobs["melee-proc-boss-1"],
|
|
auras: state.mobs["melee-proc-boss-1"].auras.map((aura) => (
|
|
aura.id === "vengeance" ? { ...aura, meleeAutoCriticalProc: undefined } : aura
|
|
)),
|
|
},
|
|
},
|
|
}));
|
|
useCombatStore.getState().advanceMobCombat(5_000, [0, 0, 0]);
|
|
useCombatStore.getState().advanceMobCombat(6_000, [0, 0, 0]);
|
|
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
|
|
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.some(
|
|
(aura) => aura.id === "vengeance-next-melee-critical",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("honors encounter-state conditions before selecting an ability", () => {
|
|
useWailingEncounterStore.getState().reset();
|
|
useCombatStore.getState().registerMob("event-caster", {
|
|
name: "Event Caster",
|
|
maxHealth: 1_000,
|
|
attacks: [{
|
|
id: "event-strike",
|
|
name: "Event Strike",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "shadow",
|
|
animation: "cast",
|
|
range: 30,
|
|
cooldownMs: 10_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [10_000, 10_000],
|
|
damageMultiplier: 1,
|
|
effect: { kind: "damage", multiplier: 1 },
|
|
condition: {
|
|
kind: "encounter-state",
|
|
encounterId: "wailing-caverns:naralex",
|
|
states: ["mutanus"],
|
|
},
|
|
}],
|
|
});
|
|
setMobPosition("event-caster", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("event-caster", 1, 1_000);
|
|
const initialHealth = useCombatStore.getState().health;
|
|
expect(useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0])).toBe(0);
|
|
|
|
useWailingEncounterStore.setState({ phase: "mutanus" });
|
|
expect(useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0])).toBe(0);
|
|
expect(useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0])).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().health).toBeLessThan(initialHealth);
|
|
});
|
|
});
|