141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { useCombatStore, stableCombatUnit, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
|
import { DEFAULT_ENEMY_ATTACK, type EnemyDamageSchool } from "./enemyAttacks";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID } from "./wow335DeathKnightSpellDeflectionRuntime";
|
|
|
|
const ATTACK = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: "spell-deflection-incoming-test",
|
|
name: "Spell Deflection incoming test",
|
|
delivery: "projectile" as const,
|
|
animation: "cast" as const,
|
|
castTimeMs: 0,
|
|
range: 20,
|
|
cooldownMs: 10_000,
|
|
initialCooldownMs: [0, 0] as const,
|
|
repeatCooldownMs: [10_000, 10_000] as const,
|
|
damageMultiplier: 1,
|
|
};
|
|
|
|
const MAX_PARRY_GEAR = { ...EMPTY_ITEM_STATS, parryRating: 50_000 };
|
|
|
|
function incomingEvent(mobId: string, targetId: string) {
|
|
const event = useCombatStore.getState().combatEvents.find((candidate) => (
|
|
candidate.kind === "damage" && candidate.sourceActorId === mobId && candidate.targetActorId === targetId
|
|
));
|
|
if (!event) throw new Error(`Expected an incoming damage event on ${targetId}.`);
|
|
return event;
|
|
}
|
|
|
|
function hitTime(mobId: string, targetId: string): number {
|
|
for (let now = 1_000; now < 200_000; now += 1) {
|
|
const resultRoll = stableCombatUnit(`${mobId}:${ATTACK.id}:${targetId}:0:${now}:outcome`);
|
|
if (resultRoll >= 0.2 && resultRoll < 0.9) return now;
|
|
}
|
|
throw new Error("Could not find a landed incoming spell hit seed.");
|
|
}
|
|
|
|
function setupAttacker(mobId: string, targetId: string, now: number, school: EnemyDamageSchool): void {
|
|
useCombatStore.getState().registerMob(mobId, {
|
|
name: "Spell Deflection test attacker", level: 80, maxHealth: 100_000, xpReward: 0,
|
|
attacks: [{ ...ATTACK, school }],
|
|
});
|
|
setMobPosition(mobId, [2, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 4_000 } },
|
|
}));
|
|
useCombatStore.getState().damageMob(mobId, 1, now - 1, { actorId: targetId, role: "tank" }, "physical");
|
|
}
|
|
|
|
function resetForHit(playerClass: "death-knight" | "warrior", runId: string): void {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({ classId: playerClass, level: 80, xp: 0 });
|
|
usePartyStore.getState().initializeParty(
|
|
`wow335-spell-deflection-${runId}`,
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"tank",
|
|
1,
|
|
);
|
|
}
|
|
|
|
function runPlayerHit(rank: number) {
|
|
resetForHit("death-knight", `player-${rank}`);
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: MAX_PARRY_GEAR,
|
|
talentRanks: { ...state.talentRanks, [WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID]: rank },
|
|
}));
|
|
const mobId = "spell-deflection-player-attacker";
|
|
const now = hitTime(mobId, PLAYER_AGGRO_ID);
|
|
setupAttacker(mobId, PLAYER_AGGRO_ID, now, "shadow");
|
|
useCombatStore.getState().advanceMobCombat(now);
|
|
return incomingEvent(mobId, PLAYER_AURA_ENTITY_ID);
|
|
}
|
|
|
|
function runCompanionHit(rank: number) {
|
|
resetForHit("warrior", `companion-${rank}`);
|
|
const talentRanks = { [WOW335_DEATH_KNIGHT_SPELL_DEFLECTION_TALENT_ID]: rank };
|
|
const member = createConfiguredPartyMember({
|
|
name: "Spell Deflection DK",
|
|
classId: "death-knight",
|
|
specialization: "Blood",
|
|
specializationId: "death-knight:blood",
|
|
talentTreeId: "death-knight-blood",
|
|
role: "tank",
|
|
talentRanks,
|
|
}, 80, 0, `wow335-spell-deflection-companion-${rank}`);
|
|
usePartyStore.setState({
|
|
members: [{
|
|
...member,
|
|
health: 100_000,
|
|
maxHealth: 100_000,
|
|
gearStats: MAX_PARRY_GEAR,
|
|
talentRanks,
|
|
globalCooldownEndsAt: 61_000,
|
|
nextActionAt: 61_000,
|
|
}],
|
|
});
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const mobId = "spell-deflection-companion-attacker";
|
|
const now = hitTime(mobId, member.id);
|
|
setupAttacker(mobId, member.id, now, "shadow");
|
|
advanceDungeonPartyCombat(now);
|
|
useCombatStore.getState().advanceMobCombat(now, [100, 0, 0]);
|
|
return incomingEvent(mobId, member.id);
|
|
}
|
|
|
|
describe("WoW Death Knight Spell Deflection combat integration", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
});
|
|
|
|
it("absorbs the rank percentage of a direct shadow hit for player and generated companion", () => {
|
|
const playerBaseline = runPlayerHit(0);
|
|
const playerDeflected = runPlayerHit(3);
|
|
const companionBaseline = runCompanionHit(0);
|
|
const companionDeflected = runCompanionHit(3);
|
|
|
|
expect(playerBaseline.effectiveAmount).toBeGreaterThan(0);
|
|
expect(playerBaseline.absorbed).toBe(0);
|
|
expect(playerDeflected.absorbed).toBe(Math.floor(playerBaseline.effectiveAmount * 0.45));
|
|
expect(playerDeflected.effectiveAmount).toBe(playerBaseline.effectiveAmount - playerDeflected.absorbed);
|
|
|
|
expect(companionBaseline.effectiveAmount).toBeGreaterThan(0);
|
|
expect(companionBaseline.absorbed).toBe(0);
|
|
expect(companionDeflected.absorbed).toBe(Math.floor(companionBaseline.effectiveAmount * 0.45));
|
|
expect(companionDeflected.effectiveAmount).toBe(companionBaseline.effectiveAmount - companionDeflected.absorbed);
|
|
});
|
|
});
|