Files
healer-man/src/game/romScoutMechanics.test.ts
T

507 lines
22 KiB
TypeScript

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