forked from phenom/healer-man
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { abilitiesForClass } from "./abilityCatalog";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
|
import { applyAura, type AuraDefinition } from "./combatAuras";
|
|
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
|
import { COA_GUARDIAN_VANGUARD_AURA_ID } from "./coaGuardianVanguardRuntime";
|
|
|
|
const HAMHOCK_CHAIN_LIGHTNING = requireDungeonDefinition("stormwind-stockade")
|
|
.entities["entry-1717"]!
|
|
.combat!
|
|
.attacks.find((attack) => attack.spellId === 421)!;
|
|
|
|
const VANGUARD_RANKS = { "coa-entry-34417": 1 };
|
|
|
|
const GUARANTEED_MELEE_BLOCK_FIXTURE: AuraDefinition = {
|
|
id: "test:vanguard-guaranteed-melee-block",
|
|
name: "Vanguard integration block fixture",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
stackBehavior: "replace",
|
|
modifiers: [
|
|
{ kind: "stat", stat: "block", operation: "flat", value: 1_000 },
|
|
{ kind: "stat", stat: "block-value", operation: "flat", value: 20 },
|
|
{ kind: "stat", stat: "dodge", operation: "flat", value: -1_000 },
|
|
{ kind: "stat", stat: "parry", operation: "flat", value: -1_000 },
|
|
{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 },
|
|
{ kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 },
|
|
],
|
|
};
|
|
|
|
const MELEE_BLOCK_FIXTURE_ATTACK: EnemyAttackDefinition = {
|
|
id: "vanguard-integration-melee",
|
|
name: "Vanguard integration melee",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
attackKind: "melee",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 60_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
damageMultiplier: 1,
|
|
};
|
|
|
|
function vanguardAuras(actorId: string) {
|
|
return useCombatStore.getState().auras.filter((aura) => (
|
|
aura.definition.id === COA_GUARDIAN_VANGUARD_AURA_ID
|
|
&& aura.sourceId === actorId
|
|
&& aura.targetId === actorId
|
|
));
|
|
}
|
|
|
|
function registerHamhock(id: string): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: "Hamhock (Vanguard integration fixture)",
|
|
serverEntry: 1717,
|
|
level: 30,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
...HAMHOCK_CHAIN_LIGHTNING,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [60_000, 60_000],
|
|
}],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().selectMob(id);
|
|
}
|
|
|
|
function applyDeterministicBlockFixture(actorId: string, now: number): void {
|
|
const auras = applyAura(useCombatStore.getState().auras, {
|
|
definition: GUARANTEED_MELEE_BLOCK_FIXTURE,
|
|
sourceId: actorId,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras;
|
|
useCombatStore.setState({ auras: [...auras] });
|
|
}
|
|
|
|
function registerMeleeBlockFixture(id: string, targetActorId: string, now: number): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: "Vanguard melee block fixture",
|
|
level: 30,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [MELEE_BLOCK_FIXTURE_ATTACK],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, now, {
|
|
actorId: targetActorId,
|
|
role: "tank",
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
// Match the imported level-30 Hamhock so its spell attack table is representative.
|
|
useCombatStore.getState().initializeCharacter({ classId: "guardian", level: 30 });
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, ...VANGUARD_RANKS },
|
|
gearStats: { ...state.gearStats, ...EMPTY_ITEM_STATS, hitRating: 1_000_000 },
|
|
}));
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty(
|
|
"coa-guardian-vanguard-integration",
|
|
30,
|
|
useCombatStore.getState().health,
|
|
"tank",
|
|
1,
|
|
"coa",
|
|
);
|
|
usePartyStore.setState({ members: [], command: "stop", activeMobId: null });
|
|
});
|
|
|
|
describe("CoA Guardian Vanguard combat integration", () => {
|
|
it("does not count Ram, then reduces Hamhock's imported Chain Lightning before consuming the auto-hit stack", () => {
|
|
const mobId = "coa-vanguard-hamhock-player";
|
|
registerHamhock(mobId);
|
|
|
|
const ram = abilitiesForClass("guardian").find((ability) => ability.dbcSpellId === 802284);
|
|
expect(ram, "Guardian Ram must be present as a normal melee action").toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(ram!.id, undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0);
|
|
|
|
// Ram starts the player's normal weapon swings. Tick the actual player combat loop.
|
|
useCombatStore.getState().tick(0.1, 1_001);
|
|
useCombatStore.getState().tick(2.5, 3_501);
|
|
const afterAutoAttacks = vanguardAuras(PLAYER_AURA_ENTITY_ID);
|
|
expect(afterAutoAttacks).toHaveLength(1);
|
|
expect(afterAutoAttacks[0]?.stacks).toBe(2);
|
|
|
|
expect(HAMHOCK_CHAIN_LIGHTNING).toMatchObject({ spellId: 421, school: "nature", chain: { targetCount: 3 } });
|
|
const playerHealthBeforeSpell = useCombatStore.getState().health;
|
|
useCombatStore.getState().advanceMobCombat(5_000);
|
|
useCombatStore.getState().advanceMobCombat(8_000);
|
|
|
|
const hit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === mobId
|
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID
|
|
&& event.abilityId === HAMHOCK_CHAIN_LIGHTNING.id
|
|
&& event.school === "nature"
|
|
));
|
|
expect(hit).toBeDefined();
|
|
const criticalMultiplier = hit!.critical ? 2 : 1;
|
|
expect(hit!.effectiveAmount).toBeCloseTo(hit!.rawAmount * criticalMultiplier * 0.96, 8);
|
|
expect(playerHealthBeforeSpell - useCombatStore.getState().health).toBeCloseTo(hit!.effectiveAmount, 8);
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(1);
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)[0]?.stacks).toBe(1);
|
|
});
|
|
|
|
it("adds a companion-owned stack on its real normal auto attack", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 60 });
|
|
const partyId = "coa-vanguard-companion-party";
|
|
const targetId = "coa-vanguard-companion-target";
|
|
usePartyStore.getState().initializeParty(
|
|
partyId,
|
|
60,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
1,
|
|
"coa",
|
|
);
|
|
const template = createConfiguredPartyMember({
|
|
name: "Vanguard Guardian Companion",
|
|
classId: "guardian",
|
|
specialization: "Vanguard",
|
|
specializationId: "guardian:vanguard",
|
|
talentTreeId: "coa-guardian-87",
|
|
talentRanks: VANGUARD_RANKS,
|
|
role: "tank",
|
|
}, 60, 0, partyId);
|
|
const guardian = {
|
|
...template,
|
|
talentRanks: { ...template.talentRanks, ...VANGUARD_RANKS },
|
|
nextActionAt: 100_000,
|
|
};
|
|
usePartyStore.setState({ members: [guardian], command: "attack", activeMobId: targetId });
|
|
registerPartyRuntimePosition(guardian.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().registerMob(targetId, {
|
|
name: "Guardian auto-attack target",
|
|
level: 60,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(targetId, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(targetId, 1, 1_000);
|
|
updatePartyRuntimeTargetVisibility(guardian.id, targetId, true);
|
|
|
|
advanceDungeonPartyCombat(2_000);
|
|
|
|
const companionAura = vanguardAuras(guardian.id);
|
|
expect(companionAura).toHaveLength(1);
|
|
expect(companionAura[0]).toMatchObject({
|
|
sourceId: guardian.id,
|
|
targetId: guardian.id,
|
|
stacks: 1,
|
|
});
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0);
|
|
});
|
|
|
|
it("adds a player-owned stack after an actual deterministic melee block", () => {
|
|
const mobId = "coa-vanguard-player-blocker";
|
|
applyDeterministicBlockFixture(PLAYER_AURA_ENTITY_ID, 900);
|
|
registerMeleeBlockFixture(mobId, PLAYER_AURA_ENTITY_ID, 1_000);
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_000);
|
|
useCombatStore.getState().advanceMobCombat(2_000);
|
|
|
|
const blockedHit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === mobId
|
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID
|
|
&& event.abilityId === MELEE_BLOCK_FIXTURE_ATTACK.id
|
|
));
|
|
expect(blockedHit).toMatchObject({ outcome: "block" });
|
|
expect(useCombatStore.getState().lastCombatResult?.blocked).toBe(true);
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(1);
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)[0]?.stacks).toBe(1);
|
|
});
|
|
|
|
it("lets a companion Guardian block, then mitigates Hamhock's spell before consuming one stack", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 30 });
|
|
const partyId = "coa-vanguard-incoming-companion-party";
|
|
const autoTargetId = "coa-vanguard-incoming-companion-auto-target";
|
|
usePartyStore.getState().initializeParty(
|
|
partyId,
|
|
30,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
1,
|
|
"coa",
|
|
);
|
|
const template = createConfiguredPartyMember({
|
|
name: "Vanguard Guardian Companion",
|
|
classId: "guardian",
|
|
specialization: "Vanguard",
|
|
specializationId: "guardian:vanguard",
|
|
talentTreeId: "coa-guardian-87",
|
|
talentRanks: VANGUARD_RANKS,
|
|
role: "tank",
|
|
}, 30, 0, partyId);
|
|
const guardian = {
|
|
...template,
|
|
talentRanks: { ...template.talentRanks, ...VANGUARD_RANKS },
|
|
nextActionAt: 100_000,
|
|
};
|
|
usePartyStore.setState({ members: [guardian], command: "attack", activeMobId: autoTargetId });
|
|
registerPartyRuntimePosition(guardian.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().registerMob(autoTargetId, {
|
|
name: "Companion auto-attack target",
|
|
level: 30,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(autoTargetId, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(autoTargetId, 1, 1_000);
|
|
updatePartyRuntimeTargetVisibility(guardian.id, autoTargetId, true);
|
|
advanceDungeonPartyCombat(2_000);
|
|
expect(vanguardAuras(guardian.id)).toHaveLength(1);
|
|
expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(1);
|
|
useCombatStore.getState().damageMob(autoTargetId, 100_000, 2_500, { actorId: guardian.id, role: guardian.role });
|
|
|
|
applyDeterministicBlockFixture(guardian.id, 2_500);
|
|
const blockerId = "coa-vanguard-companion-blocker";
|
|
registerMeleeBlockFixture(blockerId, guardian.id, 3_000);
|
|
useCombatStore.getState().advanceMobCombat(3_000);
|
|
useCombatStore.getState().advanceMobCombat(4_000);
|
|
const blockedHit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === blockerId
|
|
&& event.targetActorId === guardian.id
|
|
&& event.abilityId === MELEE_BLOCK_FIXTURE_ATTACK.id
|
|
));
|
|
expect(blockedHit).toMatchObject({ outcome: "block" });
|
|
expect(vanguardAuras(guardian.id)).toHaveLength(1);
|
|
expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(2);
|
|
|
|
const hamhockId = "coa-vanguard-hamhock-companion";
|
|
registerHamhock(hamhockId);
|
|
useCombatStore.getState().damageMob(hamhockId, 1, 5_000, { actorId: guardian.id, role: guardian.role });
|
|
const companionHealthBeforeSpell = useCombatStore.getState().actors[guardian.id]?.health;
|
|
expect(companionHealthBeforeSpell).toBeDefined();
|
|
useCombatStore.getState().advanceMobCombat(8_000);
|
|
useCombatStore.getState().advanceMobCombat(10_000);
|
|
|
|
const hit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === hamhockId
|
|
&& event.targetActorId === guardian.id
|
|
&& event.abilityId === HAMHOCK_CHAIN_LIGHTNING.id
|
|
&& event.school === "nature"
|
|
));
|
|
expect(hit).toBeDefined();
|
|
const criticalMultiplier = hit!.critical ? 2 : 1;
|
|
expect(hit!.effectiveAmount).toBe(Math.round(hit!.rawAmount * criticalMultiplier * 0.96));
|
|
expect(companionHealthBeforeSpell! - useCombatStore.getState().actors[guardian.id]!.health)
|
|
.toBeCloseTo(hit!.effectiveAmount, 8);
|
|
expect(vanguardAuras(guardian.id)).toHaveLength(1);
|
|
expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(1);
|
|
expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0);
|
|
});
|
|
});
|