Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -0,0 +1,293 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user