fix: allow explicit WoW Warrior off-hand swings
This commit is contained in:
1 parent
9be6f54c52
commit
2f8cb8a510
2 files changed
+141
-6
No files matched your search
+32
-2
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user