Files
healer-man/src/game/enemyDisarmRuntime.integration.test.ts
T
2026-10-06 22:25:34 -04:00

283 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { abilitiesForClass } from "./abilityCatalog";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, partyAttackDamage, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
const START_AT = 1_000;
function weaponItem(
instanceId: string,
weaponType: "dagger" | "sword",
damage: number,
speedMs: number,
) {
return {
instanceId,
itemId: 1,
itemKey: instanceId,
name: `Source fixture ${weaponType}`,
quality: "uncommon" as const,
inventorySlot: "one-hand",
armorType: null,
weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats: {
...EMPTY_ITEM_STATS,
meleeWeapon: { damageMin: damage, damageMax: damage, damageSchool: "physical" as const, speedMs },
},
quantity: 1,
sourceBossKey: "source-disarm-fixture",
sourceBossName: "Source Disarm Fixture",
acquiredAt: START_AT,
};
}
function rangedItem(instanceId: string) {
return {
...weaponItem(instanceId, "sword", 500, 2_500),
inventorySlot: "ranged",
weaponType: "bow",
stats: {
...EMPTY_ITEM_STATS,
rangedWeapon: { damageMin: 400, damageMax: 400, damageSchool: "physical" as const, speedMs: 2_500 },
},
};
}
function applySourceDisarm(targetId: string, now = START_AT): void {
const definition = enemyWeaponDebuffAuraDefinition(enemyWeaponDebuffContract(6713, "ascension")!);
useCombatStore.setState((state) => ({
auras: [...applyAura(state.auras, { definition, sourceId: "source-disarm-caster", targetId, now }).auras],
}));
}
function registerTarget(id: string): void {
useCombatStore.getState().registerMob(id, {
name: "Source Disarm runtime target",
level: 1,
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().selectMob(id);
}
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("enemy-disarm-runtime", 30, useCombatStore.getState().health, "damage", 1, "wow");
});
describe("source Disarm character execution", () => {
it("blocks an equipped-weapon cast and keeps the player's main-hand auto as a 1–2 / 2s punch", () => {
const dagger = weaponItem("player-disarm-dagger", "dagger", 500, 4_000);
useCombatStore.getState().initializeCharacter({
classId: "rogue",
level: 80,
inventory: [dagger],
equipment: { player: { "main-hand": dagger.instanceId } },
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "player-disarm-runtime-target";
registerTarget(targetId);
applySourceDisarm(PLAYER_AURA_ENTITY_ID);
const sinisterStrike = abilitiesForClass("rogue").find((ability) => ability.name === "Sinister Strike")!;
expect(sinisterStrike.wowWeaponRequirements?.equippedItemClass).toBe(2);
expect(useCombatStore.getState().castAbility(sinisterStrike.id, [0, 0, 0], START_AT)).toMatchObject({
ok: false,
reason: "condition-not-met",
});
useCombatStore.setState((state) => ({
autoAttackTargetId: targetId,
nextAutoAttackAt: START_AT,
nextOffhandAutoAttackAt: 0,
auras: [...state.auras],
}));
useCombatStore.getState().tick(0.05, START_AT);
const state = useCombatStore.getState();
expect(state.nextAutoAttackAt).toBe(START_AT + 2_000);
const auto = state.combatEvents.find((event) => event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === targetId);
expect(auto).toBeDefined();
expect(auto?.rawAmount).toBeLessThan(500);
expect(auto?.attackHand).toBe("mainhand");
});
it("gates the companion's source weapon cast while retaining the ordinary unarmed party auto", () => {
const targetId = "party-disarm-runtime-target";
registerTarget(targetId);
const configured = createConfiguredPartyMember({
name: "Source fixture Protection Warrior",
classId: "warrior",
raceId: "human",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 30, 0, "party-source-disarm-runtime");
const sword = weaponItem("party-disarm-sword", "sword", 500, 4_000);
const member = {
...configured,
gearStats: sword.stats,
nextActionAt: 10_000,
};
usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId });
useCombatStore.setState((state) => ({
inventory: [...state.inventory, sword],
equipment: { ...state.equipment, [member.id]: { "main-hand": sword.instanceId } },
}));
registerPartyRuntimePosition(member.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
useCombatStore.getState().synchronizeActors();
applySourceDisarm(member.id);
const heroicStrike = abilitiesForClass("warrior").find((ability) => ability.name === "Heroic Strike")!;
expect(heroicStrike.wowWeaponRequirements?.equippedItemClass).toBe(2);
expect(partyAbilityTargetIsValid(member, heroicStrike, targetId, [0, 0, 0], START_AT)).toBe(false);
const permittedPhysicalMeleeAbility = abilitiesForClass("warrior")
.find((ability) => ability.name === "Devastate")!;
expect(permittedPhysicalMeleeAbility.wowSpellDefense?.damageClass).toBe(2);
expect(permittedPhysicalMeleeAbility.wowWeaponRequirements).toBeUndefined();
expect(partyAbilityTargetIsValid(
member, permittedPhysicalMeleeAbility!, targetId, [0, 0, 0], START_AT,
)).toBe(true);
const expectedUnarmedAmount = partyAttackDamage(member, START_AT, {
damageMin: 1,
damageMax: 2,
damageSchool: "physical",
speedMs: 2_000,
});
advanceDungeonPartyCombat(START_AT);
const auto = useCombatStore.getState().combatEvents.find((event) => event.kind === "damage"
&& event.sourceActorId === member.id
&& event.abilityId === "party-auto-attack"
&& event.targetActorId === targetId);
expect(auto).toBeDefined();
expect(auto?.rawAmount).toBe(expectedUnarmedAmount);
expect(auto?.attackHand).toBe("mainhand");
});
it("uses only the unarmed main-hand contribution for a disarmed dual-wielding party shaman", () => {
const targetId = "party-disarm-shaman-target";
registerTarget(targetId);
const configured = createConfiguredPartyMember({
name: "Source fixture Enhancement Shaman",
classId: "shaman",
raceId: "orc",
specialization: "Enhancement",
specializationId: "shaman:enhancement",
talentTreeId: "shaman-enhancement",
role: "damage",
}, 80, 0, "party-source-disarm-shaman");
const mainHand = weaponItem("party-disarm-shaman-main", "sword", 500, 4_000);
const offHand = { ...weaponItem("party-disarm-shaman-off", "sword", 500, 4_000), inventorySlot: "one-hand" };
const member = { ...configured, gearStats: mainHand.stats, nextActionAt: START_AT + 100_000 };
usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId });
useCombatStore.setState((state) => ({
inventory: [...state.inventory, mainHand, offHand],
equipment: {
...state.equipment,
[member.id]: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId },
},
}));
registerPartyRuntimePosition(member.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
useCombatStore.getState().synchronizeActors();
applySourceDisarm(member.id);
advanceDungeonPartyCombat(START_AT);
const events = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === member.id && event.targetActorId === targetId
));
const mainHandHit = events.find((event) => event.abilityId === "party-auto-attack");
const offHandHit = events.find((event) => event.abilityId === "party-auto-attack-offhand");
expect(mainHandHit).toBeDefined();
expect(offHandHit).toBeDefined();
expect(mainHandHit!.rawAmount).toBeLessThan(200);
expect(offHandHit!.rawAmount).toBeGreaterThan(mainHandHit!.rawAmount + 10);
expect(mainHandHit!.attackHand).toBe("mainhand");
expect(offHandHit!.attackHand).toBe("offhand");
});
it("uses the verified 3-second unarmed profile for a native party main-hand auto", () => {
const targetId = "party-disarm-native-target";
registerTarget(targetId);
const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-knight")!;
const configured = createConfiguredPartyMember({
...identity,
statRuleset: "rom",
specialization: "Primary",
talentTreeId: "",
}, 30, 0, "party-native-disarm-runtime");
const member = { ...configured, nextActionAt: START_AT + 100_000 };
usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
useCombatStore.getState().synchronizeActors();
applySourceDisarm(member.id);
advanceDungeonPartyCombat(START_AT);
const autoEvents = () => useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === member.id
&& event.abilityId === "party-auto-attack" && event.targetActorId === targetId
));
expect(autoEvents()).toHaveLength(1);
expect(autoEvents()[0].rawAmount).toBe(partyAttackDamage(member, START_AT, undefined, true));
expect(autoEvents()[0].attackHand).toBe("mainhand");
advanceDungeonPartyCombat(START_AT + 2_999);
expect(autoEvents()).toHaveLength(1);
advanceDungeonPartyCombat(START_AT + 3_000);
expect(autoEvents()).toHaveLength(2);
});
it("rechecks the Ascension ranged channel for a running Scout Auto Shot and resumes after expiry", () => {
const bow = rangedItem("scout-disarm-bow");
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
secondaryClassId: "rom-rogue",
level: 80,
statRuleset: "rom",
inventory: [bow],
equipment: { player: { ranged: bow.instanceId } },
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "scout-disarm-ranged-target";
useCombatStore.getState().registerMob(targetId, {
name: "Scout ranged Disarm target",
level: 1,
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition(targetId, [4, 0, 0]);
applySourceDisarm(PLAYER_AURA_ENTITY_ID);
useCombatStore.setState({ autoShotTargetId: targetId, nextAutoShotAt: START_AT });
useCombatStore.getState().tick(0.05, START_AT);
expect(useCombatStore.getState().mobs[targetId]?.health).toBe(100_000);
expect(useCombatStore.getState().autoShotTargetId).toBe(targetId);
useCombatStore.getState().tick(0.05, START_AT + 5_000);
expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(100_000);
expect(useCombatStore.getState().combatEvents.find((event) => event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === targetId && event.kind === "damage")?.attackHand).toBe("ranged");
});
});