222 lines
10 KiB
TypeScript
222 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
|
|
function playerWhirlwind(state: ReturnType<typeof useCombatStore.getState>): AbilityDefinition {
|
|
const ability = state.abilities.find((candidate) => candidate.dbcSpellId === 490133);
|
|
if (!ability) throw new Error("RuneWaker Warrior Whirlwind is missing from Knight/Warrior abilities.");
|
|
return ability;
|
|
}
|
|
|
|
function testShield(instanceId: string) {
|
|
return {
|
|
instanceId,
|
|
itemId: 1,
|
|
itemKey: instanceId,
|
|
name: "Test Shield",
|
|
quality: "uncommon" as const,
|
|
inventorySlot: "off-hand" as const,
|
|
armorType: "shield" as const,
|
|
weaponType: null,
|
|
itemLevel: 1,
|
|
sourceItemLevel: 1,
|
|
stats: { ...useCombatStore.getState().gearStats },
|
|
quantity: 1,
|
|
sourceBossKey: "test",
|
|
sourceBossName: "Test",
|
|
acquiredAt: 1,
|
|
};
|
|
}
|
|
|
|
describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
usePartyStore.setState({ members: [] });
|
|
});
|
|
|
|
it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-warrior",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
});
|
|
const initial = useCombatStore.getState();
|
|
const whirlwind = playerWhirlwind(initial);
|
|
expect(initial.abilities.find((ability) => ability.dbcSpellId === 491639)).toMatchObject({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-warrior",
|
|
passive: true,
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, rage: 40 },
|
|
maxResourcePools: { ...state.maxResourcePools, rage: 100 },
|
|
}));
|
|
useCombatStore.getState().registerMob("knight-whirlwind-target", {
|
|
name: "Whirlwind Target",
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("knight-whirlwind-target", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("knight-whirlwind-target");
|
|
|
|
const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000);
|
|
|
|
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
|
const final = useCombatStore.getState();
|
|
expect(final.resourcePools.rage).toBe(0);
|
|
expect(final.cooldowns[whirlwind.id]).toBe(11_000);
|
|
expect(final.combatEvents.some((event) => event.kind === "damage"
|
|
&& event.abilityId === whirlwind.id && event.targetActorId === "knight-whirlwind-target"
|
|
&& event.effectiveAmount > 0)).toBe(true);
|
|
});
|
|
|
|
it("uses the learned paired passive for companion Whirlwind cost and cooldown", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const knightSeed = createConfiguredPartyMember({
|
|
name: "Knight Warrior",
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-warrior",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, "deadly-whirlwind-party");
|
|
const abilities = abilitiesForCharacter("rom-knight", "rom-warrior");
|
|
const whirlwind = abilities.find((ability) => ability.dbcSpellId === 490133)!;
|
|
const knight = {
|
|
...knightSeed,
|
|
resourcePools: { ...knightSeed.resourcePools, rage: 40 },
|
|
cooldowns: Object.fromEntries(abilities
|
|
.filter((ability) => ability.id !== whirlwind.id)
|
|
.map((ability) => [ability.id, 60_000])),
|
|
};
|
|
usePartyStore.setState({ members: [knight], command: "attack" });
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
|
useCombatStore.getState().registerMob("party-knight-whirlwind-target", {
|
|
name: "Party Whirlwind Target",
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
// Keep the target inside Whirlwind's area but outside the companion's melee range
|
|
// so its autoattack cannot add Rage after the discounted spell is spent.
|
|
setMobPosition("party-knight-whirlwind-target", [10, 0, 0]);
|
|
useCombatStore.getState().damageMob("party-knight-whirlwind-target", 1, 100);
|
|
updatePartyRuntimeTargetVisibility(knight.id, "party-knight-whirlwind-target", true);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!;
|
|
expect(finalMember.resourcePools.rage).toBe(0);
|
|
expect(finalMember.cooldowns[whirlwind.id]).toBe(11_000);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
|
&& event.sourceActorId === knight.id && event.abilityId === whirlwind.id
|
|
&& event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true);
|
|
});
|
|
|
|
it("applies current Compassion to player Whirlwind Shield only after a landed hit", () => {
|
|
const playerShield = testShield("compassion-player-shield");
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
|
inventory: [playerShield],
|
|
equipment: { player: { "off-hand": playerShield.instanceId } },
|
|
});
|
|
const initial = useCombatStore.getState();
|
|
const shield = initial.abilities.find((ability) => ability.dbcSpellId === 490175)!;
|
|
expect(shield).toMatchObject({ cooldownMs: 5_000, radius: 100 });
|
|
expect(initial.abilities.find((ability) => ability.dbcSpellId === 491635)).toMatchObject({
|
|
classId: "rom-knight", secondaryClassId: "rom-priest", passive: true,
|
|
});
|
|
|
|
const allySeed = createConfiguredPartyMember({
|
|
name: "Wounded Ally", classId: "priest", specialization: "Holy", role: "healer",
|
|
}, 80, 0, "compassion-player-ally");
|
|
const ally = { ...allySeed, health: 1 };
|
|
usePartyStore.setState({ members: [ally] });
|
|
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().registerMob("compassion-player-target", {
|
|
name: "Compassion Target", maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("compassion-player-target", [8, 0, 0]);
|
|
useCombatStore.getState().selectMob("compassion-player-target");
|
|
|
|
const result = useCombatStore.getState().castAbility(shield.id, undefined, 1_000);
|
|
const final = useCombatStore.getState();
|
|
|
|
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
|
expect(final.cooldowns[shield.id]).toBe(5_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === ally.id)!.health).toBeGreaterThan(1);
|
|
expect(final.combatEvents.some((event) => event.kind === "damage"
|
|
&& event.abilityId === shield.id && event.targetActorId === "compassion-player-target"
|
|
&& event.effectiveAmount > 0 && event.school === "holy")).toBe(true);
|
|
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
|
|
&& aura.targetId === ally.id && aura.expiresAt === 11_000)).toBe(true);
|
|
});
|
|
|
|
it("applies current Compassion heal, Holy Illumination and 4-second cooldown for a generated companion", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const seed = "compassion-party-knight-shaman";
|
|
const knightSeed = createConfiguredPartyMember({
|
|
name: "Knight Shaman",
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-priest",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const abilities = abilitiesForCharacter("rom-knight", "rom-priest");
|
|
const shield = abilities.find((ability) => ability.dbcSpellId === 490175)!;
|
|
const knight = {
|
|
...knightSeed,
|
|
health: 1,
|
|
cooldowns: Object.fromEntries(abilities
|
|
.filter((ability) => ability.id !== shield.id)
|
|
.map((ability) => [ability.id, 60_000])),
|
|
};
|
|
const partyShield = testShield("compassion-party-shield");
|
|
usePartyStore.setState({ members: [knight], command: "attack" });
|
|
const assignments = {
|
|
...useCombatStore.getState().equipment,
|
|
[knight.id]: { "off-hand": partyShield.instanceId },
|
|
};
|
|
useCombatStore.setState((state) => ({
|
|
equipment: assignments,
|
|
inventory: [...state.inventory, partyShield],
|
|
}));
|
|
usePartyStore.getState().syncEquipment(assignments, useCombatStore.getState().inventory);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
|
useCombatStore.getState().registerMob("compassion-party-target", {
|
|
name: "Compassion Party Target", maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("compassion-party-target", [10, 0, 0]);
|
|
useCombatStore.getState().damageMob("compassion-party-target", 1, 100);
|
|
updatePartyRuntimeTargetVisibility(knight.id, "compassion-party-target", true);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!;
|
|
const final = useCombatStore.getState();
|
|
expect(final.combatEvents.some((event) => event.kind === "damage"
|
|
&& event.sourceActorId === knight.id && event.abilityId === shield.id
|
|
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0)).toBe(true);
|
|
expect(finalMember.health).toBeGreaterThan(1);
|
|
expect(finalMember.cooldowns[shield.id]).toBe(5_000);
|
|
expect(final.combatEvents.some((event) => event.kind === "damage"
|
|
&& event.sourceActorId === knight.id && event.abilityId === shield.id
|
|
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0
|
|
&& event.school === "holy")).toBe(true);
|
|
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
|
|
&& aura.sourceId === knight.id && aura.targetId === knight.id && aura.expiresAt === 11_000)).toBe(true);
|
|
});
|
|
});
|