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

221 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
import {
CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK,
enemyFlagDrivenOffhandContract,
} from "./enemyAutomaticOffhand";
import { creatureTemplateReference } from "./azerothCoreReference";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetCombatSpatialProvider } from "./combatSpatial";
const AUTO_WEAPON_ENTRY = 1696;
const AUTO_WEAPON_GUID = 84027; // Pinned spawn: equipment 1, offhand Item.dbc Class 2.
const EMPTY_OFFHAND_ENTRY = 10447;
const EMPTY_OFFHAND_GUID = 20;
const SHIELD_OFFHAND_ENTRY = 26796;
const SHIELD_OFFHAND_GUID = 4764;
function isolatedMainhand(): EnemyAttackDefinition {
return {
...DEFAULT_ENEMY_ATTACK,
initialCooldownMs: [60_000, 60_000],
repeatCooldownMs: [60_000, 60_000],
};
}
function registerSourceMob(id: string, entry: number, sourceCreatureGuid?: number, runewakerNpcId?: number): void {
useCombatStore.getState().registerMob(id, {
name: `Source equipment fixture ${entry}`,
serverEntry: entry,
...(sourceCreatureGuid === undefined ? {} : { sourceCreatureGuid }),
...(runewakerNpcId === undefined ? {} : { runewakerNpcId }),
level: 30,
maxHealth: 50_000,
xpReward: 0,
attacks: [isolatedMainhand()],
});
setMobPosition(id, [2, 0, 0]);
}
function engageAt(id: string, now: number): void {
expect(useCombatStore.getState().selectMob(id)).toBe(true);
useCombatStore.getState().damageMob(id, 1, now);
useCombatStore.getState().advanceMobCombat(now);
}
function firstOffhandAt(id: string, engagedAt: number): number {
const mob = useCombatStore.getState().mobs[id]!;
return engagedAt + Math.round(mob.swingMs * 0.5);
}
function pinnedFlagsExtra(entry: number): number {
const flagsExtra = creatureTemplateReference(entry)?.flagsExtra;
if (flagsExtra === undefined) throw new Error(`Missing pinned flags_extra for creature ${entry}`);
return flagsExtra;
}
function offhandHits(id: string) {
return useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === id
&& event.kind === "damage"
&& event.abilityId?.startsWith("offhand:")
));
}
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetCombatSpatialProvider();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
});
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetCombatSpatialProvider();
});
describe("pinned spawn-equipment AUTO offhand integration", () => {
it("enables an ordinary source offhand swing from a validated weapon without the template flag", () => {
const mobId = "enemy-equipment-auto-targorr";
expect(pinnedFlagsExtra(AUTO_WEAPON_ENTRY) & CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK).toBe(0);
expect(enemyFlagDrivenOffhandContract(AUTO_WEAPON_ENTRY)).toBeUndefined();
registerSourceMob(mobId, AUTO_WEAPON_ENTRY, AUTO_WEAPON_GUID);
let mob = useCombatStore.getState().mobs[mobId]!;
expect(mob).toMatchObject({
serverEntry: AUTO_WEAPON_ENTRY,
sourceCreatureGuid: AUTO_WEAPON_GUID,
sourceEquipment: {
entry: AUTO_WEAPON_ENTRY,
selector: 1,
equipmentId: 1,
virtualItemIds: [5305, 5305, 0],
offhandItemId: 5305,
offhandItemClass: 2,
autoOffhand: {
source: "auto-offhand-weapon",
dualWieldMode: "auto",
requiresEquippedWeapon: true,
},
},
dualWieldEnabled: true,
});
const engagedAt = 1_000;
engageAt(mobId, engagedAt);
mob = useCombatStore.getState().mobs[mobId]!;
const mainhandReadyAt = mob.abilityReadyAt["basic-attack"];
const firstSwingAt = firstOffhandAt(mobId, engagedAt);
expect(mob.nextEnemyOffhandAttackAt).toBe(firstSwingAt);
expect(mainhandReadyAt).toBeGreaterThan(firstSwingAt);
useCombatStore.getState().advanceMobCombat(firstSwingAt - 1);
expect(offhandHits(mobId)).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(firstSwingAt);
const hits = offhandHits(mobId);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({
attackHand: "offhand",
rawAmount: Math.round(mob.attackDamage * 0.5),
occurredAt: firstSwingAt,
});
const afterSwing = useCombatStore.getState().mobs[mobId]!;
expect(afterSwing.abilityReadyAt["basic-attack"]).toBe(mainhandReadyAt);
expect(afterSwing.nextEnemyOffhandAttackAt).toBe(firstSwingAt + mob.swingMs);
});
it("preserves the source AUTO capability through evade and revive resets, rearming the half-swing timer", () => {
const mobId = "enemy-equipment-auto-lifecycle";
registerSourceMob(mobId, AUTO_WEAPON_ENTRY, AUTO_WEAPON_GUID);
const engagedAt = 1_000;
engageAt(mobId, engagedAt);
expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt)
.toBe(firstOffhandAt(mobId, engagedAt));
expect(useCombatStore.getState().resetMobForNavigation(mobId, false, 3_000)).toBe(true);
let mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.sourceEquipment?.offhandItemClass).toBe(2);
expect(mob.dualWieldEnabled).toBe(true);
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
const afterEvadeAt = 13_001;
engageAt(mobId, afterEvadeAt);
mob = useCombatStore.getState().mobs[mobId]!;
expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandAt(mobId, afterEvadeAt));
mob = useCombatStore.getState().mobs[mobId]!;
expect(useCombatStore.getState().damageMob(mobId, mob.health, 14_000).killed).toBe(true);
expect(useCombatStore.getState().mobs[mobId]?.dead).toBe(true);
useCombatStore.getState().resetEncounter();
mob = useCombatStore.getState().mobs[mobId]!;
expect(mob).toMatchObject({
dead: false,
sourceCreatureGuid: AUTO_WEAPON_GUID,
dualWieldEnabled: true,
nextEnemyOffhandAttackAt: 0,
});
expect(mob.sourceEquipment?.virtualItemIds).toEqual([5305, 5305, 0]);
const afterReviveAt = 20_000;
engageAt(mobId, afterReviveAt);
expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt)
.toBe(firstOffhandAt(mobId, afterReviveAt));
});
it("fails closed for absent or mismatched spawn data, empty and shield offhands, and native registration", () => {
const fixtures: readonly {
readonly id: string;
readonly entry: number;
readonly guid?: number;
readonly runewakerNpcId?: number;
}[] = [
{ id: "enemy-equipment-no-guid", entry: AUTO_WEAPON_ENTRY },
{ id: "enemy-equipment-mismatched-guid", entry: 1711, guid: AUTO_WEAPON_GUID },
{ id: "enemy-equipment-empty-offhand", entry: EMPTY_OFFHAND_ENTRY, guid: EMPTY_OFFHAND_GUID },
{ id: "enemy-equipment-shield-offhand", entry: SHIELD_OFFHAND_ENTRY, guid: SHIELD_OFFHAND_GUID },
{
id: "enemy-equipment-native-exclusion",
entry: AUTO_WEAPON_ENTRY,
guid: AUTO_WEAPON_GUID,
runewakerNpcId: 7,
},
] as const;
for (const fixture of fixtures) {
expect(pinnedFlagsExtra(fixture.entry) & CREATURE_FLAG_EXTRA_USE_OFFHAND_ATTACK).toBe(0);
registerSourceMob(fixture.id, fixture.entry, fixture.guid, fixture.runewakerNpcId);
}
const state = useCombatStore.getState();
for (const id of [
"enemy-equipment-no-guid",
"enemy-equipment-mismatched-guid",
"enemy-equipment-native-exclusion",
]) {
const mob = state.mobs[id]!;
expect(mob.sourceEquipment).toBeUndefined();
expect(mob.dualWieldEnabled).toBe(false);
}
expect(state.mobs["enemy-equipment-empty-offhand"]).toMatchObject({
sourceEquipment: { virtualItemIds: [11323, 0, 0], offhandItemId: 0, offhandItemClass: null },
dualWieldEnabled: false,
});
expect(state.mobs["enemy-equipment-empty-offhand"]?.sourceEquipment?.autoOffhand).toBeUndefined();
expect(state.mobs["enemy-equipment-shield-offhand"]).toMatchObject({
sourceEquipment: {
virtualItemIds: [28951, 28940, 0],
offhandItemId: 28940,
offhandItemClass: 4,
},
dualWieldEnabled: false,
});
expect(state.mobs["enemy-equipment-shield-offhand"]?.sourceEquipment?.autoOffhand).toBeUndefined();
});
});