374 lines
16 KiB
TypeScript
374 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { resolveDamageValue } from "./combatAuras";
|
|
import { auraAdjustedCharacterStats, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
|
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { deriveCharacterStats } from "./wotlkStats";
|
|
|
|
const sourceAttack = (dungeonId: string, entry: number, spellId: number): EnemyAttackDefinition => {
|
|
const found = requireDungeonDefinition(dungeonId).entities[`entry-${entry}`]?.combat?.attacks
|
|
.find((attack) => attack.spellId === spellId);
|
|
if (!found) throw new Error(`Missing imported attack ${dungeonId}:${entry}:${spellId}`);
|
|
return found;
|
|
};
|
|
|
|
const sourceCast = (
|
|
attack: EnemyAttackDefinition,
|
|
initialCooldownMs = 0,
|
|
): EnemyAttackDefinition => ({
|
|
...attack,
|
|
initialCooldownMs: [initialCooldownMs, initialCooldownMs],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
maximumCasts: 1,
|
|
});
|
|
|
|
function registerMob(
|
|
id: string,
|
|
serverEntry: number,
|
|
attacks: readonly EnemyAttackDefinition[],
|
|
position: readonly [number, number, number],
|
|
): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
serverEntry,
|
|
level: 30,
|
|
maxHealth: 50_000,
|
|
xpReward: 0,
|
|
attacks,
|
|
});
|
|
setMobPosition(id, position);
|
|
}
|
|
|
|
function engageMob(id: string, now: number): void {
|
|
useCombatStore.getState().selectMob(id);
|
|
useCombatStore.getState().damageMob(id, 1, now);
|
|
}
|
|
|
|
function createPartyPositions(
|
|
positions: readonly (readonly [number, number, number])[],
|
|
downIndex?: number,
|
|
) {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty(
|
|
"enemy-source-aura-integration", 30, useCombatStore.getState().health, "damage", 5, "wow",
|
|
);
|
|
const members = usePartyStore.getState().members.map((member, index) => ({
|
|
...member,
|
|
...(index === downIndex ? { health: 0, status: "down" as const } : {}),
|
|
}));
|
|
usePartyStore.setState({ members });
|
|
members.forEach((member, index) => {
|
|
registerPartyRuntimePosition(member.id, positions[index] ?? [40 + index, 0, 0]);
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
return members;
|
|
}
|
|
|
|
function runSingleImportedCast(id: string, startAt = 1_000): number {
|
|
engageMob(id, startAt);
|
|
const mob = useCombatStore.getState().mobs[id]!;
|
|
const cast = mob.attacks[0]!;
|
|
useCombatStore.getState().advanceMobCombat(startAt);
|
|
const releaseAt = startAt + (cast.castTimeMs ?? 0) + 1;
|
|
useCombatStore.getState().advanceMobCombat(releaseAt);
|
|
return releaseAt;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
});
|
|
|
|
afterEach(() => resetCombatSpatialProvider());
|
|
|
|
describe("imported enemy source auras in combat", () => {
|
|
it("applies Stockade Enrage to physical damage and basic swing phase only", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
|
|
const enrage = sourceCast(sourceAttack("stormwind-stockade", 1696, 8599));
|
|
expect(enrage.effect).toMatchObject({
|
|
kind: "aura",
|
|
wowAuraSource: { spellId: 8599, sourceProfile: "ascension" },
|
|
});
|
|
const unrelatedSpell: EnemyAttackDefinition = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: "unrelated-spell",
|
|
name: "Unrelated Spell",
|
|
delivery: "projectile",
|
|
target: "primary",
|
|
school: "fire",
|
|
attackKind: "spell",
|
|
range: 30,
|
|
cooldownMs: 4_000,
|
|
initialCooldownMs: [10_000, 10_000],
|
|
repeatCooldownMs: [4_000, 4_000],
|
|
};
|
|
const basicAttack: EnemyAttackDefinition = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
cooldownMs: 2_000,
|
|
initialCooldownMs: [10_000, 10_000],
|
|
repeatCooldownMs: [2_000, 2_000],
|
|
};
|
|
const mobId = "stockade-enraged-mob";
|
|
registerMob(mobId, 1696, [basicAttack, unrelatedSpell, enrage], [2, 0, 0]);
|
|
engageMob(mobId, 1_000);
|
|
const mob = useCombatStore.getState().mobs[mobId]!;
|
|
useCombatStore.setState({
|
|
mobs: {
|
|
...useCombatStore.getState().mobs,
|
|
[mobId]: {
|
|
...mob,
|
|
health: 15_000,
|
|
abilityReadyAt: {
|
|
"basic-attack": 3_600,
|
|
"unrelated-spell": 4_000,
|
|
[enrage.id]: 1_100,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_100);
|
|
const sourceCastTime = useCombatStore.getState().mobs[mobId]!.attacks
|
|
.find((attack) => attack.id === enrage.id)!.castTimeMs ?? 0;
|
|
const appliedAt = 1_100 + sourceCastTime;
|
|
useCombatStore.getState().advanceMobCombat(appliedAt);
|
|
let state = useCombatStore.getState();
|
|
const enrageAura = state.auras.find((aura) => (
|
|
aura.definition.id === "enemy-source-spell-8599" && aura.targetId === mobId
|
|
));
|
|
expect(enrageAura).toBeDefined();
|
|
expect(enrageAura?.expiresAt).toBe(appliedAt + 120_000);
|
|
expect(enrageAura?.definition.dispelCategory).toBe("enrage");
|
|
expect(resolveDamageValue(100, [enrageAura!], {
|
|
direction: "dealt", school: "physical", attackKind: "melee",
|
|
})).toBe(130);
|
|
expect(resolveDamageValue(100, [enrageAura!], {
|
|
direction: "dealt", school: "fire", attackKind: "spell",
|
|
})).toBe(100);
|
|
expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBeCloseTo(1 / 1.3);
|
|
expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"])
|
|
.toBe(appliedAt + Math.round((3_600 - appliedAt) / 1.3));
|
|
expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(4_000);
|
|
|
|
const firstHastedSwingAt = state.mobs[mobId]!.abilityReadyAt["basic-attack"];
|
|
useCombatStore.getState().advanceMobCombat(firstHastedSwingAt);
|
|
state = useCombatStore.getState();
|
|
expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"])
|
|
.toBe(firstHastedSwingAt + Math.round(2_000 / 1.3));
|
|
|
|
const navigationResetAt = firstHastedSwingAt + 1;
|
|
expect(useCombatStore.getState().resetMobForNavigation(mobId, true, navigationResetAt)).toBe(true);
|
|
state = useCombatStore.getState();
|
|
expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBe(1);
|
|
expect(state.mobs[mobId]?.abilityReadyAt).toEqual({});
|
|
expect(state.auras.some((aura) => (
|
|
aura.definition.id === "enemy-source-spell-8599"
|
|
&& (aura.sourceId === mobId || aura.targetId === mobId)
|
|
))).toBe(false);
|
|
|
|
const reengageAt = navigationResetAt + 10_001;
|
|
engageMob(mobId, reengageAt);
|
|
const resetMob = useCombatStore.getState().mobs[mobId]!;
|
|
useCombatStore.setState({
|
|
mobs: {
|
|
...useCombatStore.getState().mobs,
|
|
[mobId]: {
|
|
...resetMob,
|
|
health: 15_000,
|
|
abilityReadyAt: {
|
|
"basic-attack": reengageAt + 5_000,
|
|
"unrelated-spell": reengageAt + 6_000,
|
|
[enrage.id]: reengageAt,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
useCombatStore.getState().advanceMobCombat(reengageAt);
|
|
const reengageCastTime = useCombatStore.getState().mobs[mobId]!.attacks
|
|
.find((attack) => attack.id === enrage.id)!.castTimeMs ?? 0;
|
|
const reengageAppliedAt = reengageAt + reengageCastTime;
|
|
useCombatStore.getState().advanceMobCombat(reengageAppliedAt);
|
|
state = useCombatStore.getState();
|
|
expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBeCloseTo(1 / 1.3);
|
|
expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(reengageAt + 6_000);
|
|
|
|
const reengageExpiresAt = reengageAppliedAt + 120_000;
|
|
useCombatStore.setState((current) => {
|
|
const currentMob = current.mobs[mobId]!;
|
|
return {
|
|
mobs: {
|
|
...current.mobs,
|
|
[mobId]: {
|
|
...currentMob,
|
|
abilityReadyAt: {
|
|
...currentMob.abilityReadyAt,
|
|
"basic-attack": reengageExpiresAt + 400,
|
|
"unrelated-spell": reengageExpiresAt + 600,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
useCombatStore.getState().advanceMobCombat(reengageExpiresAt);
|
|
state = useCombatStore.getState();
|
|
expect(state.mobs[mobId]?.meleeAttackSpeedMultiplier).toBe(1);
|
|
expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]).toBe(reengageExpiresAt + 520);
|
|
expect(state.mobs[mobId]?.abilityReadyAt["unrelated-spell"]).toBe(reengageExpiresAt + 600);
|
|
|
|
const epochAttack = sourceCast(sourceAttack("the-nexus", 26723, 8599));
|
|
expect(epochAttack.effect).toMatchObject({
|
|
kind: "aura",
|
|
wowAuraSource: { spellId: 8599, sourceProfile: "epoch" },
|
|
});
|
|
const epochId = "nexus-epoch-enraged-mob";
|
|
registerMob(epochId, 26723, [epochAttack], [2, 0, 0]);
|
|
useCombatStore.setState((current) => ({
|
|
mobs: { ...current.mobs, [epochId]: { ...current.mobs[epochId]!, health: 10_000 } },
|
|
}));
|
|
const epochReleaseAt = runSingleImportedCast(epochId, 200_000);
|
|
const epochAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.id === "enemy-source-spell-8599" && aura.targetId === epochId
|
|
));
|
|
expect(epochAura?.expiresAt).toBe(epochReleaseAt + 120_000);
|
|
expect(resolveDamageValue(100, [epochAura!], {
|
|
direction: "dealt", school: "physical", attackKind: "melee",
|
|
})).toBeCloseTo(110);
|
|
expect(resolveDamageValue(100, [epochAura!], {
|
|
direction: "dealt", school: "fire", attackKind: "spell",
|
|
})).toBe(100);
|
|
});
|
|
|
|
it("applies Stockade Battle Shout to living nearby friendly mobs with LOS and suppresses duplicate casters", () => {
|
|
const members = createPartyPositions([[30, 0, 0], [31, 0, 0], [32, 0, 0], [33, 0, 0]]);
|
|
registerCombatSpatialProvider({
|
|
actor: () => null,
|
|
hasLineOfSight: (_source, target) => target[0] !== 3,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
|
|
const shout = sourceAttack("stormwind-stockade", 1716, 9128);
|
|
const casterA = "stockade-shout-a";
|
|
const casterB = "stockade-shout-b";
|
|
registerMob(casterA, 1716, [sourceCast(shout)], [0, 0, 0]);
|
|
registerMob(casterB, 1716, [sourceCast(shout, 5_000)], [5, 0, 0]);
|
|
registerMob("shout-friend", 1716, [], [10, 0, 0]);
|
|
registerMob("shout-near-friend", 1716, [], [14, 0, 0]);
|
|
registerMob("shout-blocked-friend", 1716, [], [3, 0, 0]);
|
|
registerMob("shout-far-friend", 1716, [], [27, 0, 0]);
|
|
registerMob("shout-dead-friend", 1716, [], [12, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
"shout-dead-friend": { ...state.mobs["shout-dead-friend"]!, health: 0, dead: true },
|
|
},
|
|
}));
|
|
|
|
engageMob(casterA, 1_000);
|
|
engageMob(casterB, 1_000);
|
|
useCombatStore.getState().advanceMobCombat(1_000);
|
|
const castDuration = useCombatStore.getState().mobs[casterA]!.attacks[0]!.castTimeMs ?? 0;
|
|
const firstReleaseAt = 1_000 + castDuration + 1;
|
|
useCombatStore.getState().advanceMobCombat(firstReleaseAt);
|
|
useCombatStore.getState().advanceMobCombat(6_000);
|
|
const secondReleaseAt = 6_000 + castDuration + 1;
|
|
useCombatStore.getState().advanceMobCombat(secondReleaseAt);
|
|
|
|
const activeShouts = useCombatStore.getState().auras.filter((aura) => (
|
|
aura.definition.id === "enemy-source-spell-9128"
|
|
));
|
|
const targetIds = new Set(activeShouts.map((aura) => aura.targetId));
|
|
expect(targetIds).toEqual(new Set([casterA, casterB, "shout-friend", "shout-near-friend"]));
|
|
for (const targetId of targetIds) {
|
|
expect(activeShouts.filter((aura) => aura.targetId === targetId)).toHaveLength(1);
|
|
}
|
|
const friendAura = activeShouts.find((aura) => aura.targetId === "shout-friend")!;
|
|
expect(friendAura.sourceId).toBe(casterA);
|
|
expect(friendAura.appliedAt).toBe(firstReleaseAt);
|
|
expect(friendAura.expiresAt).toBe(firstReleaseAt + 120_000);
|
|
expect(resolveDamageValue(100, [friendAura], {
|
|
direction: "dealt", school: "physical", attackKind: "melee",
|
|
})).toBeCloseTo(115);
|
|
expect(resolveDamageValue(100, [friendAura], {
|
|
direction: "dealt", school: "fire", attackKind: "spell",
|
|
})).toBe(100);
|
|
expect(activeShouts.some((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID || members.some((member) => member.id === aura.targetId)
|
|
))).toBe(false);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
(event.sourceActorId === casterA || event.sourceActorId === casterB) && event.kind === "damage"
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("applies Demoralizing Shout only to living visible actors in the 10-yard area and adjusts AP", () => {
|
|
const members = createPartyPositions([
|
|
[7, 0, 0], [12, 0, 0], [8, 0, 0], [4, 0, 0],
|
|
], 3);
|
|
registerCombatSpatialProvider({
|
|
actor: () => null,
|
|
hasLineOfSight: (_source, target) => target[0] !== 5,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
const caster = "stockade-demo-caster";
|
|
registerMob(caster, 1711, [sourceCast(sourceAttack("stormwind-stockade", 1711, 16244))], [0, 0, 1]);
|
|
const releaseAt = runSingleImportedCast(caster);
|
|
const demoralized = useCombatStore.getState().auras.filter((aura) => (
|
|
aura.definition.id === "enemy-source-spell-16244"
|
|
));
|
|
expect(new Set(demoralized.map((aura) => aura.targetId))).toEqual(new Set([
|
|
PLAYER_AGGRO_ID, members[0]!.id, members[2]!.id,
|
|
]));
|
|
const playerDebuff = demoralized.find((aura) => aura.targetId === PLAYER_AGGRO_ID)!;
|
|
expect(playerDebuff.expiresAt).toBe(releaseAt + 15_000);
|
|
expect(resolveDamageValue(100, [playerDebuff], {
|
|
direction: "dealt", school: "physical", attackKind: "melee",
|
|
})).toBe(90);
|
|
expect(resolveDamageValue(100, [playerDebuff], {
|
|
direction: "dealt", school: "fire", attackKind: "spell",
|
|
})).toBe(100);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.sourceActorId === caster && event.kind === "damage"
|
|
))).toBe(false);
|
|
|
|
const apCaster = "stockade-ap-debuff-caster";
|
|
registerMob(apCaster, 1715, [sourceCast(sourceAttack("stormwind-stockade", 1715, 13730))], [0, 0, 1]);
|
|
const apReleaseAt = runSingleImportedCast(apCaster, 10_000);
|
|
const apAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.id === "enemy-source-spell-13730" && aura.targetId === PLAYER_AGGRO_ID
|
|
));
|
|
expect(apAura?.expiresAt).toBe(apReleaseAt + 30_000);
|
|
const baseStats = deriveCharacterStats("warrior", 30, EMPTY_ITEM_STATS, "human");
|
|
const adjustedStats = auraAdjustedCharacterStats(
|
|
baseStats, useCombatStore.getState().auras, PLAYER_AURA_ENTITY_ID, apReleaseAt,
|
|
);
|
|
expect(adjustedStats.attackPower).toBeCloseTo(baseStats.attackPower * 0.85);
|
|
});
|
|
|
|
it("does not use the home leash as immunity to a sourced hostile area shout", () => {
|
|
const members = createPartyPositions([[10, 0, 0], [50, 0, 0], [60, 0, 0], [70, 0, 0]]);
|
|
useCombatStore.getState().setPlayerPosition([20, 0, 0]);
|
|
const caster = "stockade-demo-leash";
|
|
registerMob(caster, 1711, [sourceCast(sourceAttack("stormwind-stockade", 1711, 16244))], [12, 0, 0]);
|
|
useCombatStore.getState().damageMob(caster, 1, 1_000, {
|
|
actorId: members[0]!.id, role: "damage", abilityId: "shout-leash-fixture",
|
|
});
|
|
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [caster]: {
|
|
...state.mobs[caster]!, homePosition: [0, 0, 0], leashRange: 11,
|
|
} } }));
|
|
useCombatStore.getState().advanceMobCombat(1_000);
|
|
const castTime = useCombatStore.getState().mobs[caster]!.attacks[0]!.castTimeMs ?? 0;
|
|
useCombatStore.getState().advanceMobCombat(1_001 + castTime);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "enemy-source-spell-16244"
|
|
&& aura.targetId === PLAYER_AGGRO_ID)).toBe(true);
|
|
});
|
|
});
|