Files
healer-man/src/game/enemyPeriodicDamage.integration.test.ts
2026-10-06 22:25:34 -04:00

359 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, resolveDamageValue, type ActiveAura } from "./combatAuras";
import { useCombatStore, type EnemyPeriodicCombatEffect } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import {
enemyPeriodicDamageSource,
enemyPeriodicDamageTickAmount,
} from "./enemyPeriodicDamage";
import {
applyEnemySourceAura,
enemySourceAuraContract,
supportedEnemySourceAuraDefinition,
} from "./enemySourceAuras";
import type { EnemyAbilityEffect, EnemyAttackDefinition } from "./enemyAttacks";
const stockade = requireDungeonDefinition("stormwind-stockade");
interface RendSource {
readonly serverEntry: number;
readonly level: number;
readonly damageMultiplier: number;
readonly attack: EnemyAttackDefinition;
}
function importedRend(spellId: 6547 | 16509): RendSource {
const entry = spellId === 6547 ? 1708 : 1707;
const combat = stockade.entities[`entry-${entry}`]?.combat;
const attack = combat?.attacks.find((candidate) => candidate.spellId === spellId);
if (!combat || !attack) throw new Error(`Stockade entry ${entry} is missing sourced Rend ${spellId}.`);
return {
serverEntry: combat.serverEntry ?? entry,
level: combat.level ?? 20,
damageMultiplier: combat.damageMultiplier ?? 1,
attack,
};
}
const rend6547 = importedRend(6547);
const rend16509 = importedRend(16509);
function attackEffects(attack: EnemyAttackDefinition): readonly Exclude<EnemyAbilityEffect, { kind: "composite" }>[] {
if (!attack.effect) throw new Error(`Stockade Rend ${attack.spellId} has no periodic effect.`);
return attack.effect.kind === "composite" ? attack.effect.effects : [attack.effect];
}
function sourcedDot(attack: EnemyAttackDefinition) {
const dots = attackEffects(attack).filter((effect) => effect.kind === "dot");
expect(dots).toHaveLength(1);
if (dots[0]?.kind !== "dot" || !dots[0].wowPeriodicSource) {
throw new Error(`Stockade Rend ${attack.spellId} is missing its sourced periodic identity.`);
}
return dots[0];
}
function initDefenders(statRuleset: "wow" | "rom", targetKind: "player" | "party") {
useCombatStore.getState().initializeCharacter(statRuleset === "rom"
? { classId: "rom-priest", secondaryClassId: "rom-knight", level: 30, xp: 0, statRuleset: "rom" }
: { classId: "warrior", level: 30, xp: 0, statRuleset: "wow" });
expect(useCombatStore.getState().statRuleset).toBe(statRuleset);
useCombatStore.getState().setPlayerPosition(targetKind === "player" ? [0, 0, 0] : [40, 0, 0]);
usePartyStore.getState().initializeParty("stockade-rend-periodic", 30,
useCombatStore.getState().health, "damage", 5, statRuleset);
const configuredMembers = usePartyStore.getState().members.map((partyMember, index) => createConfiguredPartyMember({
name: partyMember.name,
classId: partyMember.classId,
secondaryClassId: partyMember.secondaryClassId,
raceId: partyMember.raceId,
statRuleset,
specialization: partyMember.specialization,
specializationId: partyMember.specializationId,
talentTreeId: partyMember.talentTreeId,
role: partyMember.role,
}, 30, index, "stockade-rend-periodic"));
usePartyStore.setState({ members: configuredMembers });
const member = configuredMembers[0];
if (!member) throw new Error("Expected a Stockade Rend party defender.");
usePartyStore.setState((state) => ({
members: state.members.map((partyMember) => ({
...partyMember,
// Verify the bleed's armor exception against a defender with substantial armor.
gearStats: { ...partyMember.gearStats, armor: 50_000 },
})),
}));
expect(usePartyStore.getState().members.every((partyMember) => partyMember.statRuleset === statRuleset)).toBe(true);
usePartyStore.getState().members.forEach((partyMember, index) => {
registerPartyRuntimePosition(partyMember.id, targetKind === "party" && index === 0
? [2, 0, 0]
: [50 + index * 3, 0, 0]);
});
if (targetKind === "player") {
useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, armor: 50_000 } }));
}
useCombatStore.getState().synchronizeActors();
return member.id;
}
function registerRendCaster(
source: RendSource,
id: string,
targetActorId: string,
stopAfterApplication = true,
enragedAtApplication = false,
): number {
useCombatStore.getState().registerMob(id, {
name: source.attack.name,
serverEntry: source.serverEntry,
level: source.level,
damageMultiplier: source.damageMultiplier,
maxHealth: 10_000,
xpReward: 0,
attacks: [{ ...source.attack, castTimeMs: 0, initialCooldownMs: [0, 0], repeatCooldownMs: [1, 1] }],
});
setMobPosition(id, [targetActorId === PLAYER_AGGRO_ID ? 2 : 0, 0, 0]);
if (enragedAtApplication) {
const enrage = sourcedEnrageAura(id, 500);
useCombatStore.setState((state) => ({ auras: [...state.auras, enrage] }));
}
useCombatStore.getState().selectMob(id);
const sourceActor = targetActorId === PLAYER_AGGRO_ID
? undefined
: { actorId: targetActorId, role: "damage" as const, abilityId: "stockade-rend-fixture" };
useCombatStore.getState().damageMob(id, 1, 1_000, sourceActor);
let periodic: EnemyPeriodicCombatEffect | undefined;
let appliedAt = 0;
for (let now = 1_000; now < 2_500; now += 1) {
useCombatStore.getState().advanceMobCombat(now);
periodic = useCombatStore.getState().enemyPeriodicEffects.find((entry) => (
entry.sourceMob.id === id && entry.targetActorId === targetActorId
));
if (periodic) {
appliedAt = periodic.nextTickAt - periodic.intervalMs;
break;
}
}
if (!periodic) throw new Error(`Stockade Rend ${source.attack.spellId} did not land for ${id} within the bounded deterministic fixture.`);
if (stopAfterApplication) {
useCombatStore.setState((state) => {
const mob = state.mobs[id];
return mob ? { mobs: { ...state.mobs, [id]: {
...mob,
abilityReadyAt: { ...mob.abilityReadyAt, [source.attack.id]: Number.POSITIVE_INFINITY },
nextAttackAt: Number.POSITIVE_INFINITY,
} } } : state;
});
}
return appliedAt;
}
function enemyDamageEvents(sourceId: string, targetId: string) {
return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.sourceActorId === sourceId && event.targetActorId === targetId);
}
function expectedTick(source: RendSource): number {
const dot = sourcedDot(source.attack);
const identity = dot.wowPeriodicSource!;
const descriptor = enemyPeriodicDamageSource(identity.spellId, identity.sourceProfile);
expect(descriptor).toBeDefined();
const amount = enemyPeriodicDamageTickAmount(source.serverEntry, source.level, descriptor!);
expect(amount.status).toBe("resolved");
if (amount.status !== "resolved") throw new Error(`Stockade Rend source amount is unresolved: ${amount.reason}.`);
return Math.round(amount.tickAmount * source.damageMultiplier);
}
function sourcedEnrageAura(casterId: string, appliedAt: number): ActiveAura {
const contract = enemySourceAuraContract(8599, "ascension");
if (!contract) throw new Error("The pinned Ascension Enrage source contract is missing.");
const definition = supportedEnemySourceAuraDefinition(contract);
if (!definition) throw new Error("The pinned Ascension Enrage modifier is unsupported.");
const aura = applyEnemySourceAura([], definition, casterId, casterId, appliedAt)[0];
if (!aura) throw new Error("The sourced Enrage aura could not be created.");
return aura;
}
function enrageAdjustedTick(source: RendSource, casterId: string, appliedAt: number): number {
return Math.round(resolveDamageValue(expectedTick(source), [sourcedEnrageAura(casterId, appliedAt)], {
direction: "dealt",
school: "physical",
attackKind: "melee",
}));
}
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ mobs: {}, enemyPeriodicEffects: [], auras: [], actors: {}, combatEvents: [] });
});
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
describe("source-backed Stockade Rend in combat", () => {
it.each([
[rend6547, "player", "wow"], [rend6547, "player", "rom"],
[rend6547, "party", "wow"], [rend6547, "party", "rom"],
[rend16509, "player", "wow"], [rend16509, "player", "rom"],
[rend16509, "party", "wow"], [rend16509, "party", "rom"],
] as const)("resolves sourced Stockade Rend against player and NPC defenders in both rulesets", (source, targetKind, statRuleset) => {
const partyId = initDefenders(statRuleset, targetKind);
const targetActorId = targetKind === "player" ? PLAYER_AGGRO_ID : partyId;
const casterId = `stockade-rend-${source.attack.spellId}-${targetKind}-${statRuleset}`;
const dot = sourcedDot(source.attack);
expect(dot.wowPeriodicSource).toEqual({
spellId: source.attack.spellId,
sourceProfile: "ascension-patch-d",
});
expect(attackEffects(source.attack).map((effect) => effect.kind)).toEqual(["dot"]);
registerRendCaster(source, casterId, targetActorId);
const effect = useCombatStore.getState().enemyPeriodicEffects.find((entry) => (
entry.sourceMob.id === casterId && entry.targetActorId === targetActorId
));
expect(effect).toBeDefined();
expect(effect).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 });
const firstTickAt = effect!.nextTickAt;
expect(enemyDamageEvents(casterId, targetActorId)).toEqual([]);
for (let tick = 1; tick <= 5; tick += 1) {
useCombatStore.getState().advanceMobCombat(firstTickAt + (tick - 1) * 3_000);
}
const ticks = enemyDamageEvents(casterId, targetActorId);
expect(ticks.map((event) => event.occurredAt)).toEqual(Array.from({ length: 5 }, (_, index) => firstTickAt + index * 3_000));
expect(ticks.map((event) => event.rawAmount)).toEqual(Array(5).fill(expectedTick(source)));
expect(ticks.every((event) => event.effectiveAmount === event.rawAmount)).toBe(true);
});
it("same-caster refresh restarts one Rend and preserves five full ticks", () => {
initDefenders("wow", "player");
const source = rend6547;
const casterId = "stockade-rend-refresh";
const firstApplicationAt = registerRendCaster(source, casterId, PLAYER_AGGRO_ID, false);
const firstEffect = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId);
expect(firstEffect).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 });
const initialNextTickAt = firstEffect!.nextTickAt;
let refreshAt: number | undefined;
for (let now = firstApplicationAt + 1; now < initialNextTickAt; now += 1) {
useCombatStore.getState().advanceMobCombat(now);
const current = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId);
if (current && current.nextTickAt > initialNextTickAt) {
refreshAt = now;
break;
}
}
expect(refreshAt).toBeDefined();
const refreshed = useCombatStore.getState().enemyPeriodicEffects.filter((entry) => entry.sourceMob.id === casterId);
expect(refreshed).toHaveLength(1);
expect(refreshed[0]).toMatchObject({ remainingTicks: 5, intervalMs: 3_000, nextTickAt: refreshAt! + 3_000 });
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toEqual([]);
for (let tick = 0; tick < 5; tick += 1) {
useCombatStore.getState().advanceMobCombat(refreshed[0]!.nextTickAt + tick * 3_000);
}
const ticks = enemyDamageEvents(casterId, PLAYER_AGGRO_ID);
expect(ticks).toHaveLength(5);
expect(ticks.map((event) => event.occurredAt)).toEqual(Array.from({ length: 5 }, (_, index) => refreshAt! + 3_000 + index * 3_000));
});
it("keeps different casters' Rend effects independent on the same defender", () => {
initDefenders("wow", "player");
registerRendCaster(rend6547, "stockade-rend-caster-a", PLAYER_AGGRO_ID);
registerRendCaster(rend16509, "stockade-rend-caster-b", PLAYER_AGGRO_ID);
const effects = useCombatStore.getState().enemyPeriodicEffects.filter((entry) => entry.targetActorId === PLAYER_AGGRO_ID);
expect(effects).toHaveLength(2);
expect(new Set(effects.map((entry) => entry.sourceMob.id))).toEqual(new Set([
"stockade-rend-caster-a", "stockade-rend-caster-b",
]));
});
it("snapshots the sourced outgoing bonus when Rend is applied, even after Enrage expires", () => {
initDefenders("wow", "player");
const casterId = "stockade-rend-enrage-snapshot";
const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID, true, true);
const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt;
const expected = enrageAdjustedTick(rend6547, casterId, appliedAt);
expect(expected).toBeGreaterThan(expectedTick(rend6547));
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.sourceId === casterId && aura.definition.id === "enemy-source-spell-8599"
? { ...aura, expiresAt: firstTickAt - 1 }
: aura),
}));
useCombatStore.getState().advanceMobCombat(firstTickAt);
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toMatchObject([
{ rawAmount: expected, effectiveAmount: expected },
]);
});
it("does not add a later Enrage bonus to an already applied Rend", () => {
initDefenders("wow", "player");
const casterId = "stockade-rend-enrage-late";
const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID);
const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt;
const enrage = sourcedEnrageAura(casterId, appliedAt + 1);
useCombatStore.setState((state) => ({ auras: [...state.auras, enrage] }));
useCombatStore.getState().advanceMobCombat(firstTickAt);
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toMatchObject([
{ rawAmount: expectedTick(rend6547), effectiveAmount: expectedTick(rend6547) },
]);
});
it("keeps target-side damage-taken modifiers dynamic across Rend ticks", () => {
initDefenders("wow", "player");
const casterId = "stockade-rend-dynamic-target-defense";
const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID);
const firstTickAt = useCombatStore.getState().enemyPeriodicEffects.find((entry) => entry.sourceMob.id === casterId)!.nextTickAt;
const defendedAuras = applyAura(useCombatStore.getState().auras, {
definition: {
id: "stockade-rend-target-defense",
name: "Temporary Rend defense",
disposition: "buff",
durationMs: 2_000,
modifiers: [{ kind: "damage", direction: "taken", school: "physical", attackKind: "melee",
operation: "percent", value: -0.5 }],
},
sourceId: "stockade-rend-target-defense-source",
targetId: PLAYER_AGGRO_ID,
now: firstTickAt - 1_000,
}).auras;
useCombatStore.setState({ auras: defendedAuras });
useCombatStore.getState().advanceMobCombat(firstTickAt);
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => aura.definition.id !== "stockade-rend-target-defense"),
}));
useCombatStore.getState().advanceMobCombat(firstTickAt + 3_000);
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID).map((event) => event.effectiveAmount)).toEqual([
Math.round(expectedTick(rend6547) * 0.5),
expectedTick(rend6547),
]);
expect(appliedAt).toBeLessThan(firstTickAt);
});
it("does not let a magic dispel remove Rend or suppress its final scheduled tick", () => {
initDefenders("wow", "player");
const casterId = "stockade-rend-undispellable";
const appliedAt = registerRendCaster(rend6547, casterId, PLAYER_AGGRO_ID);
const aura = useCombatStore.getState().auras.find((entry) => entry.sourceId === casterId
&& entry.targetId === PLAYER_AGGRO_ID);
expect(aura).toBeDefined();
expect(aura?.definition.dispelCategory).toBeUndefined();
expect(useCombatStore.getState().dispelActor(PLAYER_AGGRO_ID, ["magic"], 1, appliedAt + 1)).toBe(0);
expect(useCombatStore.getState().enemyPeriodicEffects.some((entry) => entry.sourceMob.id === casterId)).toBe(true);
for (let tick = 1; tick <= 5; tick += 1) {
useCombatStore.getState().advanceMobCombat(appliedAt + tick * 3_000);
}
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID)).toHaveLength(5);
expect(enemyDamageEvents(casterId, PLAYER_AGGRO_ID).at(-1)?.occurredAt).toBe(appliedAt + 15_000);
});
});