Files
healer-man/src/game/romKnightPantherDodgeProc.integration.test.ts
T

294 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { stableCombatUnit, useCombatStore } from "./combatStore";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import {
ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID,
ROM_KNIGHT_PANTHERS_BLESSING_SPELL_ID,
} from "./romKnightPanthersBlessingRuntime";
const PANTHER = ROM_ALL_ABILITIES.find((ability) => (
ability.source === "runewaker"
&& ability.classId === "rom-knight"
&& ability.secondaryClassId === "rom-rogue"
&& ability.dbcSpellId === ROM_KNIGHT_PANTHERS_BLESSING_SPELL_ID
))!;
const HIGH_DODGE_RATING = 1_000_000;
function initializePlayer(level = 80): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty(
`panther-player-${level}`, level, useCombatStore.getState().health, "damage", 1, "rom",
);
}
function createKnightRogueCompanion(level: number) {
return createConfiguredPartyMember({
name: "Knight/Rogue Panther's Blessing test companion",
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
specialization: "Knight/Rogue",
specializationId: "rom-knight:rom-rogue",
talentTreeId: "rom-knight:rom-rogue",
role: "damage",
}, level, 0, `panther-companion-${level}`);
}
function preparePlayerPools(healthMissing: number, manaMissing: number): {
readonly maxHealth: number;
readonly maxMana: number;
readonly startHealth: number;
readonly startMana: number;
} {
useCombatStore.setState((state) => {
const maxMana = state.maxResourcePools.mana ?? 0;
const startMana = Math.max(0, maxMana - manaMissing);
return {
gearStats: { ...state.gearStats, dodgeRating: HIGH_DODGE_RATING },
health: Math.max(1, state.maxHealth - healthMissing),
resource: state.classId === "rom-knight" ? startMana : state.resource,
resourcePools: { ...state.resourcePools, mana: startMana },
};
});
useCombatStore.getState().synchronizeActors();
const state = useCombatStore.getState();
return {
maxHealth: state.maxHealth,
maxMana: state.maxResourcePools.mana ?? 0,
startHealth: state.health,
startMana: state.resourcePools.mana ?? 0,
};
}
function prepareCompanionPools(memberId: string, healthMissing: number, manaMissing: number): {
readonly maxHealth: number;
readonly maxMana: number;
readonly startHealth: number;
readonly startMana: number;
} {
usePartyStore.setState((state) => ({
members: state.members.map((member) => {
if (member.id !== memberId) return member;
const maxMana = member.resourcePools.mana === undefined
? (member.resourceType === "mana" ? member.maxResource : 0)
: member.resourceType === "mana" ? member.maxResource : 100;
const startMana = Math.max(0, maxMana - manaMissing);
const gearStats = { ...member.gearStats, dodgeRating: HIGH_DODGE_RATING };
return {
...member,
gearStats,
health: Math.max(1, member.maxHealth - healthMissing),
resource: member.resourceType === "mana" ? startMana : member.resource,
resourcePools: { ...member.resourcePools, mana: startMana },
};
}),
}));
useCombatStore.getState().synchronizeActors();
const member = usePartyStore.getState().members.find((candidate) => candidate.id === memberId)!;
return {
maxHealth: member.maxHealth,
maxMana: member.resourcePools.mana === undefined
? (member.resourceType === "mana" ? member.maxResource : 0)
: member.resourceType === "mana" ? member.maxResource : 100,
startHealth: member.health,
startMana: member.resourcePools.mana ?? 0,
};
}
function createIncomingAttack(attackId: string, rangedSpell = false) {
return {
...DEFAULT_ENEMY_ATTACK,
id: attackId,
name: rangedSpell ? "Non-dodge spell probe" : "Dodge probe",
...(rangedSpell ? { delivery: "projectile" as const, school: "fire" as const, animation: "cast" as const } : {}),
cooldownMs: 1_000,
repeatCooldownMs: [1_000, 1_000] as const,
initialCooldownMs: [450, 450] as const,
};
}
function engageMobAgainstActor(
mobId: string,
attackId: string,
actorId: string,
level: number,
engageAt = 1_000,
rangedSpell = false,
): void {
useCombatStore.getState().registerMob(mobId, {
name: mobId,
level,
maxHealth: 100_000,
xpReward: 0,
attacks: [createIncomingAttack(attackId, rangedSpell)],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, engageAt, {
actorId,
role: "damage",
abilityId: "panther-dodge-integration-probe",
}, "physical", {
attackKind: "melee",
seed: `${mobId}:${actorId}:engage`,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
}
function incomingTimeForRoll(
mobId: string,
attackId: string,
actorId: string,
minimumRoll: number,
firstReadyAt = 1_450,
): number {
for (let now = firstReadyAt; now < 100_000; now += 1) {
if (stableCombatUnit(`${mobId}:${attackId}:${actorId}:0:${now}:outcome`) >= minimumRoll) return now;
}
throw new Error("Could not find a deterministic incoming attack outcome for this test.");
}
function incomingEvent(mobId: string, actorId: string, at: number) {
return useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage"
&& event.sourceActorId === mobId
&& event.targetActorId === actorId
&& event.occurredAt === at
));
}
function pantherEvents(actorId: string) {
return useCombatStore.getState().combatEvents.filter((event) => (
event.abilityId === ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID
&& event.sourceActorId === actorId
&& event.targetActorId === actorId
));
}
describe("CoA Knight/Rogue Panther's Blessing dodge integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
initializePlayer();
});
it("restores the player's capped 10% max HP and 1% max MP after a resolved dodge", () => {
const maximum = useCombatStore.getState().maxHealth;
const missingHealth = 50;
const pools = preparePlayerPools(missingHealth, 1);
expect(pools.maxMana).toBeGreaterThan(1);
engageMobAgainstActor("panther-player-dodge", "panther-player-attack", PLAYER_AGGRO_ID, 80);
const now = incomingTimeForRoll("panther-player-dodge", "panther-player-attack", PLAYER_AGGRO_ID, 0.05);
useCombatStore.getState().advanceMobCombat(now);
expect(incomingEvent("panther-player-dodge", PLAYER_AGGRO_ID, now)?.outcome).toBe("dodge");
expect(useCombatStore.getState().health).toBe(maximum);
expect(useCombatStore.getState().resourcePools.mana).toBe(pools.maxMana);
expect(useCombatStore.getState().actors[PLAYER_AGGRO_ID]).toMatchObject({
health: maximum,
resources: { mana: { current: pools.maxMana, maximum: pools.maxMana } },
});
const healing = pantherEvents(PLAYER_AGGRO_ID).find((event) => event.kind === "healing");
const mana = pantherEvents(PLAYER_AGGRO_ID).find((event) => event.kind === "resource");
expect(healing).toMatchObject({
rawAmount: Math.round(maximum * 0.1),
effectiveAmount: missingHealth,
overheal: Math.round(maximum * 0.1) - missingHealth,
});
expect(mana).toMatchObject({
rawAmount: Math.round(pools.maxMana * 0.01),
effectiveAmount: 1,
overheal: Math.round(pools.maxMana * 0.01) - 1,
});
});
it("restores and mirrors a generated Knight/Rogue companion's own HP and mana pools", () => {
const member = createKnightRogueCompanion(80);
usePartyStore.setState({ members: [member] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
const pools = prepareCompanionPools(member.id, 1_000, 10);
expect(pools.maxMana).toBeGreaterThan(10);
engageMobAgainstActor("panther-companion-dodge", "panther-companion-attack", member.id, 80);
const now = incomingTimeForRoll("panther-companion-dodge", "panther-companion-attack", member.id, 0.05);
useCombatStore.getState().advanceMobCombat(now);
expect(incomingEvent("panther-companion-dodge", member.id, now)?.outcome).toBe("dodge");
const expectedHealth = Math.min(pools.maxHealth, pools.startHealth + Math.round(pools.maxHealth * 0.1));
const expectedMana = Math.min(pools.maxMana, pools.startMana + Math.round(pools.maxMana * 0.01));
expect(usePartyStore.getState().members[0]).toMatchObject({
health: expectedHealth,
resourcePools: { mana: expectedMana },
resource: expectedMana,
});
expect(useCombatStore.getState().actors[member.id]).toMatchObject({
health: expectedHealth,
resources: { mana: { current: expectedMana, maximum: pools.maxMana } },
});
expect(pantherEvents(member.id)).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "healing", rawAmount: Math.round(pools.maxHealth * 0.1) }),
expect.objectContaining({ kind: "resource", rawAmount: Math.round(pools.maxMana * 0.01) }),
]));
});
it("keeps an exact-pair player below level 45 out of the proc", () => {
initializePlayer(44);
const pools = preparePlayerPools(500, 10);
engageMobAgainstActor("panther-unlearned-player", "panther-unlearned-player-attack", PLAYER_AGGRO_ID, 44);
const now = incomingTimeForRoll("panther-unlearned-player", "panther-unlearned-player-attack", PLAYER_AGGRO_ID, 0.05);
useCombatStore.getState().advanceMobCombat(now);
expect(incomingEvent("panther-unlearned-player", PLAYER_AGGRO_ID, now)?.outcome).toBe("dodge");
expect(useCombatStore.getState().health).toBe(pools.startHealth);
expect(useCombatStore.getState().resourcePools.mana).toBe(pools.startMana);
expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]);
});
it("keeps an exact-pair companion below level 45 out of the proc", () => {
initializePlayer(44);
const member = createKnightRogueCompanion(44);
usePartyStore.setState({ members: [member] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
const pools = prepareCompanionPools(member.id, 500, 10);
engageMobAgainstActor("panther-unlearned-companion", "panther-unlearned-companion-attack", member.id, 44);
const now = incomingTimeForRoll("panther-unlearned-companion", "panther-unlearned-companion-attack", member.id, 0.05);
useCombatStore.getState().advanceMobCombat(now);
expect(incomingEvent("panther-unlearned-companion", member.id, now)?.outcome).toBe("dodge");
expect(usePartyStore.getState().members[0]?.health).toBe(pools.startHealth);
expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBe(pools.startMana);
expect(pantherEvents(member.id)).toEqual([]);
});
it("does not invent a dodge for direct damage or a nondodgeable spell hit", () => {
const pools = preparePlayerPools(1_000, 10);
const directDamage = useCombatStore.getState().damagePlayer(1, "physical");
expect(useCombatStore.getState().health).toBe(pools.startHealth - directDamage);
expect(useCombatStore.getState().resourcePools.mana).toBe(pools.startMana);
expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]);
engageMobAgainstActor(
"panther-spell-hit", "panther-spell-attack", PLAYER_AGGRO_ID, 80, 2_000, true,
);
const now = incomingTimeForRoll("panther-spell-hit", "panther-spell-attack", PLAYER_AGGRO_ID, 0.1, 2_450);
useCombatStore.getState().advanceMobCombat(now);
expect(incomingEvent("panther-spell-hit", PLAYER_AGGRO_ID, now)?.outcome).not.toBe("dodge");
expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]);
expect(PANTHER).toMatchObject({ name: "Panther's Blessing", unlockLevel: 45, passive: true });
});
});