Files
healer-man/src/game/playerAutoAttackEventLineage.integration.test.ts
T

287 lines
12 KiB
TypeScript
Executable File

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura as applyCombatAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
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,
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
const TARGET_ID = "player-auto-event-lineage-target";
let presentationEvents: CombatPresentationEvent[] = [];
let publishedCombatEvents: StructuredCombatEvent[] = [];
let unsubscribePresentation = () => {};
let unsubscribeCombatEvents = () => {};
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: overrides.inventorySlot ?? inventorySlot,
armorType: overrides.armorType ?? null,
weaponType: overrides.weaponType === undefined ? "dagger" : overrides.weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats: {
...EMPTY_ITEM_STATS,
hitRating: 10_000,
meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 },
},
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
}
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 });
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);
presentationEvents = [];
publishedCombatEvents = [];
unsubscribePresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event") presentationEvents.push(message.event);
});
unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event));
});
afterEach(() => {
unsubscribePresentation();
unsubscribeCombatEvents();
});
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");
useCombatStore.getState().initializeCharacter({
classId: "rogue",
level: 80,
inventory: [mainHand, offHand],
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: {
"main-hand": mainHand.instanceId,
"off-hand": offHand.instanceId,
} },
});
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);
const triggeringAbility = useCombatStore.getState().abilities.find((ability) => ability.name === "Sinister Strike");
expect(triggeringAbility).toBeDefined();
expect(useCombatStore.getState().castAbility(triggeringAbility!.id, undefined, 1_000).ok).toBe(true);
const healthBeforeSwings = useCombatStore.getState().mobs[TARGET_ID]!.health;
useCombatStore.getState().tick(0, 1_000);
const swingEvents = 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")
));
expect(swingEvents).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
]));
expect(swingEvents).toHaveLength(2);
expect(swingEvents.reduce((sum, event) => sum + event.effectiveAmount, 0))
.toBe(healthBeforeSwings - useCombatStore.getState().mobs[TARGET_ID]!.health);
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
kind: "damage",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: TARGET_ID,
abilityId: triggeringAbility!.id,
}));
expect(presentationEvents).toContainEqual(expect.objectContaining({
occurredAt: 1_000,
phase: "release",
targetActorId: TARGET_ID,
abilityId: "player-auto-attack",
}));
expect(presentationEvents).toContainEqual(expect.objectContaining({
occurredAt: 1_000,
phase: "release",
targetActorId: TARGET_ID,
abilityId: "player-auto-attack-offhand",
}));
expect(publishedCombatEvents.filter((event) => event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === TARGET_ID
&& (event.abilityId === "player-auto-attack" || event.abilityId === "player-auto-attack-offhand")))
.toHaveLength(2);
});
it("keeps Dancing Rune Weapon's mirrored damage under its proc identity", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
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]);
const aura = applyCombatAura(useCombatStore.getState().auras, {
definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 1_000,
});
useCombatStore.setState({
auras: [...aura.auras],
autoAttackTargetId: TARGET_ID,
nextAutoAttackAt: 1_000,
});
const healthBeforeSwing = useCombatStore.getState().mobs[TARGET_ID]!.health;
useCombatStore.getState().tick(0, 1_000);
const playerDamage = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
));
expect(playerDamage).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
expect.objectContaining({
abilityId: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
}),
]));
expect(playerDamage.find((event) => event.abilityId
=== WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).not.toHaveProperty("attackHand");
expect(playerDamage).toHaveLength(2);
expect(playerDamage.reduce((sum, event) => sum + event.effectiveAmount, 0))
.toBe(healthBeforeSwing - useCombatStore.getState().mobs[TARGET_ID]!.health);
expect(presentationEvents.filter((event) => event.phase === "release"
&& event.abilityId === "player-auto-attack")).toHaveLength(1);
expect(presentationEvents.some((event) => event.phase === "release"
&& event.abilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).toBe(false);
});
});