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

317 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
advanceDungeonPartyCombat,
resetPartyRuntimeClock,
} from "./partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import type { DamageSchool } from "./combatAuras";
import {
ROM_SCOUT_PRIEST_DRUMS_OF_WAR,
ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA,
} from "./romScoutPriestOverrides";
const TARGET_ID = "drums-of-war-target";
function initializeScoutPriest(): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
secondaryClassId: "rom-priest",
raceId: "rom-human",
level: 80,
});
}
function registerTarget(): void {
useCombatStore.getState().registerMob(TARGET_ID, {
name: TARGET_ID,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(TARGET_ID, [3, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
}
function shot() {
const ability = useCombatStore.getState().abilities.find((candidate) => (
candidate.source === "runewaker" && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId
));
if (!ability) throw new Error("RuneWaker Shot (490423) is missing from Scout/Priest abilities.");
return ability;
}
function landPlayerShot(now: number, seed: string) {
const ability = shot();
return useCombatStore.getState().damageMob(
TARGET_ID,
100,
now,
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: ability.id },
"physical",
{
attackKind: "ranged",
abilityId: ability.id,
seed,
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
},
);
}
function directHit(
actorId: string,
now: number,
school: DamageSchool,
attackKind: "melee" | "ranged" | "spell",
seed: string,
options: { readonly forceCrit?: boolean; readonly canCrit?: boolean } = {},
) {
const abilityId = `drums-direct-hit:${attackKind}:${school}`;
return useCombatStore.getState().damageMob(
TARGET_ID,
1_000,
now,
{ actorId, role: "damage", abilityId },
school,
{
attackKind,
abilityId,
seed,
criticalChanceBonusPercent: options.forceCrit ? 100 : 0,
rules: {
canMiss: false,
canDodge: false,
canParry: false,
canBlock: false,
canCrit: options.canCrit,
},
},
);
}
function activeDrumsAura(actorId: string, now: number, sourceId = PLAYER_AGGRO_ID) {
return useCombatStore.getState().auras.find((aura) => (
aura.targetId === actorId
&& aura.sourceId === sourceId
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id
&& (aura.expiresAt === null || aura.expiresAt > now)
));
}
describe("RoM Scout/Priest Drums of War integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
initializeScoutPriest();
});
it("applies CoA critical damage after a landed Shot and waits 15 seconds after expiry", () => {
registerTarget();
const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 493010);
expect(passive).toMatchObject({ classId: "rom-scout", secondaryClassId: "rom-priest", passive: true });
const first = landPlayerShot(1_000, "drums-player-first");
expect(first.combatResult?.landed).toBe(true);
expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toMatchObject({
expiresAt: 16_000,
definition: {
modifiers: [
{ kind: "stat", stat: "critical-damage", operation: "percent", value: 0.2 },
],
},
});
expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${PLAYER_AGGRO_ID}`])
.toBe(31_000);
landPlayerShot(5_000, "drums-player-on-icd");
expect(activeDrumsAura(PLAYER_AGGRO_ID, 5_000)?.expiresAt).toBe(16_000);
landPlayerShot(16_000, "drums-player-lockout-after-expiry");
expect(activeDrumsAura(PLAYER_AGGRO_ID, 16_000)).toBeUndefined();
landPlayerShot(30_999, "drums-player-last-lockout-ms");
expect(activeDrumsAura(PLAYER_AGGRO_ID, 30_999)).toBeUndefined();
landPlayerShot(31_000, "drums-player-ready-again");
expect(activeDrumsAura(PLAYER_AGGRO_ID, 31_000)?.expiresAt).toBe(46_000);
});
it("increases player physical and magical critical damage without changing crit chance or ordinary hits", () => {
registerTarget();
const baselinePhysicalChance = directHit(
PLAYER_AGGRO_ID, 500, "physical", "ranged", "drums-player-physical-chance-before",
);
const baselineMagicChance = directHit(
PLAYER_AGGRO_ID, 600, "fire", "spell", "drums-player-magic-chance-before",
);
const baselinePhysicalCrit = directHit(
PLAYER_AGGRO_ID, 700, "physical", "ranged", "drums-player-physical-crit-before", { forceCrit: true },
);
const baselineMagicalCrit = directHit(
PLAYER_AGGRO_ID, 800, "fire", "spell", "drums-player-magic-crit-before", { forceCrit: true },
);
expect(baselinePhysicalCrit.combatResult?.critical).toBe(true);
expect(baselineMagicalCrit.combatResult?.critical).toBe(true);
landPlayerShot(1_000, "drums-player-crit-buff");
const buffedPhysicalChance = directHit(
PLAYER_AGGRO_ID, 2_000, "physical", "ranged", "drums-player-physical-chance-after",
);
const buffedMagicChance = directHit(
PLAYER_AGGRO_ID, 2_100, "fire", "spell", "drums-player-magic-chance-after",
);
expect(buffedPhysicalChance.combatResult?.attackTable.criticalPercent)
.toBe(baselinePhysicalChance.combatResult?.attackTable.criticalPercent);
expect(buffedMagicChance.combatResult?.attackTable.criticalPercent)
.toBe(baselineMagicChance.combatResult?.attackTable.criticalPercent);
const physicalCrit = directHit(
PLAYER_AGGRO_ID, 2_200, "physical", "ranged", "drums-player-physical-crit-after", { forceCrit: true },
);
const magicalCrit = directHit(
PLAYER_AGGRO_ID, 2_300, "fire", "spell", "drums-player-magic-crit-after", { forceCrit: true },
);
expect(physicalCrit.combatResult?.critical).toBe(true);
expect(magicalCrit.combatResult?.critical).toBe(true);
expect(physicalCrit.combatResult?.damageBeforeMitigation)
.toBe(Math.round((baselinePhysicalCrit.combatResult?.damageBeforeMitigation ?? 0) * 1.1));
expect(magicalCrit.combatResult?.damageBeforeMitigation)
.toBe(Math.round((baselineMagicalCrit.combatResult?.damageBeforeMitigation ?? 0) * 1.1));
const ordinaryHit = directHit(
PLAYER_AGGRO_ID, 2_400, "fire", "spell", "drums-player-ordinary-hit", { canCrit: false },
);
expect(ordinaryHit.combatResult?.critical).toBe(false);
expect(ordinaryHit.combatResult?.damageBeforeMitigation).toBe(1_000);
});
it("applies only to living party members within 50 yards of the caster", () => {
const near = createConfiguredPartyMember({
name: "Near Ally",
classId: "priest",
specialization: "Discipline",
role: "healer",
}, 80, 0, "drums-near-party-member");
const far = createConfiguredPartyMember({
name: "Far Ally",
classId: "warrior",
specialization: "Arms",
role: "damage",
}, 80, 1, "drums-far-party-member");
usePartyStore.setState({ members: [near, far] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(near.id, [40, 0, 0]);
registerPartyRuntimePosition(far.id, [51, 0, 0]);
registerTarget();
const nearCritBefore = directHit(
near.id, 500, "fire", "spell", "drums-near-ally-crit-before", { forceCrit: true },
);
expect(nearCritBefore.combatResult?.critical).toBe(true);
landPlayerShot(1_000, "drums-player-party-radius");
expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === near.id
&& aura.sourceId === PLAYER_AGGRO_ID
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === far.id
&& aura.sourceId === PLAYER_AGGRO_ID
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(false);
const nearCritAfter = directHit(
near.id, 2_000, "fire", "spell", "drums-near-ally-crit-after", { forceCrit: true },
);
expect(nearCritAfter.combatResult?.critical).toBe(true);
expect(nearCritAfter.combatResult?.damageBeforeMitigation)
.toBe(Math.round((nearCritBefore.combatResult?.damageBeforeMitigation ?? 0) * 1.1));
});
it("passes party Shot through the ranged hit-resolution path before proc application", () => {
registerTarget();
const playerHit = landPlayerShot(1_000, "drums-player-independent-icd");
expect(playerHit.combatResult?.landed).toBe(true);
const seed = "drums-party-scout";
const member = createConfiguredPartyMember({
name: "Scout Priest",
classId: "rom-scout",
secondaryClassId: "rom-priest",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
const partyShot = abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId);
if (!partyShot) throw new Error("RuneWaker Shot (490423) is missing from party Scout/Priest abilities.");
const passive = abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId);
if (!passive) throw new Error("Drums of War (493010) is missing from party Scout/Priest abilities.");
expect(abilityById(partyShot.id)?.dbcSpellId).toBe(490_423);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.getState().setCommand("attack");
usePartyStore.setState({ members: [{
...member,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...member.resourcePools, "nature-power": 10_000, mana: 10_000 },
activeCast: {
abilityId: partyShot.id,
school: "physical",
mode: "cast",
startedAt: 500,
completesAt: 3_500,
originalDurationMs: 3_000,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: TARGET_ID,
},
activeCastTargetId: TARGET_ID,
status: "attacking",
}] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
registerTarget();
const companionCritBefore = directHit(
member.id, 2_000, "physical", "ranged", "drums-companion-crit-before", { forceCrit: true },
);
expect(companionCritBefore.combatResult?.critical).toBe(true);
useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400);
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(3_500);
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === member.id
&& aura.targetId === member.id
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id
&& aura.expiresAt === 18_500)).toBe(true);
expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${member.id}`])
.toBe(33_500);
expect(activeDrumsAura(PLAYER_AGGRO_ID, 3_500)?.expiresAt).toBe(16_000);
const companionAuras = useCombatStore.getState().auras;
useCombatStore.setState({
auras: companionAuras.filter((aura) => !(aura.targetId === member.id
&& aura.sourceId === member.id
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)),
});
const companionCritWithoutDrums = directHit(
member.id, 4_000, "physical", "ranged", "drums-companion-crit-without-aura", { forceCrit: true },
);
useCombatStore.setState({ auras: companionAuras });
const companionCritAfter = directHit(
member.id, 4_100, "physical", "ranged", "drums-companion-crit-after", { forceCrit: true },
);
expect(companionCritAfter.combatResult?.critical).toBe(true);
expect(companionCritAfter.combatResult?.damageBeforeMitigation)
.toBe(Math.round((companionCritWithoutDrums.combatResult?.damageBeforeMitigation ?? 0) * 1.1));
});
});