221 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|