forked from phenom/healer-man
369 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|