Files
healer-man/src/game/romRogueMechanics.test.ts
T
2026-10-02 10:38:12 -04:00

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);
});
});