834 lines
41 KiB
TypeScript
834 lines
41 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { abilityById } from "./abilityCatalog";
|
|
import { clearMobRuntimeRegistry, getMobRuntimeTransform, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { LOOT_CATALOG } from "./lootCatalog";
|
|
import { abilityRequiresOutOfCombat } from "./abilityRules";
|
|
import { romEffectsForCast } from "./romCombatMechanics";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
|
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
|
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
|
import { combatAttackTable } from "./combatResults";
|
|
import { usePartyStore } from "./partyStore";
|
|
import {
|
|
ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER,
|
|
ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION,
|
|
ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID,
|
|
ROM_ROGUE_COERCE_PASSIVE_SPELL_ID,
|
|
ROM_ROGUE_COERCE_TAG,
|
|
ROM_ROGUE_DAGGER_MASTERY_TAG,
|
|
ROM_ROGUE_INSTANT_SHADOW_READY_TAG,
|
|
ROM_ROGUE_OFF_HAND_MASTERY_TAG,
|
|
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
|
|
ROM_ROGUE_TREASURE_HUNTER_TAG,
|
|
romRogueCoerceAbilityAdjustment,
|
|
romRogueCoerceLowBlowDamageMultiplier,
|
|
romRogueDaggerDamageMultiplier,
|
|
romRogueShadowWalkerAdjustment,
|
|
romRogueTreasureHunterLootChance,
|
|
} from "./romRogueMechanics";
|
|
import {
|
|
ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG,
|
|
ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY,
|
|
ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG,
|
|
romRogueMageDayOfRainBaseMissPercent,
|
|
romScoutRogueProcActionSpec,
|
|
} from "./romScoutRogueOverrides";
|
|
|
|
const SINISTER_METHODS_BUFF_ID = "rom-pair-sinister-methods-critical";
|
|
|
|
function prepareSinisterMethodsTarget(
|
|
abilityId: "rom-rogue-primary-1" | "rom-rogue-primary-4" | "rom-rogue-primary-2",
|
|
mobLevel = 80,
|
|
criticalStrikeRating = 0,
|
|
) {
|
|
const behindTarget = abilityId === "rom-rogue-primary-4";
|
|
const dagger = behindTarget ? testWeapon("dagger", "rogue-sinister-methods-dagger") : null;
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
...(dagger ? { inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } } } : {}),
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
playerPosition: behindTarget ? [0, 0, -2] : [0, 0, 0],
|
|
gearStats: {
|
|
...state.gearStats,
|
|
hitRating: mobLevel >= 200 ? 0 : 1_000_000,
|
|
criticalStrikeRating,
|
|
},
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
|
}));
|
|
const targetId = "rogue-sinister-methods-target";
|
|
useCombatStore.getState().registerMob(targetId, {
|
|
name: "Sinister Methods target",
|
|
maxHealth: 100_000,
|
|
level: mobLevel,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(targetId, behindTarget ? [0, 0, 0] : [0, 0, 2]);
|
|
useCombatStore.getState().selectMob(targetId);
|
|
return targetId;
|
|
}
|
|
|
|
function sinisterMethodsBuff() {
|
|
return useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID && aura.definition.id === SINISTER_METHODS_BUFF_ID
|
|
));
|
|
}
|
|
|
|
function castRogueAbilityAtTarget(abilityId: string, targetActorId: string, now: number) {
|
|
return useCombatStore.getState().castAbility(abilityId, { targetActorId, now });
|
|
}
|
|
|
|
function effectAuras(abilityId: string) {
|
|
return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura");
|
|
}
|
|
|
|
function testWeapon(weaponType: string, instanceId: string, stats = { ...useCombatStore.getState().gearStats }) {
|
|
return {
|
|
instanceId,
|
|
itemId: 1,
|
|
itemKey: instanceId,
|
|
name: `Test ${weaponType}`,
|
|
quality: "uncommon" as const,
|
|
inventorySlot: weaponType === "crossbow" ? "two-hand" : "one-hand",
|
|
armorType: null,
|
|
weaponType,
|
|
itemLevel: 1,
|
|
sourceItemLevel: 1,
|
|
stats,
|
|
quantity: 1,
|
|
sourceBossKey: "test",
|
|
sourceBossName: "Test",
|
|
acquiredAt: 1,
|
|
};
|
|
}
|
|
|
|
describe("RoM Rogue source mechanics", () => {
|
|
afterEach(() => resetCombatSpatialProvider());
|
|
|
|
beforeEach(() => {
|
|
resetCombatSpatialProvider();
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
|
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
|
registerCombatSpatialProvider({
|
|
actor: (id) => {
|
|
if (id === PLAYER_AGGRO_ID) return { id, position: useCombatStore.getState().playerPosition, yaw: 0 };
|
|
const transform = getMobRuntimeTransform(id);
|
|
return transform ? { id, ...transform } : null;
|
|
},
|
|
hasLineOfSight: () => true,
|
|
projectGroundPoint: (point) => point,
|
|
});
|
|
});
|
|
|
|
it("keeps Dagger Mastery, Treasure Hunter, and Off Hand Mastery as scoped passive tags", () => {
|
|
const daggerMastery = effectAuras("rom-rogue-primary-3")[0];
|
|
const treasureHunter = effectAuras("rom-rogue-primary-10")[0];
|
|
expect(daggerMastery?.kind === "apply-aura" && daggerMastery.aura.utilityTags)
|
|
.toContain(ROM_ROGUE_DAGGER_MASTERY_TAG);
|
|
expect(treasureHunter?.kind === "apply-aura" && treasureHunter.aura.utilityTags)
|
|
.toContain(ROM_ROGUE_TREASURE_HUNTER_TAG);
|
|
expect(effectAuras("rom-rogue-primary-12")[0]).toEqual(expect.objectContaining({
|
|
kind: "apply-aura",
|
|
aura: expect.objectContaining({ utilityTags: [ROM_ROGUE_OFF_HAND_MASTERY_TAG] }),
|
|
}));
|
|
});
|
|
|
|
it("translates Coerce to a learned Rogue/Scout marker for current Low Blow behavior", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
|
|
});
|
|
const passive = abilityById("rom-rogue-rom-scout-elite-45")!;
|
|
const [marker] = effectAuras(passive.id);
|
|
expect(marker?.kind === "apply-aura" && marker.aura.utilityTags).toContain(ROM_ROGUE_COERCE_TAG);
|
|
expect(marker?.kind === "apply-aura" && marker.aura.modifiers).toBeUndefined();
|
|
});
|
|
|
|
it("applies Coerce's sourced 20.2% Low Blow multiplier only to learned Rogue/Scout Low Blow", () => {
|
|
const context = {
|
|
classId: "rom-rogue",
|
|
abilitySource: "runewaker",
|
|
abilitySpellId: ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID,
|
|
passiveTags: [ROM_ROGUE_COERCE_TAG],
|
|
} as const;
|
|
expect(romRogueCoerceLowBlowDamageMultiplier(context)).toBe(ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER);
|
|
expect(romRogueCoerceLowBlowDamageMultiplier({ ...context, abilitySpellId: 490324 })).toBe(1);
|
|
expect(romRogueCoerceLowBlowDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
|
|
});
|
|
|
|
it("reduces only learned Rogue/Scout Low Blow's Energy cost by the local sourced amount", () => {
|
|
const passiveSpellIds = [ROM_ROGUE_COERCE_PASSIVE_SPELL_ID];
|
|
expect(romRogueCoerceAbilityAdjustment(
|
|
"rom-rogue", "rom-scout", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, passiveSpellIds,
|
|
)).toMatchObject({ energyCostReduction: ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION, cooldownIncreaseMs: 0 });
|
|
expect(romRogueCoerceAbilityAdjustment(
|
|
"rom-rogue", "rom-scout", "runewaker", 490324, passiveSpellIds,
|
|
).energyCostReduction).toBe(0);
|
|
expect(romRogueCoerceAbilityAdjustment(
|
|
"rom-rogue", "rom-priest", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, passiveSpellIds,
|
|
).energyCostReduction).toBe(0);
|
|
expect(romRogueCoerceAbilityAdjustment(
|
|
"rom-rogue", "rom-scout", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, [],
|
|
).energyCostReduction).toBe(0);
|
|
});
|
|
|
|
it("applies Coerce's reduced Low Blow Energy cost during a Rogue/Scout player cast", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
|
|
});
|
|
const targetId = "rogue-coerce-target";
|
|
useCombatStore.getState().registerMob(targetId, {
|
|
name: "Coerce target", maxHealth: 100_000, level: 80, xpReward: 0,
|
|
});
|
|
setMobPosition(targetId, [0, 0, 2]);
|
|
useCombatStore.getState().selectMob(targetId);
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, hitRating: 1_000_000 },
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
|
}));
|
|
const beforeEnergy = useCombatStore.getState().resourcePools.energy!;
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-primary-1", { targetActorId: targetId, now: 1_000 }))
|
|
.toMatchObject({ ok: true });
|
|
expect(beforeEnergy - useCombatStore.getState().resourcePools.energy!).toBe(25);
|
|
});
|
|
|
|
it("scales Low Blow's damage effects by the current Coerce value for player and party cast contexts", () => {
|
|
const lowBlow = abilityById("rom-rogue-primary-1")!;
|
|
const context = {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 1_000,
|
|
targetHealthPercent: 1,
|
|
passiveTags: [ROM_ROGUE_COERCE_TAG],
|
|
} as const;
|
|
const base = romEffectsForCast(lowBlow, { ...context, passiveTags: [] });
|
|
const coerced = romEffectsForCast(lowBlow, context);
|
|
const baseDamage = base.filter((effect) => effect.kind === "damage");
|
|
const coercedDamage = coerced.filter((effect) => effect.kind === "damage");
|
|
expect(coercedDamage).toHaveLength(baseDamage.length);
|
|
expect(coercedDamage.map((effect) => effect.kind === "damage" && effect.coefficient))
|
|
.toEqual(baseDamage.map((effect) => effect.kind === "damage" && effect.coefficient * ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER));
|
|
expect(romEffectsForCast(lowBlow, { ...context, passiveTags: [] }).filter((effect) => effect.kind === "damage"))
|
|
.toEqual(baseDamage);
|
|
});
|
|
|
|
it("learns Sinister Methods as a proc marker without an always-on critical aura", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
|
});
|
|
const passive = abilityById("rom-rogue-rom-priest-elite-45")!;
|
|
const [marker] = effectAuras(passive.id);
|
|
expect(marker?.kind === "apply-aura" && marker.aura.utilityTags)
|
|
.toContain(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG);
|
|
expect(marker?.kind === "apply-aura" && marker.aura.procs?.[0]?.triggers).toEqual(["hit", "crit"]);
|
|
expect(marker?.kind === "apply-aura" && marker.aura.modifiers).toBeUndefined();
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false);
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.utilityTags?.includes(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG)
|
|
))).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["Low Blow", "rom-rogue-primary-1"],
|
|
["Blind Spot", "rom-rogue-primary-4"],
|
|
] as const)("procs Sinister Methods after a landed %s", (_name, abilityId) => {
|
|
const targetId = prepareSinisterMethodsTarget(abilityId);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false);
|
|
const castResult = castRogueAbilityAtTarget(abilityId, targetId, 1_000);
|
|
expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true });
|
|
const hit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === abilityId
|
|
));
|
|
expect(hit?.outcome).not.toBe("miss");
|
|
expect(sinisterMethodsBuff()).toMatchObject({
|
|
appliedAt: 1_000,
|
|
expiresAt: 11_000,
|
|
definition: {
|
|
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
|
|
},
|
|
});
|
|
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000);
|
|
});
|
|
|
|
it("also procs Sinister Methods on a landed critical hit", () => {
|
|
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 80, 1_000_000);
|
|
const castResult = castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000);
|
|
expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === "rom-rogue-primary-1"
|
|
))?.outcome).toBe("crit");
|
|
expect(sinisterMethodsBuff()?.appliedAt).toBe(1_000);
|
|
});
|
|
|
|
it("does not proc or start its cooldown on a miss or another ability", () => {
|
|
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 200);
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === "rom-rogue-primary-1"
|
|
))?.outcome).toBe("miss");
|
|
expect(sinisterMethodsBuff()).toBeUndefined();
|
|
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined();
|
|
|
|
const otherTargetId = prepareSinisterMethodsTarget("rom-rogue-primary-2");
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-2", otherTargetId, 3_000).ok).toBe(true);
|
|
expect(sinisterMethodsBuff()).toBeUndefined();
|
|
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined();
|
|
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, hitRating: 1_000_000 },
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
|
}));
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", otherTargetId, 5_000).ok).toBe(true);
|
|
expect(sinisterMethodsBuff()?.appliedAt).toBe(5_000);
|
|
});
|
|
|
|
it("honors Sinister Methods duration and 20-second cooldown expiry", () => {
|
|
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1");
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true);
|
|
expect(sinisterMethodsBuff()?.expiresAt).toBe(11_000);
|
|
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
|
}));
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 12_000).ok).toBe(true);
|
|
expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 1_000, expiresAt: 11_000 });
|
|
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000);
|
|
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
|
}));
|
|
expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 21_000).ok).toBe(true);
|
|
expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 21_000, expiresAt: 31_000 });
|
|
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(41_000);
|
|
});
|
|
|
|
it("arms Instant Shadow on a successful Rogue/Mage dodge and consumes it for its three-second concealment", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
|
|
});
|
|
const ability = abilityById("rom-rogue-rom-mage-elite-60")!;
|
|
useCombatStore.getState().resetSettings();
|
|
usePartyStore.getState().initializeParty("rom-instant-shadow-tests", 1, useCombatStore.getState().health);
|
|
resetCombatSpatialProvider();
|
|
const beforeDodge = useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
|
|
expect(beforeDodge).toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
|
|
useCombatStore.getState().registerMob("rogue-instant-shadow-attacker", {
|
|
name: "Instant Shadow attacker", maxHealth: 100_000, xpReward: 0, attacks: [DEFAULT_ENEMY_ATTACK],
|
|
});
|
|
setMobPosition("rogue-instant-shadow-attacker", [0, 0, 1]);
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, dodgeRating: 1_000_000 },
|
|
resource: state.maxResource,
|
|
resourcePools: {
|
|
...state.resourcePools,
|
|
energy: state.maxResourcePools.energy,
|
|
mana: state.maxResourcePools.mana,
|
|
},
|
|
}));
|
|
useCombatStore.getState().damageMob("rogue-instant-shadow-attacker", 1, 1_100);
|
|
|
|
let dodgedAt: number | null = null;
|
|
for (let now = 1_450; now < 20_000; now += 100) {
|
|
useCombatStore.getState().advanceMobCombat(now);
|
|
if (useCombatStore.getState().lastCombatResult?.outcome === "dodge") {
|
|
dodgedAt = now;
|
|
break;
|
|
}
|
|
}
|
|
expect(dodgedAt, JSON.stringify({ health: useCombatStore.getState().health,
|
|
lastCombatResult: useCombatStore.getState().lastCombatResult,
|
|
mob: useCombatStore.getState().mobs["rogue-instant-shadow-attacker"] })).not.toBeNull();
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG)
|
|
))).toBe(true);
|
|
|
|
const beforeCast = useCombatStore.getState();
|
|
const manaCost = Math.round((beforeCast.maxResourcePools.mana ?? 0) * 0.06);
|
|
const castAt = dodgedAt! + 100;
|
|
expect(useCombatStore.getState().castAbility(ability.id, undefined, castAt))
|
|
.toMatchObject({ ok: true, abilityId: ability.id });
|
|
const afterCast = useCombatStore.getState();
|
|
expect(afterCast.resourcePools.energy).toBe((beforeCast.resourcePools.energy ?? 0) - 15);
|
|
expect(afterCast.resourcePools.mana).toBe((beforeCast.resourcePools.mana ?? 0) - manaCost);
|
|
expect(afterCast.cooldowns[ability.id]).toBe(castAt + ability.cooldownMs);
|
|
expect(afterCast.auras.some((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG)
|
|
))).toBe(false);
|
|
expect(afterCast.auras.some((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes("stealth")
|
|
&& aura.expiresAt === castAt + 3_000
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => {
|
|
const context = {
|
|
classId: "rom-rogue",
|
|
sourceActorId: "player",
|
|
playerActorId: "player",
|
|
attackKind: "melee",
|
|
school: "physical",
|
|
mainHandDagger: true,
|
|
passiveTags: [ROM_ROGUE_DAGGER_MASTERY_TAG],
|
|
};
|
|
expect(romRogueDaggerDamageMultiplier(context)).toBe(1.1);
|
|
expect(romRogueDaggerDamageMultiplier({ ...context, mainHandDagger: false })).toBe(1);
|
|
expect(romRogueDaggerDamageMultiplier({ ...context, classId: "rom-scout" })).toBe(1);
|
|
expect(romRogueDaggerDamageMultiplier({ ...context, attackKind: "spell" })).toBe(1);
|
|
expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
|
|
});
|
|
|
|
it("adds the CoA Fire packet from each landed Chaos Dagger hand and mitigates it as Fire", () => {
|
|
const weaponStats = { ...useCombatStore.getState().gearStats, hitRating: 1_000_000 };
|
|
const mainHandDamage = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 };
|
|
const offHandDamage = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 };
|
|
const mainHand = testWeapon("dagger", "chaos-dagger-player-main", { ...weaponStats, meleeWeapon: mainHandDamage });
|
|
const offHand = {
|
|
...testWeapon("dagger", "chaos-dagger-player-off", { ...weaponStats, meleeWeapon: offHandDamage }),
|
|
inventorySlot: "off-hand" as const,
|
|
};
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
|
|
inventory: [mainHand, offHand],
|
|
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
|
|
});
|
|
const manaBefore = useCombatStore.getState().resourcePools.mana;
|
|
useCombatStore.getState().registerMob("chaos-dagger-target", {
|
|
name: "Chaos Dagger target", maxHealth: 1_000_000, level: 80,
|
|
schoolImmunities: ["fire"],
|
|
});
|
|
setMobPosition("chaos-dagger-target", [0, 0, 3]);
|
|
useCombatStore.setState({
|
|
autoAttackTargetId: "chaos-dagger-target",
|
|
nextAutoAttackAt: 1_500,
|
|
nextOffhandAutoAttackAt: 1_500,
|
|
});
|
|
useCombatStore.getState().tick(0.25, 1_500);
|
|
|
|
const hits = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.targetActorId === "chaos-dagger-target"
|
|
));
|
|
const fireHits = hits.filter((event) => event.school === "fire");
|
|
expect(fireHits.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]);
|
|
expect(fireHits.every((event) => event.effectiveAmount === 0)).toBe(true);
|
|
expect(hits.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
|
|
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore);
|
|
});
|
|
|
|
it.each([
|
|
["unlearned below level 15", 14, "rom-mage", 80],
|
|
["wrong secondary class", 80, "rom-warrior", 80],
|
|
["missed attack", 80, "rom-mage", 255],
|
|
] as const)("does not add Chaos Dagger Fire damage for %s", (_label, level, secondaryClassId, targetLevel) => {
|
|
clearMobRuntimeRegistry();
|
|
const gear = { ...useCombatStore.getState().gearStats, hitRating: targetLevel === 255 ? 0 : 1_000_000 };
|
|
const mainHand = testWeapon("dagger", `chaos-dagger-gated-main-${level}-${secondaryClassId}-${targetLevel}`, {
|
|
...gear,
|
|
meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 },
|
|
});
|
|
const offHand = {
|
|
...testWeapon("dagger", `chaos-dagger-gated-off-${level}-${secondaryClassId}-${targetLevel}`, {
|
|
...gear,
|
|
meleeWeapon: { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 },
|
|
}),
|
|
inventorySlot: "off-hand" as const,
|
|
};
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId, raceId: "rom-human", level,
|
|
inventory: [mainHand, offHand],
|
|
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
|
|
});
|
|
useCombatStore.getState().registerMob("chaos-dagger-gated-target", {
|
|
name: "Target", maxHealth: 1_000_000, level: targetLevel,
|
|
});
|
|
setMobPosition("chaos-dagger-gated-target", [0, 0, 3]);
|
|
useCombatStore.setState({
|
|
autoAttackTargetId: "chaos-dagger-gated-target",
|
|
nextAutoAttackAt: 1_500,
|
|
nextOffhandAutoAttackAt: 1_500,
|
|
});
|
|
for (let now = 1_500; now <= 20_000; now += 250) {
|
|
useCombatStore.getState().tick(0.25, now);
|
|
if (now === 1_500 && targetLevel === 255) break;
|
|
}
|
|
const damage = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.targetActorId === "chaos-dagger-gated-target"
|
|
));
|
|
expect(damage.some((event) => event.school === "fire")).toBe(false);
|
|
});
|
|
|
|
it("applies Off Hand Mastery damage only to the RoM Rogue player's equipped off-hand swing", () => {
|
|
const simulateOffhand = (masteryActive: boolean) => {
|
|
const weaponStats = {
|
|
...useCombatStore.getState().gearStats,
|
|
hitRating: 1_000_000,
|
|
meleeWeapon: { damageMin: 2_000, damageMax: 2_000, damageSchool: "physical" as const, speedMs: 2_000 },
|
|
};
|
|
const mainHand = testWeapon("dagger", `rom-offhand-main-${masteryActive}`, weaponStats);
|
|
const offHand = {
|
|
...testWeapon("dagger", `rom-offhand-second-${masteryActive}`, weaponStats),
|
|
inventorySlot: "off-hand" as const,
|
|
};
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
inventory: [mainHand, offHand],
|
|
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
|
|
});
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
|
|
))).toBe(true);
|
|
useCombatStore.getState().registerMob("rom-offhand-mastery-target", {
|
|
name: "Off-hand Mastery Target", maxHealth: 10_000_000, level: 80, xpReward: 0,
|
|
});
|
|
setMobPosition("rom-offhand-mastery-target", [0, 0, 3]);
|
|
useCombatStore.setState((state) => ({
|
|
...(masteryActive ? {} : { auras: state.auras.filter((aura) => (
|
|
!aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
|
|
)) }),
|
|
autoAttackTargetId: "rom-offhand-mastery-target",
|
|
nextAutoAttackAt: 1_500,
|
|
nextOffhandAutoAttackAt: 1_500,
|
|
}));
|
|
const presentations: CombatPresentationEvent[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event") presentations.push(message.event);
|
|
});
|
|
for (let now = 1_500; now <= 20_000; now += 250) {
|
|
useCombatStore.getState().tick(0.25, now);
|
|
if (presentations.some((event) => event.abilityId === "player-auto-attack-offhand")) break;
|
|
}
|
|
unsubscribe();
|
|
const combat = useCombatStore.getState();
|
|
return {
|
|
offhandSwingPresented: presentations.some((event) => event.abilityId === "player-auto-attack-offhand"),
|
|
totalDamage: combat.combatEvents
|
|
.filter((event) => event.kind === "damage" && event.targetActorId === "rom-offhand-mastery-target")
|
|
.reduce((total, event) => total + event.effectiveAmount, 0),
|
|
};
|
|
};
|
|
|
|
const withoutMastery = simulateOffhand(false);
|
|
const withMastery = simulateOffhand(true);
|
|
expect(withoutMastery.offhandSwingPresented).toBe(true);
|
|
expect(withMastery.offhandSwingPresented).toBe(true);
|
|
expect(withMastery.totalDamage).toBeGreaterThan(withoutMastery.totalDamage);
|
|
});
|
|
|
|
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
|
|
expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2);
|
|
expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25);
|
|
expect(romRogueTreasureHunterLootChance(undefined, true)).toBeCloseTo(0.05);
|
|
expect(romRogueTreasureHunterLootChance(0.98, true)).toBe(1);
|
|
});
|
|
|
|
it("limits Shadow Walker's in-combat, cost, and cooldown rider to Rogue/Priest Sneak Attack", () => {
|
|
const sneakAttack = abilityById("rom-rogue-primary-9")!;
|
|
expect(romRogueShadowWalkerAdjustment(
|
|
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId,
|
|
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
|
)).toEqual({ allowsCombatUse: true, energyCostReduction: 10, cooldownIncreaseMs: 10_000 });
|
|
expect(romRogueShadowWalkerAdjustment(
|
|
"rom-rogue", "rom-scout", sneakAttack.source, sneakAttack.dbcSpellId,
|
|
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
|
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
|
expect(romRogueShadowWalkerAdjustment(
|
|
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, [],
|
|
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
|
expect(romRogueShadowWalkerAdjustment(
|
|
"rom-rogue", "rom-priest", sneakAttack.source, 490306,
|
|
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
|
|
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
|
});
|
|
|
|
it("gives Rogue/Mage Throw its source-backed one-second Day of Rain cooldown reduction", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
|
|
});
|
|
useCombatStore.getState().registerMob("rogue-day-of-rain-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-day-of-rain-target", [0, 0, 1]);
|
|
useCombatStore.getState().selectMob("rogue-day-of-rain-target");
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(26_000);
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-general-1", undefined, 2_600).ok).toBe(true);
|
|
expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(25_000);
|
|
});
|
|
|
|
it("applies Day of Rain's exact 50% miss rate only while Chaos Dagger is learned", () => {
|
|
const dayOfRain = abilityById("rom-rogue-rom-mage-elite-30")!;
|
|
const beforeBuff = romRogueMageDayOfRainBaseMissPercent(
|
|
"rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [], 6,
|
|
);
|
|
const withBuff = romRogueMageDayOfRainBaseMissPercent(
|
|
"rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG], 6,
|
|
);
|
|
expect(beforeBuff).toBeNull();
|
|
expect(withBuff).toBe(56);
|
|
expect(combatAttackTable({
|
|
amount: 100,
|
|
attackKind: "ranged",
|
|
damageSchool: "physical",
|
|
attacker: { level: 80, hitPercent: 6, criticalChancePercent: 0 },
|
|
defender: { level: 80, dodgePercent: 0, parryPercent: 0, blockPercent: 0, criticalAvoidancePercent: 0 },
|
|
rules: { baseMissChancePercent: withBuff! },
|
|
}).missPercent).toBe(50);
|
|
expect(romRogueMageDayOfRainBaseMissPercent(
|
|
"rom-rogue", "rom-warrior", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG], 6,
|
|
)).toBeNull();
|
|
});
|
|
|
|
it("uses the learned Chaos Dagger hit penalty in Day of Rain's resolved combat result", () => {
|
|
const outcomeFor = (now: number, chaosDaggerLearned: boolean): string | undefined => {
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
|
|
});
|
|
useCombatStore.getState().registerMob("rogue-day-rain-roll-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-day-rain-roll-target", [0, 0, 1]);
|
|
useCombatStore.getState().selectMob("rogue-day-rain-roll-target");
|
|
if (!chaosDaggerLearned) {
|
|
useCombatStore.setState((state) => ({
|
|
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG)),
|
|
}));
|
|
}
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, now).ok).toBe(true);
|
|
return useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === "rom-rogue-rom-mage-elite-30"
|
|
))?.outcome;
|
|
};
|
|
|
|
let foundEnchantedOnlyMiss = false;
|
|
for (let now = 3_000; now < 3_100; now += 1) {
|
|
const ordinaryOutcome = outcomeFor(now, false);
|
|
const enchantedOutcome = outcomeFor(now, true);
|
|
if (ordinaryOutcome !== "miss" && ordinaryOutcome !== undefined && enchantedOutcome === "miss") {
|
|
foundEnchantedOnlyMiss = true;
|
|
break;
|
|
}
|
|
}
|
|
expect(foundEnchantedOnlyMiss).toBe(true);
|
|
});
|
|
|
|
it("marks the learned Chaos Dagger passive for Day of Rain's hit-rate rider", () => {
|
|
const chaosDagger = abilityById("rom-rogue-rom-mage-elite-15")!;
|
|
expect(chaosDagger.passive).toBe(true);
|
|
expect(chaosDagger.costs).toEqual([]);
|
|
expect(chaosDagger.description).toContain("lists 25 MP");
|
|
expect(chaosDagger.description).toContain("no Mana charge is modeled");
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 15,
|
|
});
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG)
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("adds one bonus boss loot roll only while Treasure Hunter is active", () => {
|
|
const bossKey = LOOT_CATALOG.bosses[0]!.bossKey;
|
|
const lootCount = (passiveActive: boolean) => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
|
if (!passiveActive) {
|
|
useCombatStore.setState((state) => ({
|
|
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG)),
|
|
}));
|
|
}
|
|
useCombatStore.getState().registerMob("rogue-treasure-target", {
|
|
name: "Treasure Target", boss: true, maxHealth: 10, lootSourceId: bossKey,
|
|
});
|
|
const random = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
try {
|
|
useCombatStore.getState().damageMob("rogue-treasure-target", 10, 1_000);
|
|
} finally {
|
|
random.mockRestore();
|
|
}
|
|
return useCombatStore.getState().mobs["rogue-treasure-target"]!.pendingLoot.length;
|
|
};
|
|
const ordinaryDrops = lootCount(false);
|
|
const treasureHunterDrops = lootCount(true);
|
|
expect(treasureHunterDrops).toBe(ordinaryDrops + 1);
|
|
});
|
|
|
|
it("keeps Soul Stab's unconditional first hit and gates only the second hit on Weakened", () => {
|
|
const ability = abilityById("rom-rogue-rom-warlock-elite-25")!;
|
|
const context = {
|
|
sealStacks: 0,
|
|
bleedCount: 0,
|
|
charged: false,
|
|
mainHandDagger: false,
|
|
casterMaxHealth: 2_000,
|
|
targetHealthPercent: 1,
|
|
};
|
|
const ordinary = romEffectsForCast(ability, context).filter((effect) => effect.kind === "damage");
|
|
const weakened = romEffectsForCast(ability, { ...context, targetTags: ["rom-weakened"] })
|
|
.filter((effect) => effect.kind === "damage");
|
|
expect(ordinary).toHaveLength(1);
|
|
expect(weakened).toHaveLength(2);
|
|
});
|
|
|
|
it("marks Blind Spot and Sneak Attack as rear attacks and preserves Sneak Attack's combat gate", () => {
|
|
const blindSpot = abilityById("rom-rogue-primary-4")!;
|
|
const sneakAttack = abilityById("rom-rogue-primary-9")!;
|
|
expect(blindSpot.targeting?.requiresBehindTarget).toBe(true);
|
|
expect(sneakAttack.targeting?.requiresBehindTarget).toBe(true);
|
|
expect(abilityRequiresOutOfCombat(sneakAttack)).toBe(true);
|
|
});
|
|
|
|
it("rejects Blind Spot from the front and allows Sneak Attack from behind out of combat", () => {
|
|
const dagger = testWeapon("dagger", "rogue-position-dagger");
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
|
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
|
});
|
|
useCombatStore.getState().registerMob("rogue-rear-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-rear-target", [0, 0, 0]);
|
|
useCombatStore.getState().selectMob("rogue-rear-target");
|
|
useCombatStore.setState((state) => ({
|
|
playerPosition: [0, 0, 2],
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-primary-4", undefined, 1_000))
|
|
.toMatchObject({ ok: false, reason: "must-be-behind" });
|
|
|
|
useCombatStore.setState((state) => ({ playerPosition: [0, 0, -2], resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 } }));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 2_000).ok).toBe(true);
|
|
});
|
|
|
|
it("rejects Sneak Attack while an enemy is already engaged", () => {
|
|
const dagger = testWeapon("dagger", "rogue-combat-dagger");
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
|
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
|
});
|
|
useCombatStore.getState().registerMob("rogue-engaged-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-engaged-target", [0, 0, 0]);
|
|
useCombatStore.getState().selectMob("rogue-engaged-target");
|
|
useCombatStore.setState((state) => ({
|
|
playerPosition: [0, 0, -2],
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
mobs: { ...state.mobs, "rogue-engaged-target": { ...state.mobs["rogue-engaged-target"]!, engaged: true } },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
});
|
|
|
|
it("applies Shadow Walker to combat Sneak Attack with the exact Energy and cooldown rider", () => {
|
|
const dagger = testWeapon("dagger", "rogue-shadow-walker-dagger");
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
|
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
|
|
});
|
|
expect(useCombatStore.getState().abilities.some((candidate) => (
|
|
candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && candidate.passive
|
|
))).toBe(true);
|
|
useCombatStore.getState().registerMob("rogue-shadow-walker-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-shadow-walker-target", [0, 0, 0]);
|
|
useCombatStore.getState().selectMob("rogue-shadow-walker-target");
|
|
useCombatStore.setState((state) => ({
|
|
playerPosition: [0, 0, -2],
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
mobs: { ...state.mobs, "rogue-shadow-walker-target": { ...state.mobs["rogue-shadow-walker-target"]!, engaged: true } },
|
|
}));
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().resourcePools.energy).toBe(80);
|
|
expect(useCombatStore.getState().cooldowns["rom-rogue-primary-9"]).toBe(11_000);
|
|
});
|
|
|
|
it("enforces Energy Conservation's strict below-75% health threshold", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 80,
|
|
});
|
|
const maximum = useCombatStore.getState().maxHealth;
|
|
const thresholdHealth = Math.ceil(maximum * 0.75);
|
|
useCombatStore.setState((state) => ({
|
|
health: thresholdHealth,
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
useCombatStore.setState((state) => ({
|
|
health: thresholdHealth - 1,
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000).ok).toBe(true);
|
|
});
|
|
|
|
it("requires the Rogue/Knight Lion's Protection aura for Power of the Lion", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100, mana: 100 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 1_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-25", undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-491552")))
|
|
.toBe(true);
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 2_600).ok).toBe(true);
|
|
});
|
|
|
|
it("increases Shadowstab damage only with Dagger Mastery and a main-hand dagger", () => {
|
|
const baselineStats = { ...useCombatStore.getState().gearStats };
|
|
const hitWith = (weaponType: string, passiveActive: boolean, instanceId: string) => {
|
|
const weapon = testWeapon(weaponType, instanceId, baselineStats);
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue", raceId: "rom-human", level: 80,
|
|
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
|
|
});
|
|
const equipped = resolvedEquipmentLoadout(useCombatStore.getState().equipment, useCombatStore.getState().inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue",
|
|
specializationId: null, role: "damage", level: 80,
|
|
});
|
|
expect(equipped["main-hand"]?.weaponType).toBe(weaponType);
|
|
if (!passiveActive) {
|
|
useCombatStore.setState((state) => ({
|
|
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)),
|
|
}));
|
|
}
|
|
useCombatStore.getState().registerMob("rogue-dagger-mastery-target", { name: "Target", maxHealth: 100_000 });
|
|
setMobPosition("rogue-dagger-mastery-target", [0, 0, 3]);
|
|
useCombatStore.getState().selectMob("rogue-dagger-mastery-target");
|
|
useCombatStore.setState((state) => ({
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, energy: 100 },
|
|
}));
|
|
const before = useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
|
|
const cast = useCombatStore.getState().castAbility("rom-rogue-general-0", undefined, 1_000);
|
|
if (!cast.ok) throw new Error(JSON.stringify(cast));
|
|
return before - useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
|
|
};
|
|
const baseDaggerDamage = hitWith("dagger", false, "rogue-mastery-dagger-without-passive");
|
|
const daggerDamage = hitWith("dagger", true, "rogue-mastery-dagger-with-passive");
|
|
const baseNonDaggerDamage = hitWith("crossbow", false, "rogue-mastery-crossbow-without-passive");
|
|
const nonDaggerDamage = hitWith("crossbow", true, "rogue-mastery-crossbow-with-passive");
|
|
expect(daggerDamage).toBeGreaterThan(baseDaggerDamage);
|
|
expect(daggerDamage / baseDaggerDamage).toBeCloseTo(1.1, 1);
|
|
expect(nonDaggerDamage).toBe(baseNonDaggerDamage);
|
|
});
|
|
});
|