import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; import { resolvedEquipmentLoadout } from "./heirloomEquipment"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { resolveDamageValue, resolveStatValue } from "./combatAuras"; import { romAbilityNeedsRangedWeapon } from "./romCombatMechanics"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { overrideRomScoutRogueEffects, ROM_SCOUT_MAGE_WIND_RETURN_TAG, ROM_SCOUT_PRIEST_REVIVAL_ENGRAVING_TAG, ROM_SCOUT_WARDEN_FOREST_GUIDANCE_TAG, romScoutRogueProcActionSpec, } from "./romScoutRogueOverrides"; describe("RoM Scout ranged weapon masteries", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); }); it.each(["bow", "crossbow"] as const)( "applies Ranged Weapon Mastery and Speed Shooting Mastery once with an equipped %s", (weaponType) => { const rangedWeapon = { instanceId: `scout-mastery-${weaponType}`, itemId: 1, itemKey: `scout-mastery-${weaponType}`, name: `Test ${weaponType}`, quality: "uncommon" as const, inventorySlot: "ranged", armorType: null, weaponType, itemLevel: 1, sourceItemLevel: 1, stats: EMPTY_ITEM_STATS, quantity: 1, sourceBossKey: "test", sourceBossName: "test", acquiredAt: 1, }; useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 50, inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } }, }); const state = useCombatStore.getState(); expect(state.equipment.player?.ranged).toBe(rangedWeapon.instanceId); const playerAuras = state.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID); const rangedDamageAuras = playerAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => ( modifier.kind === "damage" && modifier.direction === "dealt" && modifier.attackKind === "ranged" && modifier.operation === "percent" ))); expect(rangedDamageAuras).toHaveLength(1); expect(resolveDamageValue(100, playerAuras, { direction: "dealt", school: "physical", attackKind: "ranged" })) .toBeCloseTo(108); expect(resolveStatValue(0, playerAuras, "ranged-hit")).toBe(8); expect(resolveStatValue(0, playerAuras, "physical-hit")).toBe(0); expect(resolveStatValue(0, playerAuras, "ranged-haste")).toBe(11); const masteryDamageModifiers = abilityById("rom-scout-primary-2")!.effects.flatMap((effect) => ( effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [] )).filter((modifier) => modifier.kind === "damage" && modifier.direction === "dealt" && modifier.attackKind === "ranged" && modifier.operation === "percent"); const speedMasteryModifiers = abilityById("rom-scout-general-8")!.effects.flatMap((effect) => ( effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [] )).filter((modifier) => modifier.kind === "stat" && modifier.stat === "ranged-haste"); expect(masteryDamageModifiers).toHaveLength(1); expect(speedMasteryModifiers).toHaveLength(1); }, ); it("Cripple Joint roots on Joint Blow hits, not on other Scout attacks", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 50, }); for (const id of ["shot-target", "joint-target"]) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); setMobPosition(id, [3, 0, 0]); } // Make the proc deterministic here; its catalog regression separately checks the sourced 55% chance. useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.definition.utilityTags?.includes("rom-cripple-joint") ? { ...aura, definition: { ...aura.definition, procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })), }, } : aura), })); useCombatStore.getState().selectMob("shot-target"); expect(useCombatStore.getState().castAbility("rom-scout-general-0", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs["shot-target"]?.statuses.some((status) => status.kind === "root")).toBe(false); useCombatStore.getState().selectMob("joint-target"); expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 2_600)).toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs["joint-target"]?.statuses).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }), ])); }); it.each([ ["rom-scout", "rom-warrior", 25, "rom-enhanced-throat-attack"], ["rom-rogue", "rom-scout", 20, "rom-slit-throat"], ] as const)("applies the local four-second Throat Attack silence for %s/%s", ( classId, secondaryClassId, level, passiveTag, ) => { useCombatStore.getState().initializeCharacter({ classId, secondaryClassId, raceId: "rom-human", level, }); useCombatStore.getState().registerMob("throat-silence-target", { name: "Target", level: 1, maxHealth: 100_000 }); setMobPosition("throat-silence-target", [3, 0, 0]); useCombatStore.getState().selectMob("throat-silence-target"); const throatAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490428); expect(throatAttack).toBeDefined(); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(passiveTag))).toBe(true); expect(useCombatStore.getState().castAbility(throatAttack!.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true); expect(useCombatStore.getState().mobs["throat-silence-target"]?.statuses).toContainEqual( expect.objectContaining({ kind: "silence", endsAt: 5_000, sourceAbilityId: throatAttack!.id }), ); }); it("limits the paired Throat Attack silence proc to Throat Attack hits", () => { expect(romScoutRogueProcActionSpec("rom-enhanced-throat-attack", 490428, false)) .toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" }); expect(romScoutRogueProcActionSpec("rom-slit-throat", 490428, false)) .toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" }); expect(romScoutRogueProcActionSpec("rom-slit-throat", 490423, false)).toBeNull(); }); it("reduces Snipe's cast by one second only with Scout/Priest Sniper", () => { const bow = { instanceId: "scout-sniper-bow", itemId: 1, itemKey: "scout-sniper-bow", name: "Test bow", quality: "uncommon" as const, inventorySlot: "ranged", armorType: null, weaponType: "bow" as const, itemLevel: 1, sourceItemLevel: 1, stats: EMPTY_ITEM_STATS, quantity: 1, sourceBossKey: "test", sourceBossName: "test", acquiredAt: 1, }; const prepareSnipe = (secondaryClassId?: "rom-priest", targetId = "sniper-target") => { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", ...(secondaryClassId ? { secondaryClassId } : {}), raceId: "rom-human", level: 25, inventory: [bow], equipment: { player: { ranged: bow.instanceId } }, }); useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000 }); setMobPosition(targetId, [3, 0, 0]); useCombatStore.getState().selectMob(targetId); }; prepareSnipe("rom-priest"); expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true }); const withSniper = useCombatStore.getState().activeCast?.originalDurationMs; expect(withSniper).toBeGreaterThan(0); prepareSnipe(undefined, "sniper-base-target"); expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().activeCast?.originalDurationMs).toBe(withSniper! + 1_000); }); it("marks all sourced Scout projectile abilities as requiring a ranged weapon", () => { for (const [abilityId, sourceSpellId] of [ ["rom-scout-general-0", 490423], // Shot ["rom-scout-general-2", 491292], // Vampire Arrows ["rom-scout-general-3", 490420], // Joint Blow ["rom-scout-primary-1", 491128], // Wind Arrows ["rom-scout-primary-57", 492589], // Autoshot ] as const) { const ability = abilityById(abilityId); expect(ability?.dbcSpellId, abilityId).toBe(sourceSpellId); expect(romAbilityNeedsRangedWeapon(ability!), abilityId).toBe(true); } }); it("leaves Blood Arrow and Accurate Targeting outside the ranged weapon requirement", () => { const bloodArrow = abilitiesForCharacter("rom-scout", null).find((candidate) => candidate.dbcSpellId === 490434); const accurateTargeting = abilitiesForCharacter("rom-scout", "rom-warrior").find((candidate) => candidate.dbcSpellId === 493006); expect(bloodArrow).toBeDefined(); expect(accurateTargeting).toBeDefined(); expect(romAbilityNeedsRangedWeapon(bloodArrow!)).toBe(false); expect(romAbilityNeedsRangedWeapon(accurateTargeting!)).toBe(false); }); it("rejects Scout secondary projectile skills without a ranged weapon for the player", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, inventory: [], equipment: { player: {} }, }); useCombatStore.getState().registerMob("ranged-weapon-target", { name: "Target", maxHealth: 100_000 }); setMobPosition("ranged-weapon-target", [3, 0, 0]); useCombatStore.getState().selectMob("ranged-weapon-target"); useCombatStore.setState((state) => ({ resource: 1_000, maxResource: 1_000, resourcePools: { ...state.resourcePools, focus: 1_000 }, maxResourcePools: { ...state.maxResourcePools, focus: 1_000 }, })); const player = useCombatStore.getState(); expect(player.abilities.map((ability) => ability.id)).toEqual(expect.arrayContaining([ "rom-scout-general-0", "rom-scout-general-2", "rom-scout-general-3", ])); expect(resolvedEquipmentLoadout(player.equipment, player.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: player.classId, secondaryClassId: player.secondaryClassId, specializationId: player.specializationId, role: player.gearRole, level: player.level, }).ranged).toBeUndefined(); for (const abilityId of [ "rom-scout-general-0", // Shot (490423) "rom-scout-general-2", // Vampire Arrows (491292) "rom-scout-general-3", // Joint Blow (490420) ]) { expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000), abilityId) .toMatchObject({ ok: false, reason: "condition-not-met" }); } }); it("does not let a Scout secondary companion cast Shot without a ranged weapon", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 80 }); const player = useCombatStore.getState(); usePartyStore.getState().initializeParty("scout-companion-ranged-guard", 80, player.health, "damage"); const scout = createConfiguredPartyMember({ name: "Warrior Scout companion", classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", specialization: "Primary", role: "damage", }, 80, 0, "scout-companion-ranged-guard"); const shot = abilitiesForCharacter("rom-warrior", "rom-scout").find((ability) => ability.dbcSpellId === 490423); if (!shot) throw new Error("Missing Scout Shot"); const cooldowns = Object.fromEntries(abilitiesForCharacter("rom-warrior", "rom-scout").map((ability) => [ability.id, 60_000])); usePartyStore.setState({ members: [{ ...scout, resource: scout.maxResource, resourcePools: { ...scout.resourcePools, focus: scout.maxResource }, cooldowns: { ...cooldowns, [shot.id]: 0 }, }] }); const combat = useCombatStore.getState(); expect(resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: scout.id, ownerKind: "party", classId: scout.classId, secondaryClassId: scout.secondaryClassId, specializationId: scout.specializationId, role: scout.role, level: scout.level, }).ranged).toBeUndefined(); registerPartyRuntimePosition(scout.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob("scout-companion-target", { name: "Target", maxHealth: 100_000 }); setMobPosition("scout-companion-target", [3, 0, 0]); useCombatStore.getState().damageMob("scout-companion-target", 1, 100); useCombatStore.getState().selectMob("scout-companion-target"); updatePartyRuntimeTargetVisibility(scout.id, "scout-companion-target", true); advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === scout.id && event.abilityId === shot.id)).toBe(false); }); }); describe("RoM Scout paired passive source markers", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); }); it.each([ { secondaryClassId: "rom-warden" as const, sourceSkillId: 499575, triggerSkillId: 499576, name: "Forest Guidance", tag: ROM_SCOUT_WARDEN_FOREST_GUIDANCE_TAG, }, { secondaryClassId: "rom-mage" as const, sourceSkillId: 499548, triggerSkillId: 499549, name: "Wind Return", tag: ROM_SCOUT_MAGE_WIND_RETURN_TAG, }, { secondaryClassId: "rom-priest" as const, sourceSkillId: 499552, triggerSkillId: 490443, name: "Revival Engraving", tag: ROM_SCOUT_PRIEST_REVIVAL_ENGRAVING_TAG, }, ])("keeps $name source components off the owner until their trigger is supported", ({ secondaryClassId, sourceSkillId, triggerSkillId, name, tag, }) => { const buildAbilities = abilitiesForCharacter("rom-scout", secondaryClassId); const passive = buildAbilities.find((ability) => ability.dbcSpellId === sourceSkillId); expect(passive?.passive).toBe(true); expect(buildAbilities.some((ability) => ability.dbcSpellId === triggerSkillId)).toBe(false); const marker = passive?.effects.find((effect) => effect.kind === "apply-aura"); expect(marker?.kind).toBe("apply-aura"); if (marker?.kind !== "apply-aura") return; expect(marker.aura.utilityTags).toContain(tag); expect(marker.aura.modifiers ?? []).toEqual([]); expect(marker.aura.procs ?? []).toEqual([]); useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId, raceId: "rom-elf", level: 79, }); const ownerAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID); const activeMarker = ownerAuras.find((aura) => aura.definition.utilityTags?.includes(tag)); expect(activeMarker).toBeDefined(); expect(activeMarker?.definition.modifiers ?? []).toEqual([]); expect(ownerAuras.some((aura) => aura.definition.name.startsWith(name) && (aura.definition.modifiers?.length ?? 0) > 0)).toBe(false); }); it("limits suppression to each exact passive row and pair", () => { const translated = [{ kind: "apply-aura" as const, recipient: "caster" as const, aura: { id: "source-forest-guidance-test", name: "Forest Guidance source component", disposition: "buff" as const, durationMs: 10_000, modifiers: [{ kind: "damage" as const, direction: "taken" as const, school: "physical" as const, operation: "percent" as const, value: 0.02, }], }, sourceEffectType: -1, sourceAuraType: 143, miscValue: -2, triggerSpellId: 623117, }]; const baseRow = { name: "Forest Guidance", description: "A source component test row.", passive: true, }; for (const [sourceSkillId, secondaryClassId, triggerSkillId] of [ [499575, "rom-warden", 499576], [499548, "rom-mage", 499549], [499552, "rom-priest", 490443], ] as const) { expect(overrideRomScoutRogueEffects({ ...baseRow, sourceSkillId, classId: "rom-scout", secondaryClassId: "rom-rogue", }, translated)).toBeNull(); expect(overrideRomScoutRogueEffects({ ...baseRow, sourceSkillId: triggerSkillId, classId: "rom-scout", secondaryClassId, }, translated)).toBeNull(); } }); }); describe("RoM Warrior/Scout Calm", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); }); function prepareWarriorScoutTarget(targetId: string, targetLevel: number) { useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, }); useCombatStore.setState((state) => ({ resource: 1_000, maxResource: 1_000, resourcePools: { ...state.resourcePools, focus: 1_000, rage: 1_000 }, maxResourcePools: { ...state.maxResourcePools, focus: 1_000, rage: 1_000 }, })); useCombatStore.getState().registerMob(targetId, { name: targetId, level: targetLevel, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(targetId, [3, 0, 0]); useCombatStore.getState().selectMob(targetId); const skullBreaker = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491328); if (!skullBreaker) throw new Error("Missing Warrior/Scout Skull Breaker"); return skullBreaker; } function calmAura() { return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("rom-warrior-scout-calm")); } it("grants 20 physical hit for 15 seconds when Skull Breaker misses", () => { const skullBreaker = prepareWarriorScoutTarget("calm-miss-target", 255); const player = useCombatStore.getState(); const basePhysicalHit = resolveStatValue(0, player.auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit"); expect(player.abilities.some((ability) => ability.dbcSpellId === 498032 && ability.passive)).toBe(true); expect(player.castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false); const aura = calmAura(); expect(aura?.expiresAt).toBe(16_000); expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit")) .toBe(basePhysicalHit + 20); useCombatStore.getState().tick(0, 16_001); expect(calmAura()).toBeUndefined(); expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit")) .toBe(basePhysicalHit); }); it("does not grant Calm when Skull Breaker lands", () => { const skullBreaker = prepareWarriorScoutTarget("calm-landed-target", 1); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "calm-landed-target": { ...state.mobs["calm-landed-target"]!, characterDefender: { level: 1, armor: 0, dodgePercent: 0, parryPercent: 0, blockPercent: 0, blockValue: 0, }, }, }, })); expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true); expect(calmAura()).toBeUndefined(); }); it("does not grant Calm for another failed Warrior attack or without the Warrior/Scout pair", () => { const skullBreaker = prepareWarriorScoutTarget("calm-other-ability-target", 255); const probingAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491133); if (!probingAttack) throw new Error("Missing Probing Attack"); expect(useCombatStore.getState().castAbility(probingAttack.id, undefined, 3_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false); expect(calmAura()).toBeUndefined(); useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 80 }); useCombatStore.getState().registerMob("calm-wrong-pair-target", { name: "Target", maxHealth: 1_000_000 }); setMobPosition("calm-wrong-pair-target", [3, 0, 0]); useCombatStore.getState().selectMob("calm-wrong-pair-target"); expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000)) .toMatchObject({ ok: false, reason: "wrong-class" }); expect(calmAura()).toBeUndefined(); }); });