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