294 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|