fix: allow explicit WoW Warrior off-hand swings

This commit is contained in:
warren authored and Codex (Root combat coordinator) committed 2026-10-15 09:26:01 -04:00
1 parent 9be6f54c52
commit 2f8cb8a510
2 files changed
+141 -6

No files matched your search

+32 -2
View File
@@ -462,6 +462,7 @@ import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
classCanEquipItem,
equipInventoryItem,
normalizeEquipment,
unequipInventorySlot,
@@ -2416,6 +2417,33 @@ function playerEquippedMeleeWeapon(
return item?.weaponType ? item.stats.meleeWeapon : null;
}
function playerHasExplicitWoWWarriorDualWield(
state: Pick<CombatState, "classId" | "level" | "statRuleset" | "equipment" | "inventory">,
): boolean {
if (state.classId !== "warrior" || state.level < 20
|| normalizeCharacterStatRuleset(state.statRuleset, state.classId) !== "wow") return false;
const itemForSlot = (slot: "main-hand" | "off-hand"): InventoryItem | null => {
const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot];
return assignedId ? state.inventory.find((item) => item.instanceId === assignedId) ?? null : null;
};
const mainHand = itemForSlot("main-hand");
const offHand = itemForSlot("off-hand");
const isClassLegalMeleeWeapon = (item: InventoryItem | null, allowedSlots: readonly string[]): boolean => Boolean(
item
&& allowedSlots.includes(item.inventorySlot)
&& item.armorType !== "shield"
&& item.weaponType
&& item.stats.meleeWeapon
&& classCanEquipItem(state.classId, item, { level: state.level })
);
// WoW Warrior spell 674 is learned at level 20. Only actual, legal one-hand
// assignments count; generated heirloom stats never grant a second swing.
return isClassLegalMeleeWeapon(mainHand, ["one-hand", "main-hand"])
&& isClassLegalMeleeWeapon(offHand, ["one-hand", "off-hand"]);
}
function playerEquippedRangedWeapon(
state: Pick<CombatState,
"classId" | "secondaryClassId" | "specializationId" | "gearRole" | "level" | "equipment" | "inventory"
@@ -24448,13 +24476,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom"
? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats,
state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined;
const explicitWarriorDualWield = playerHasExplicitWoWWarriorDualWield(state);
const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue"
|| state.classId === "shaman" || state.classId === "death-knight"
|| state.classId === "shaman" || state.classId === "death-knight" || explicitWarriorDualWield
? playerEquippedMeleeWeapon(state, "off-hand")
: null;
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue"
|| state.classId === "shaman"
|| (state.classId === "death-knight" && Boolean(offHandWeapon));
|| (state.classId === "death-knight" && Boolean(offHandWeapon))
|| explicitWarriorDualWield;
const mainHandWeapon = supportsPlayerOffhandSwings
? playerEquippedMeleeWeapon(state, "main-hand")
: null;
@@ -6,7 +6,9 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { EMPTY_ITEM_STATS } from "./itemStats";
import type { InventoryItem } from "./lootTypes";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import {
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
@@ -19,16 +21,21 @@ let publishedCombatEvents: StructuredCombatEvent[] = [];
let unsubscribePresentation = () => {};
let unsubscribeCombatEvents = () => {};
function weapon(instanceId: string, inventorySlot: "one-hand" | "off-hand") {
function weapon(
instanceId: string,
inventorySlot: InventoryItem["inventorySlot"],
overrides: Partial<Pick<InventoryItem, "inventorySlot" | "armorType" | "weaponType">> = {},
) {
return {
contentCategory: "wow" as const,
instanceId,
itemId: 1,
itemKey: instanceId,
name: instanceId,
quality: "uncommon" as const,
inventorySlot,
armorType: null,
weaponType: "dagger",
inventorySlot: overrides.inventorySlot ?? inventorySlot,
armorType: overrides.armorType ?? null,
weaponType: overrides.weaponType === undefined ? "dagger" : overrides.weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats: {
@@ -43,6 +50,50 @@ function weapon(instanceId: string, inventorySlot: "one-hand" | "off-hand") {
};
}
function warriorAutoAttackEvents(options: {
level?: number;
mainHand?: InventoryItem;
offHand?: InventoryItem;
assignMainHand?: boolean;
assignOffHand?: boolean;
virtualDualWieldHeirloom?: boolean;
} = {}) {
const mainHand = options.mainHand ?? weapon("warrior-main-hand", "one-hand", { weaponType: "dagger" });
const offHand = options.offHand ?? weapon("warrior-off-hand", "off-hand", { weaponType: "dagger" });
const assignments = {
...(options.assignMainHand === false ? {} : { "main-hand": mainHand.instanceId }),
...(options.assignOffHand === false ? {} : { "off-hand": offHand.instanceId }),
};
useCombatStore.getState().initializeCharacter({
classId: "warrior",
level: options.level ?? 20,
statRuleset: "wow",
specializationId: options.virtualDualWieldHeirloom ? "warrior:fury" : undefined,
inventory: [mainHand, offHand],
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: assignments },
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
useCombatStore.getState().registerMob(TARGET_ID, {
name: "Player auto event lineage target",
level: 80,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(TARGET_ID, [2, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
useCombatStore.setState({
autoAttackTargetId: TARGET_ID,
nextAutoAttackAt: 1_000,
nextOffhandAutoAttackAt: 1_000,
});
useCombatStore.getState().tick(0, 1_000);
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
&& (event.attackHand === "mainhand" || event.attackHand === "offhand")
));
}
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
@@ -69,6 +120,60 @@ afterEach(() => {
});
describe("player auto-attack event lineage", () => {
it("enables WoW Warrior off-hand swings only with two assigned legal one-hand weapons at level 20", () => {
const validWarriorSwings = warriorAutoAttackEvents();
expect(validWarriorSwings).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
]));
expect(validWarriorSwings).toHaveLength(2);
const belowTrainingLevel = warriorAutoAttackEvents({ level: 19 });
expect(belowTrainingLevel.some((event) => event.attackHand === "offhand")).toBe(false);
const noMainHandAssignment = warriorAutoAttackEvents({ assignMainHand: false });
expect(noMainHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
const noOffHandAssignment = warriorAutoAttackEvents({ assignOffHand: false });
expect(noOffHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
const noExplicitLoadout = warriorAutoAttackEvents({ assignMainHand: false, assignOffHand: false });
expect(noExplicitLoadout.some((event) => event.attackHand === "offhand")).toBe(false);
const virtualDualWieldHeirloom = warriorAutoAttackEvents({
assignMainHand: false,
assignOffHand: false,
virtualDualWieldHeirloom: true,
});
const state = useCombatStore.getState();
const generatedLoadout = resolvedEquipmentLoadout(state.equipment, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
});
expect(state.specializationId).toBe("warrior:fury");
expect(generatedLoadout["off-hand"]).toMatchObject({ inventorySlot: "off-hand", weaponType: "dagger" });
expect(virtualDualWieldHeirloom.some((event) => event.attackHand === "offhand")).toBe(false);
});
it("rejects shields, two-hand mains, and class-illegal off-hand weapons", () => {
const shield = weapon("warrior-offhand-shield", "off-hand", { armorType: "shield", weaponType: "dagger" });
const shieldedSwings = warriorAutoAttackEvents({ offHand: shield });
expect(shieldedSwings.some((event) => event.attackHand === "offhand")).toBe(false);
const twoHandMain = weapon("warrior-two-hand-main", "two-hand", { weaponType: "dagger" });
const twoHandSwings = warriorAutoAttackEvents({ mainHand: twoHandMain });
expect(twoHandSwings.some((event) => event.attackHand === "offhand")).toBe(false);
const illegalOffHand = weapon("warrior-illegal-offhand", "off-hand", { weaponType: "wand" });
const illegalSwings = warriorAutoAttackEvents({ offHand: illegalOffHand });
expect(illegalSwings.some((event) => event.attackHand === "offhand")).toBe(false);
});
it("joins both hand events to their releases without relabeling the triggering melee ability", () => {
const mainHand = weapon("event-lineage-main-hand", "one-hand");
const offHand = weapon("event-lineage-off-hand", "off-hand");