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, }, }, })); 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); } }); });