Files
healer-man/src/game/enemyExtraAttacks.integration.test.ts
2026-10-06 22:25:34 -04:00

369 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition } from "./enemySourceAuras";
import { useCombatStore, type MobCombatState } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetCombatSpatialProvider } from "./combatSpatial";
const stockade = requireDungeonDefinition("stormwind-stockade");
function importedAttack(entry: number, spellId: number): EnemyAttackDefinition {
const attack = stockade.entities[`entry-${entry}`]?.combat?.attacks
.find((candidate) => candidate.spellId === spellId);
if (!attack) throw new Error(`Missing imported Stockade attack ${entry}:${spellId}`);
return attack;
}
function registerMob(id: string, entry: number, attacks: readonly EnemyAttackDefinition[]): void {
useCombatStore.getState().registerMob(id, {
name: `Imported Stockade entry ${entry}`,
serverEntry: entry,
level: 30,
maxHealth: 50_000,
xpReward: 0,
attacks,
});
setMobPosition(id, [2, 0, 0]);
}
function engageMob(id: string, now: number): void {
useCombatStore.getState().selectMob(id);
useCombatStore.getState().damageMob(id, 1, now);
}
function delayedMainhand(): EnemyAttackDefinition {
return {
...DEFAULT_ENEMY_ATTACK,
initialCooldownMs: [60_000, 60_000],
repeatCooldownMs: [2_000, 2_000],
};
}
function bloodlustAura(casterId: string, appliedAt: number) {
const contract = enemySourceAuraContract(6742, "ascension");
if (!contract) throw new Error("Missing pinned Bloodlust source contract");
const definition = supportedEnemySourceAuraDefinition(contract);
if (!definition) throw new Error("Missing supported Bloodlust source definition");
const aura = applyEnemySourceAura([], definition, casterId, casterId, appliedAt)[0];
if (!aura) throw new Error("Could not create sourced Bloodlust aura");
return aura;
}
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetCombatSpatialProvider();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
});
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetCombatSpatialProvider();
});
describe("imported Stockade Dual Wield and Thrash", () => {
it("preserves the three sourced 674 self casts and Bazil's 29651 capability row", () => {
for (const [entry, eventType] of [[1696, 0], [1711, 1], [1716, 1]] as const) {
const attack = importedAttack(entry, 674);
expect(attack).toMatchObject({
target: "self",
source: { entry, eventType, actionType: 11, targetType: 1 },
effect: { kind: "weapon-capability", capability: "dual-wield", sourceSpellId: 674, sourceProfile: "ascension" },
});
expect(attack.repeatCooldownMs).toEqual([0, 0]);
}
expect(importedAttack(1716, 29651)).toMatchObject({
effect: { kind: "weapon-capability", sourceSpellId: 29651, sourceProfile: "ascension" },
});
});
it("enables Targorr's offhand from the imported in-combat cast and uses a separate half-delay swing clock", () => {
const mobId = "enemy-extra-targorr-dual-wield";
const importedDualWield = importedAttack(1696, 674);
// Synthetic clock knobs make the exact imported SmartAI effect testable in a short fixture.
const testDualWield: EnemyAttackDefinition = {
...importedDualWield,
initialCooldownMs: [0, 0],
repeatCooldownMs: [60_000, 60_000],
maximumCasts: 1,
};
registerMob(mobId, 1696, [delayedMainhand(), testDualWield]);
engageMob(mobId, 1_000);
useCombatStore.getState().advanceMobCombat(1_000);
const normalizedCast = useCombatStore.getState().mobs[mobId]!.attacks
.find((attack) => attack.id === testDualWield.id)!;
const releaseAt = 1_000 + (normalizedCast.castTimeMs ?? 0);
useCombatStore.getState().advanceMobCombat(releaseAt + 1);
let state = useCombatStore.getState();
const mob = state.mobs[mobId]!;
expect(mob.dualWieldEnabled).toBe(true);
const firstOffhandAt = releaseAt + 201;
expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandAt);
expect(mob.abilityReadyAt[testDualWield.id]).toBe(Number.POSITIVE_INFINITY);
useCombatStore.getState().advanceMobCombat(firstOffhandAt - 1);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("offhand:")
))).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(firstOffhandAt);
state = useCombatStore.getState();
const offhandHits = state.combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("offhand:")
));
expect(offhandHits).toHaveLength(1);
expect(offhandHits[0]?.attackHand).toBe("offhand");
expect(offhandHits[0]?.extraAttackSourceSpellId).toBeUndefined();
expect(offhandHits[0]?.rawAmount).toBe(Math.round(mob.attackDamage * 0.5));
expect(state.mobs[mobId]?.abilityReadyAt["basic-attack"]).toBe(61_000);
expect(state.mobs[mobId]?.nextEnemyOffhandAttackAt).toBeGreaterThan(firstOffhandAt);
});
it("starts Bazil's sourced out-of-combat capability with a half-swing initial offhand delay", () => {
const mobId = "enemy-extra-bazil-dual-wield-ooc";
const importedDualWield = importedAttack(1716, 674);
expect(importedDualWield.activation).toBe("out-of-combat");
const testDualWield: EnemyAttackDefinition = {
...importedDualWield,
initialCooldownMs: [0, 0],
repeatCooldownMs: [60_000, 60_000],
maximumCasts: 1,
};
registerMob(mobId, 1716, [delayedMainhand(), testDualWield]);
useCombatStore.getState().advanceMobCombat(5_000);
expect(useCombatStore.getState().mobs[mobId]?.dualWieldEnabled).toBe(true);
expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt).toBe(0);
engageMob(mobId, 6_000);
useCombatStore.getState().advanceMobCombat(6_000);
const mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.nextEnemyOffhandAttackAt).toBe(6_000 + Math.round(mob.swingMs * 0.5));
});
it("retimes only in-flight melee clocks for Bloodlust; Thrash drains next update and spell cooldown stays fixed", () => {
const mobId = "enemy-extra-targorr-haste-clock";
const thrash: EnemyAttackDefinition = {
...importedAttack(1696, 3391),
initialCooldownMs: [60_000, 60_000],
repeatCooldownMs: [60_000, 60_000],
maximumCasts: 1,
};
registerMob(mobId, 1696, [delayedMainhand(), thrash]);
engageMob(mobId, 10_000);
const current = useCombatStore.getState().mobs[mobId]!;
const sourceSpellReadyAt = 17_000;
useCombatStore.setState((state) => ({
auras: [bloodlustAura(mobId, 10_999)],
mobs: { ...state.mobs, [mobId]: {
...current,
dualWieldEnabled: true,
nextEnemyOffhandAttackAt: 12_000,
pendingExtraAttacks: [{
id: `thrash:${mobId}:source-cast`,
sourceSpellId: 3391,
targetActorId: PLAYER_AGGRO_ID,
queuedAt: 11_000,
remaining: 2,
}],
abilityReadyAt: {
...current.abilityReadyAt,
"basic-attack": 14_000,
[thrash.id]: sourceSpellReadyAt,
},
meleeAttackSpeedMultiplier: 1,
} },
}));
useCombatStore.getState().advanceMobCombat(11_000);
let state = useCombatStore.getState();
let mob = state.mobs[mobId]!;
expect(mob.nextEnemyOffhandAttackAt).toBe(11_000 + Math.round(1_000 / 1.3));
expect(mob.pendingExtraAttacks).toHaveLength(1);
expect(mob.pendingExtraAttacks?.[0]?.queuedAt).toBe(11_000);
expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt);
expect(state.combatEvents.filter((event) => event.sourceActorId === mobId
&& event.kind === "damage" && event.abilityId?.startsWith("thrash:") )).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(11_001);
state = useCombatStore.getState();
mob = state.mobs[mobId]!;
const queuedHits = state.combatEvents.filter((event) => event.sourceActorId === mobId
&& event.kind === "damage" && event.abilityId?.startsWith("thrash:"));
expect(queuedHits).toHaveLength(2);
expect(queuedHits.every((event) => event.attackHand === "extra-mainhand"
&& event.extraAttackSourceSpellId === 3391)).toBe(true);
expect(queuedHits.every((event) => event.occurredAt === 11_001)).toBe(true);
expect(mob.pendingExtraAttacks).toEqual([]);
expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt);
useCombatStore.setState({ auras: [] });
useCombatStore.getState().advanceMobCombat(11_100);
state = useCombatStore.getState();
mob = state.mobs[mobId]!;
expect(mob.nextEnemyOffhandAttackAt).toBe(11_100
+ Math.round((11_000 + Math.round(1_000 / 1.3) - 11_100) * 1.3));
expect(mob.abilityReadyAt[thrash.id]).toBe(sourceSpellReadyAt);
});
it("drops queued Thrash outside melee range and clears weapon state for dead targets", () => {
const rangedMobId = "enemy-extra-thrash-out-of-range";
registerMob(rangedMobId, 1696, [delayedMainhand()]);
engageMob(rangedMobId, 20_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [rangedMobId]: {
...state.mobs[rangedMobId]!,
dualWieldEnabled: true,
nextEnemyOffhandAttackAt: 25_000,
pendingExtraAttacks: [{
id: `thrash:${rangedMobId}:range`, sourceSpellId: 3391,
targetActorId: PLAYER_AGGRO_ID, queuedAt: 20_000, remaining: 2,
}],
} },
}));
useCombatStore.getState().setPlayerPosition([20, 0, 0]);
useCombatStore.getState().advanceMobCombat(20_001);
expect(useCombatStore.getState().mobs[rangedMobId]?.pendingExtraAttacks).toEqual([]);
expect(useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === rangedMobId
&& event.kind === "damage" && event.abilityId?.startsWith("thrash:"))).toHaveLength(0);
const deadTargetMobId = "enemy-extra-thrash-dead-target";
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
registerMob(deadTargetMobId, 1696, [delayedMainhand()]);
engageMob(deadTargetMobId, 21_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [deadTargetMobId]: {
...state.mobs[deadTargetMobId]!,
dualWieldEnabled: true,
nextEnemyOffhandAttackAt: 30_000,
pendingExtraAttacks: [{
id: `thrash:${deadTargetMobId}:dead`, sourceSpellId: 3391,
targetActorId: PLAYER_AGGRO_ID, queuedAt: 21_000, remaining: 2,
}],
lastDamagedActorId: PLAYER_AGGRO_ID,
} },
health: 0,
}));
useCombatStore.getState().advanceMobCombat(21_001);
const deadState = useCombatStore.getState().mobs[deadTargetMobId]!;
expect(deadState.pendingExtraAttacks).toEqual([]);
expect(deadState.dualWieldEnabled).toBe(false);
expect(deadState.nextEnemyOffhandAttackAt).toBe(0);
expect(deadState.lastDamagedActorId).toBeNull();
});
it("clears queued attacks and offhand timers on navigation/leash and full encounter resets", () => {
const mobId = "enemy-extra-thrash-navigation-reset";
registerMob(mobId, 1696, [delayedMainhand()]);
engageMob(mobId, 30_000);
const prime = (id: string) => useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [id]: {
...state.mobs[id]!,
dualWieldEnabled: true,
nextEnemyOffhandAttackAt: 45_000,
pendingExtraAttacks: [{
id: `thrash:${id}:reset`, sourceSpellId: 3391,
targetActorId: PLAYER_AGGRO_ID, queuedAt: 30_000, remaining: 2,
}],
lastDamagedActorId: PLAYER_AGGRO_ID,
meleeAttackSpeedMultiplier: 1 / 1.3,
} },
}));
prime(mobId);
expect(useCombatStore.getState().resetMobForNavigation(mobId, false, 31_000)).toBe(true);
let mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.pendingExtraAttacks).toEqual([]);
expect(mob.dualWieldEnabled).toBe(false);
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
expect(mob.lastDamagedActorId).toBeNull();
expect(mob.meleeAttackSpeedMultiplier).toBe(1);
prime(mobId);
useCombatStore.getState().resetEncounter();
mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.pendingExtraAttacks).toEqual([]);
expect(mob.dualWieldEnabled).toBe(false);
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
expect(mob.lastDamagedActorId).toBeNull();
expect(mob.meleeAttackSpeedMultiplier).toBe(1);
});
it("does not grant source Dual Wield capability to RuneWaker, summoned, or character-defender mobs", () => {
const importedDualWield = importedAttack(1716, 674);
const testDualWield: EnemyAttackDefinition = {
...importedDualWield,
initialCooldownMs: [0, 0],
repeatCooldownMs: [60_000, 60_000],
maximumCasts: 1,
};
const fixtures = [
["enemy-extra-rw-exclusion", { runewakerNpcId: 7 }],
["enemy-extra-summon-exclusion", { summonedBy: "summoner" }],
["enemy-extra-defender-exclusion", { characterDefender: {} as MobCombatState["characterDefender"] }],
] as const;
for (const [id] of fixtures) registerMob(id, 1716, [testDualWield]);
useCombatStore.setState((state) => {
const mobs = { ...state.mobs };
for (const [id, excludedState] of fixtures) {
mobs[id] = { ...mobs[id]!, ...excludedState, dualWieldEnabled: true,
pendingExtraAttacks: [], nextEnemyOffhandAttackAt: 0 };
}
return { mobs };
});
useCombatStore.getState().advanceMobCombat(40_000);
for (const [id] of fixtures) expect(useCombatStore.getState().mobs[id]?.dualWieldEnabled).toBe(false);
});
it("queues Targorr's imported Thrash for two independent next-update mainhand packets", () => {
const mobId = "enemy-extra-targorr-thrash";
const importedThrash = importedAttack(1696, 3391);
expect(importedThrash).toMatchObject({
id: "spell-3391",
effect: { kind: "extra-attacks", sourceSpellId: 3391, sourceProfile: "ascension", count: 2 },
});
// The source has no Stockade SmartAI cast row for Thrash; these are synthetic test-only schedule values.
const syntheticThrashClock: EnemyAttackDefinition = {
...importedThrash,
initialCooldownMs: [0, 0],
repeatCooldownMs: [60_000, 60_000],
maximumCasts: 1,
};
registerMob(mobId, 1696, [delayedMainhand(), syntheticThrashClock]);
engageMob(mobId, 2_000);
useCombatStore.getState().advanceMobCombat(2_000);
const normalizedThrash = useCombatStore.getState().mobs[mobId]!.attacks
.find((attack) => attack.id === syntheticThrashClock.id)!;
const releaseAt = 2_000 + (normalizedThrash.castTimeMs ?? 0);
useCombatStore.getState().advanceMobCombat(releaseAt + 1);
let mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.pendingExtraAttacks).toEqual([{
id: `thrash:${mobId}:spell-3391:0:${releaseAt + 1}`,
sourceSpellId: 3391,
targetActorId: PLAYER_AGGRO_ID,
queuedAt: releaseAt + 1,
remaining: 2,
}]);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage"
))).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(releaseAt + 2);
mob = useCombatStore.getState().mobs[mobId]!;
const swings = useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage" && event.abilityId?.startsWith("thrash:")
));
expect(swings).toHaveLength(2);
expect(new Set(swings.map((event) => event.abilityId)).size).toBe(2);
expect(swings.every((event) => event.targetActorId === PLAYER_AGGRO_ID)).toBe(true);
expect(mob.pendingExtraAttacks).toEqual([]);
expect(mob.abilityCastCounts[syntheticThrashClock.id]).toBe(1);
});
});