321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { applyAura } from "./combatAuras";
|
|
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
|
|
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
|
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
|
|
const SOURCE_START_AT = 1_000;
|
|
const SOURCE_ID = "stockade-infected-wound-caster";
|
|
const TARGET_AURA_ID = "enemy-source-spell-17230";
|
|
|
|
const stockadeWound = requireDungeonDefinition("stormwind-stockade").entities["entry-1707"]?.combat;
|
|
const importedWound = stockadeWound?.attacks.find((attack) => attack.spellId === 17_230);
|
|
if (!stockadeWound || !importedWound) {
|
|
throw new Error("Stockade entry 1707 is missing imported Infected Wound 17230.");
|
|
}
|
|
|
|
type DamageCase = "normal" | "direct-physical" | "periodic-physical" | "direct-nature" | "direct-magic";
|
|
|
|
interface ScenarioResult {
|
|
readonly applicationAt?: number;
|
|
readonly event: ReturnType<typeof damageEvents>[number] | undefined;
|
|
readonly combatResult: ReturnType<typeof useCombatStore.getState>["lastCombatResult"];
|
|
}
|
|
|
|
function initCombat(): void {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.setState({
|
|
mobs: {},
|
|
auras: [],
|
|
actors: {},
|
|
enemyPeriodicEffects: [],
|
|
combatEvents: [],
|
|
effects: [],
|
|
lastCombatResult: null,
|
|
});
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "priest",
|
|
level: 30,
|
|
});
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty(
|
|
"stockade-infected-wound", 30, useCombatStore.getState().health, "damage", 1, "wow",
|
|
);
|
|
useCombatStore.getState().synchronizeActors();
|
|
}
|
|
|
|
function registerMob(id: string, attacks: readonly EnemyAttackDefinition[], serverEntry?: number): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
...(serverEntry === undefined ? {} : { serverEntry }),
|
|
level: 30,
|
|
maxHealth: 50_000,
|
|
xpReward: 0,
|
|
attacks,
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
}
|
|
|
|
function engageMob(id: string, now: number): void {
|
|
useCombatStore.getState().selectMob(id);
|
|
useCombatStore.getState().damageMob(id, 1, now);
|
|
}
|
|
|
|
function sourceWoundAttack(): EnemyAttackDefinition {
|
|
return {
|
|
...importedWound!,
|
|
castTimeMs: 0,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [300_000, 300_000],
|
|
maximumCasts: 1,
|
|
};
|
|
}
|
|
|
|
function applyImportedWoundAndRemoveCaster(cleanup: "remove" | "kill" = "remove"): number {
|
|
expect(importedWound).toMatchObject({
|
|
spellId: 17_230,
|
|
effect: {
|
|
kind: "target-aura",
|
|
wowAuraSource: { spellId: 17_230, sourceProfile: "ascension" },
|
|
},
|
|
});
|
|
registerMob(SOURCE_ID, [sourceWoundAttack()], stockadeWound!.serverEntry);
|
|
engageMob(SOURCE_ID, SOURCE_START_AT);
|
|
useCombatStore.getState().advanceMobCombat(SOURCE_START_AT);
|
|
const applicationAt = SOURCE_START_AT + 1;
|
|
useCombatStore.getState().advanceMobCombat(applicationAt);
|
|
|
|
const wound = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.id === TARGET_AURA_ID && aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
));
|
|
expect(wound).toBeDefined();
|
|
expect(wound).toMatchObject({
|
|
sourceId: SOURCE_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
appliedAt: expect.any(Number),
|
|
expiresAt: expect.any(Number),
|
|
definition: {
|
|
name: "Infected Wound",
|
|
disposition: "debuff",
|
|
dispelCategory: "disease",
|
|
durationMs: 300_000,
|
|
},
|
|
});
|
|
expect(wound!.expiresAt! - wound!.appliedAt).toBe(300_000);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.sourceActorId === SOURCE_ID
|
|
))).toEqual([]);
|
|
|
|
// A sourced timer belongs to the target. Removing the caster must leave the
|
|
// target's five-minute disease active until a cleanse or its own expiry.
|
|
if (cleanup === "kill") {
|
|
const killed = useCombatStore.getState().damageMob(SOURCE_ID, 100_000, applicationAt + 1);
|
|
expect(killed.killed).toBe(true);
|
|
} else {
|
|
useCombatStore.getState().removeMob(SOURCE_ID);
|
|
}
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.id === TARGET_AURA_ID && aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
))).toBe(true);
|
|
return wound!.appliedAt;
|
|
}
|
|
|
|
function attackForCase(scenario: DamageCase): EnemyAttackDefinition {
|
|
const common: EnemyAttackDefinition = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: `infected-wound-${scenario}`,
|
|
name: `Fixture ${scenario}`,
|
|
range: 5,
|
|
// Deterministic fixture cadence; source spell 17230 itself is still the
|
|
// unchanged imported attack exercised in applyImportedWoundAndRemoveCaster.
|
|
castTimeMs: 0,
|
|
cooldownMs: 10_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [10_000, 10_000],
|
|
maximumCasts: 1,
|
|
};
|
|
if (scenario === "normal") return { ...common, attackKind: "melee", animation: "attack" };
|
|
if (scenario === "periodic-physical") {
|
|
return {
|
|
...common,
|
|
attackKind: "spell",
|
|
school: "physical",
|
|
delivery: "projectile",
|
|
animation: "cast",
|
|
effect: { kind: "dot", multiplier: 1, ticks: 1, intervalMs: 1_000 },
|
|
};
|
|
}
|
|
return {
|
|
...common,
|
|
attackKind: "spell",
|
|
school: scenario === "direct-nature" ? "nature" : scenario === "direct-magic" ? "fire" : "physical",
|
|
delivery: "projectile",
|
|
animation: "cast",
|
|
effect: { kind: "damage", multiplier: 1 },
|
|
};
|
|
}
|
|
|
|
function damageEvents(sourceId: string) {
|
|
return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
|
&& event.sourceActorId === sourceId && event.targetActorId === PLAYER_AURA_ENTITY_ID);
|
|
}
|
|
|
|
function runDamageScenario(scenario: DamageCase, applyWound: boolean): ScenarioResult {
|
|
initCombat();
|
|
const applicationAt = applyWound ? applyImportedWoundAndRemoveCaster() : undefined;
|
|
const attackerId = "infected-wound-damage-fixture";
|
|
registerMob(attackerId, [attackForCase(scenario)]);
|
|
const attackAt = 5_000;
|
|
engageMob(attackerId, attackAt);
|
|
useCombatStore.getState().advanceMobCombat(attackAt);
|
|
useCombatStore.getState().advanceMobCombat(attackAt + 1);
|
|
if (scenario === "periodic-physical") {
|
|
useCombatStore.getState().advanceMobCombat(attackAt + 1_001);
|
|
}
|
|
return {
|
|
applicationAt,
|
|
event: damageEvents(attackerId).at(-1),
|
|
combatResult: useCombatStore.getState().lastCombatResult,
|
|
};
|
|
}
|
|
|
|
function cureDiseaseAbility() {
|
|
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === "Cure Disease");
|
|
if (!ability) throw new Error("Level-30 Priest catalog is missing Cure Disease.");
|
|
return ability;
|
|
}
|
|
|
|
beforeEach(initCombat);
|
|
|
|
describe("imported Stockade Infected Wound 17230", () => {
|
|
it.each(["remove", "kill"] as const)(
|
|
"applies its sourced disease without a hit and survives caster %s",
|
|
(cleanup) => {
|
|
const playerHealth = useCombatStore.getState().health;
|
|
const appliedAt = applyImportedWoundAndRemoveCaster(cleanup);
|
|
const wound = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.id === TARGET_AURA_ID && aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
));
|
|
expect(wound?.appliedAt).toBeGreaterThanOrEqual(SOURCE_START_AT);
|
|
expect(wound?.appliedAt).toBeLessThanOrEqual(appliedAt);
|
|
expect(useCombatStore.getState().health).toBe(playerHealth);
|
|
if (cleanup === "remove") expect(useCombatStore.getState().mobs[SOURCE_ID]).toBeUndefined();
|
|
else expect(useCombatStore.getState().mobs[SOURCE_ID]?.dead).toBe(true);
|
|
expect(damageEvents(SOURCE_ID)).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it.each(["normal", "direct-physical", "periodic-physical"] as const)(
|
|
"adds exactly six physical damage before mitigation to %s attacks",
|
|
(scenario) => {
|
|
const baseline = runDamageScenario(scenario, false);
|
|
const infected = runDamageScenario(scenario, true);
|
|
expect(baseline.event).toBeDefined();
|
|
expect(infected.event).toBeDefined();
|
|
expect(infected.event!.rawAmount - baseline.event!.rawAmount).toBe(6);
|
|
expect(infected.event!.school).toBe("physical");
|
|
expect(infected.combatResult?.rawDamage).toBe(infected.event!.rawAmount);
|
|
expect(infected.combatResult?.damageBeforeMitigation).toBeGreaterThanOrEqual(infected.event!.rawAmount);
|
|
},
|
|
);
|
|
|
|
it.each(["direct-nature", "direct-magic"] as const)(
|
|
"does not change %s damage",
|
|
(scenario) => {
|
|
const baseline = runDamageScenario(scenario, false);
|
|
const infected = runDamageScenario(scenario, true);
|
|
expect(baseline.event).toBeDefined();
|
|
expect(infected.event).toBeDefined();
|
|
expect(infected.event!.school).toBe(scenario === "direct-nature" ? "nature" : "fire");
|
|
expect(infected.event!.rawAmount).toBe(baseline.event!.rawAmount);
|
|
},
|
|
);
|
|
|
|
it("places the physical bonus ahead of armor and block, with the remaining packet then absorbed", () => {
|
|
initCombat();
|
|
applyImportedWoundAndRemoveCaster();
|
|
useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, armor: 50_000 } }));
|
|
const guard = applyAura(useCombatStore.getState().auras, {
|
|
definition: {
|
|
id: "fixture:infected-wound-block-and-absorb",
|
|
name: "Test-only block and absorb",
|
|
disposition: "buff",
|
|
durationMs: 30_000,
|
|
modifiers: [
|
|
{ kind: "stat", stat: "block", operation: "flat", value: 100, perStack: false },
|
|
{ kind: "stat", stat: "block-value", operation: "flat", value: 8, perStack: false },
|
|
],
|
|
absorbs: [{ id: "test-only-absorb", amount: 1_000, school: "physical" }],
|
|
},
|
|
sourceId: "fixture:infected-wound-block-and-absorb",
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now: 4_000,
|
|
}).auras;
|
|
useCombatStore.setState({ auras: [...guard] });
|
|
|
|
const attack = {
|
|
...attackForCase("normal"),
|
|
id: "infected-wound-blocked-normal",
|
|
repeatCooldownMs: [1, 1] as const,
|
|
maximumCasts: 100,
|
|
};
|
|
const attackerId = "infected-wound-blocked-attacker";
|
|
registerMob(attackerId, [attack]);
|
|
engageMob(attackerId, 5_000);
|
|
for (let now = 5_000; now < 5_200; now += 1) {
|
|
useCombatStore.getState().advanceMobCombat(now);
|
|
if (useCombatStore.getState().lastCombatResult?.blocked) break;
|
|
}
|
|
|
|
const result = useCombatStore.getState().lastCombatResult;
|
|
const event = damageEvents(attackerId).find((candidate) => candidate.outcome === "block");
|
|
expect(result?.blocked).toBe(true);
|
|
expect(result?.landed).toBe(true);
|
|
expect(result?.effectiveArmor).toBeGreaterThan(0);
|
|
expect(result?.armorMitigatedAmount).toBeGreaterThan(0);
|
|
expect(result?.blockedAmount).toBeGreaterThan(0);
|
|
expect(event).toBeDefined();
|
|
expect(event!.rawAmount).toBe(result!.rawDamage);
|
|
expect(event!.rawAmount).toBe(useCombatStore.getState().mobs[attackerId]!.attackDamage + 6);
|
|
expect(result!.damageBeforeMitigation).toBe(event!.rawAmount);
|
|
expect(event!.absorbed).toBeGreaterThan(0);
|
|
expect(event!.effectiveAmount).toBe(0);
|
|
});
|
|
|
|
it("cleanses the imported disease with the actual level-30 Priest Cure Disease ability", () => {
|
|
const appliedAt = applyImportedWoundAndRemoveCaster();
|
|
const cure = abilityAtLevel(cureDiseaseAbility(), 30);
|
|
expect(cure).toMatchObject({
|
|
dbcSpellId: 528,
|
|
effects: [expect.objectContaining({
|
|
kind: "dispel",
|
|
mode: "cleanse",
|
|
categories: ["disease"],
|
|
})],
|
|
});
|
|
// Cure Disease targets the player's self slot when no party actor is selected.
|
|
expect(useCombatStore.getState().selectFriendlyActor(null)).toBe(true);
|
|
const result = useCombatStore.getState().castAbility(cure.id, {
|
|
now: appliedAt + 1,
|
|
});
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === TARGET_AURA_ID
|
|
&& aura.targetId === PLAYER_AURA_ENTITY_ID)).toBe(false);
|
|
});
|
|
|
|
it("expires at the sourced five-minute duration after the caster has been removed", () => {
|
|
const appliedAt = applyImportedWoundAndRemoveCaster();
|
|
const expirationAt = useCombatStore.getState().auras.find((aura) => aura.definition.id === TARGET_AURA_ID)!.expiresAt!;
|
|
expect(expirationAt - appliedAt).toBe(300_000);
|
|
useCombatStore.getState().advanceMobCombat(expirationAt);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === TARGET_AURA_ID
|
|
&& aura.targetId === PLAYER_AURA_ENTITY_ID)).toBe(false);
|
|
});
|
|
});
|