163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { useCombatStore, type MobCombatState } from "./combatStore";
|
|
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { resetCombatSpatialProvider } from "./combatSpatial";
|
|
|
|
const FLAGGED_ENTRY = 4540; // Pinned template: Scarlet Monk, flags_extra 0x800.
|
|
|
|
function syntheticMainhand(): EnemyAttackDefinition {
|
|
return {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
initialCooldownMs: [60_000, 60_000],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
};
|
|
}
|
|
|
|
function registerFlaggedMob(id: string, overrides: {
|
|
readonly runewakerNpcId?: number;
|
|
} = {}): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: "Pinned flag fixture: Scarlet Monk",
|
|
serverEntry: FLAGGED_ENTRY,
|
|
level: 30,
|
|
maxHealth: 50_000,
|
|
xpReward: 0,
|
|
attacks: [syntheticMainhand()],
|
|
...overrides,
|
|
});
|
|
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);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
});
|
|
|
|
describe("flag-driven AzerothCore creature offhand integration", () => {
|
|
it("registers a pinned flagged creature and schedules an independent offhand on engagement", () => {
|
|
const mobId = "enemy-automatic-offhand-flagged-scarlet-monk";
|
|
registerFlaggedMob(mobId);
|
|
let mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.serverEntry).toBe(FLAGGED_ENTRY);
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
|
|
const engagedAt = 1_000;
|
|
engageAt(mobId, engagedAt);
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
const mainhandReadyAt = mob.abilityReadyAt["basic-attack"];
|
|
const firstOffhandSwingAt = firstOffhandAt(mobId, engagedAt);
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandSwingAt);
|
|
expect(mainhandReadyAt).toBeGreaterThan(firstOffhandSwingAt);
|
|
|
|
// Synthetic cooldowns isolate the hand clocks; no hit-table result is forced.
|
|
useCombatStore.getState().advanceMobCombat(firstOffhandSwingAt);
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandSwingAt + mob.swingMs);
|
|
expect(mob.abilityReadyAt["basic-attack"]).toBe(mainhandReadyAt);
|
|
});
|
|
|
|
it("keeps the template capability through evade, encounter reset, and death, then rearms its timer", () => {
|
|
const mobId = "enemy-automatic-offhand-flagged-lifecycle";
|
|
registerFlaggedMob(mobId);
|
|
|
|
engageAt(mobId, 1_000);
|
|
let mob = useCombatStore.getState().mobs[mobId]!;
|
|
const firstSwingAt = firstOffhandAt(mobId, 1_000);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(firstSwingAt);
|
|
|
|
expect(useCombatStore.getState().resetMobForNavigation(mobId, false, 3_000)).toBe(true);
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
|
|
|
|
const afterEvadeAt = 13_001; // The synthetic ten-second navigation evade has elapsed.
|
|
engageAt(mobId, afterEvadeAt);
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(firstOffhandAt(mobId, afterEvadeAt));
|
|
|
|
useCombatStore.getState().resetEncounter();
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.dead).toBe(false);
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
|
|
|
|
const afterEncounterResetAt = 20_000;
|
|
engageAt(mobId, afterEncounterResetAt);
|
|
expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt)
|
|
.toBe(firstOffhandAt(mobId, afterEncounterResetAt));
|
|
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(useCombatStore.getState().damageMob(mobId, mob.health, 21_000).killed).toBe(true);
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.dead).toBe(true);
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
|
|
|
|
useCombatStore.getState().resetEncounter();
|
|
mob = useCombatStore.getState().mobs[mobId]!;
|
|
expect(mob.dead).toBe(false);
|
|
expect(mob.dualWieldEnabled).toBe(true);
|
|
expect(mob.nextEnemyOffhandAttackAt).toBe(0);
|
|
|
|
const afterDeathResetAt = 30_000;
|
|
engageAt(mobId, afterDeathResetAt);
|
|
expect(useCombatStore.getState().mobs[mobId]?.nextEnemyOffhandAttackAt)
|
|
.toBe(firstOffhandAt(mobId, afterDeathResetAt));
|
|
});
|
|
|
|
it("keeps native RuneWaker, summoned, and character-defender actors outside the flag path", () => {
|
|
const nativeId = "enemy-automatic-offhand-native-exclusion";
|
|
const summonedId = "enemy-automatic-offhand-summon-exclusion";
|
|
const defenderId = "enemy-automatic-offhand-defender-exclusion";
|
|
registerFlaggedMob(nativeId, { runewakerNpcId: 7 });
|
|
registerFlaggedMob(summonedId);
|
|
registerFlaggedMob(defenderId);
|
|
|
|
// These actor tags are applied by their respective spawn paths after the
|
|
// shared registry creates a MobCombatState; setState keeps this fixture on
|
|
// the actual combat-store path without adding test-only registration APIs.
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
[summonedId]: { ...state.mobs[summonedId]!, summonedBy: "owner" },
|
|
[defenderId]: {
|
|
...state.mobs[defenderId]!,
|
|
characterDefender: {} as NonNullable<MobCombatState["characterDefender"]>,
|
|
},
|
|
},
|
|
}));
|
|
|
|
for (const id of [nativeId, summonedId, defenderId]) engageAt(id, 5_000);
|
|
useCombatStore.getState().advanceMobCombat(5_000);
|
|
|
|
for (const id of [nativeId, summonedId, defenderId]) {
|
|
expect(useCombatStore.getState().mobs[id]?.dualWieldEnabled).toBe(false);
|
|
expect(useCombatStore.getState().mobs[id]?.nextEnemyOffhandAttackAt).toBe(0);
|
|
}
|
|
});
|
|
});
|