From 2f8cb8a5104978e66077523202b4d7f9ec24ff60 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 15 Oct 2026 09:24:00 -0400 Subject: [PATCH] fix: allow explicit WoW Warrior off-hand swings --- src/game/combatStore.ts | 34 +++++- ...AutoAttackEventLineage.integration.test.ts | 113 +++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 4973fc4a..de32e050 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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, +): 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((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; diff --git a/src/game/playerAutoAttackEventLineage.integration.test.ts b/src/game/playerAutoAttackEventLineage.integration.test.ts index 32334af3..29def3c0 100755 --- a/src/game/playerAutoAttackEventLineage.integration.test.ts +++ b/src/game/playerAutoAttackEventLineage.integration.test.ts @@ -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> = {}, +) { 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");