forked from phenom/healer-man
359 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|