650 lines
36 KiB
TypeScript
650 lines
36 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilitiesForCharacter, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { characterCombatDefender, useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { commitPartyAbilityUse } from "./partyAbilityAi";
|
|
import { applyAura as applyCombatAura } from "./combatAuras";
|
|
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
|
import { romPriestKnightAdditionalAbilities, romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
|
import { ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, ROM_KNIGHT_MASTER_OF_PARRY_TAG } from "./romKnightOverrides";
|
|
|
|
const PAIRED_SKILLS = [
|
|
["rom-priest", "rom-knight", 15, 492355, "Enhanced Grace of Life", "passive"],
|
|
["rom-priest", "rom-knight", 20, 492361, "Life Link", "passive"],
|
|
["rom-priest", "rom-knight", 25, 491648, "Last Prayer", "elite"],
|
|
["rom-priest", "rom-knight", 30, 491366, "Divine Incarnation", "elite"],
|
|
["rom-priest", "rom-knight", 35, 492639, "Calm Heart", "elite"],
|
|
["rom-priest", "rom-knight", 40, 492934, "Sacrificial Cure", "elite"],
|
|
["rom-priest", "rom-knight", 45, 493044, "Holy Power", "elite"],
|
|
["rom-priest", "rom-knight", 50, 493271, "Light Fairy", "elite"],
|
|
["rom-priest", "rom-knight", 60, 499968, "Splendor Ripple", "passive"],
|
|
["rom-priest", "rom-knight", 70, 499957, "Light Guidance", "passive"],
|
|
["rom-knight", "rom-scout", 15, 491616, "Arrow of Vengeance", "elite"],
|
|
["rom-knight", "rom-scout", 20, 491610, "Enhanced Holy Strike", "passive"],
|
|
["rom-knight", "rom-scout", 25, 491613, "Holy Chains", "elite"],
|
|
["rom-knight", "rom-scout", 30, 491354, "Heavenly Arrow", "elite"],
|
|
["rom-knight", "rom-scout", 35, 490066, "Weaken Weapon Strength", "passive"],
|
|
["rom-knight", "rom-scout", 40, 492939, "Sacred Resistance", "elite"],
|
|
["rom-knight", "rom-scout", 45, 493046, "Sacred Protection", "elite"],
|
|
["rom-knight", "rom-scout", 50, 493273, "Disregard Danger", "elite"],
|
|
["rom-knight", "rom-scout", 60, 499926, "Evil Shackles", "passive"],
|
|
["rom-knight", "rom-scout", 70, 499923, "Blood Punishment", "passive"],
|
|
] as const;
|
|
|
|
function pairedSkill(sourceId: number): AbilityDefinition {
|
|
const found = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === sourceId);
|
|
if (!found) throw new Error(`Missing paired RoM source skill ${sourceId}`);
|
|
return found;
|
|
}
|
|
|
|
function executable(effect: AbilityEffect): boolean {
|
|
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
|
|
if (effect.kind === "apply-aura") return Boolean(
|
|
effect.control || effect.aura.modifiers?.length || effect.aura.absorbs?.length
|
|
|| effect.aura.procs?.length || effect.aura.utilityTags?.length
|
|
|| effect.aura.controlImmunities?.length || effect.aura.redirectDamagePercent,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
|
});
|
|
|
|
it("has exactly the twenty RuneWaker paired skills, including passive upgrades", () => {
|
|
for (const [primary, secondary] of [["rom-priest", "rom-knight"], ["rom-knight", "rom-scout"]] as const) {
|
|
const pair = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === primary
|
|
&& ability.secondaryClassId === secondary);
|
|
expect(pair, `${primary}/${secondary}`).toHaveLength(primary === "rom-priest" ? 11 : 10);
|
|
const kit = abilitiesForCharacter(primary, secondary);
|
|
for (const skill of pair) expect(kit).toContain(skill);
|
|
}
|
|
});
|
|
|
|
it.each(PAIRED_SKILLS)("maps %s/%s level %i source %i %s (%s) with the right gate", (
|
|
primary, secondary, unlockLevel, sourceId, name, group,
|
|
) => {
|
|
const skill = pairedSkill(sourceId);
|
|
expect(skill).toMatchObject({
|
|
classId: primary, secondaryClassId: secondary, unlockLevel,
|
|
dbcSpellId: sourceId, name, romSkillGroup: group, passive: group === "passive",
|
|
});
|
|
for (const level of [10, 30, 60, 79]) {
|
|
expect(isAbilityUnlocked(skill, level), `${name} at level ${level}`)
|
|
.toBe(level >= unlockLevel);
|
|
}
|
|
});
|
|
|
|
it.each(PAIRED_SKILLS)("gives %s/%s level %i source %i %s (%s) an executable mechanic", (
|
|
_primary, _secondary, _unlockLevel, sourceId, name,
|
|
) => {
|
|
const skill = pairedSkill(sourceId);
|
|
expect(skill.effects.some(executable), `${name} (${sourceId})`).toBe(true);
|
|
});
|
|
|
|
it("retains direct and area Knight/Scout taunts", () => {
|
|
expect(pairedSkill(491616).effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "taunt" }),
|
|
]));
|
|
expect(pairedSkill(491354).effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "taunt", maxTargets: expect.any(Number) }),
|
|
]));
|
|
});
|
|
|
|
it("retains Priest/Knight healing costs and offensive/defensive effects", () => {
|
|
const sacrifice = pairedSkill(492934);
|
|
expect(sacrifice.costs).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ resource: "health", amount: 70, percentage: true }),
|
|
]));
|
|
expect(sacrifice.effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "heal", percentMaxHealth: expect.any(Number) }),
|
|
]));
|
|
expect(pairedSkill(491366).effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({
|
|
modifiers: expect.arrayContaining([expect.objectContaining({ kind: "healing", direction: "done", value: 0.15 })]),
|
|
}) }),
|
|
]));
|
|
expect(pairedSkill(493271).effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "summon", creatureId: 102108 }),
|
|
]));
|
|
});
|
|
|
|
it("retains Knight/Scout damage and Dexterity buff on Sacred Resistance", () => {
|
|
const skill = pairedSkill(492939);
|
|
expect(skill.effects.some((effect) => effect.kind === "damage" && effect.coefficient > 0)).toBe(true);
|
|
expect(skill.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "dexterity" && modifier.value > 0))).toBe(true);
|
|
});
|
|
|
|
it("makes Calm Heart resist Fear rather than applying an empty buff", () => {
|
|
expect(pairedSkill(492639).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.controlImmunities?.includes("fear"))).toBe(true);
|
|
});
|
|
|
|
it("maps Sacred Protection to a real shield block bonus", () => {
|
|
expect(pairedSkill(493046).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "block-chance" && modifier.value > 0))).toBe(true);
|
|
});
|
|
|
|
it("redirects damage with Disregard Danger", () => {
|
|
expect(pairedSkill(493273).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& (effect.aura.redirectDamagePercent ?? 0) > 0)).toBe(true);
|
|
});
|
|
|
|
it("applies only the advertised root from Holy Chains", () => {
|
|
const controls = pairedSkill(491613).effects.flatMap((effect) => effect.kind === "apply-aura"
|
|
&& effect.control ? [effect.control.kind] : []);
|
|
expect(controls).toEqual(["root"]);
|
|
});
|
|
|
|
it("increases healing received through Blood Punishment", () => {
|
|
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.utilityTags?.includes("rom-blood-punishment"))).toBe(true);
|
|
expect(pairedSkill(499923).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
|
|
});
|
|
|
|
it("keeps passive pair upgrades conditional on their parent casts", () => {
|
|
for (const [sourceId, tag] of [
|
|
[492355, "rom-enhanced-grace-of-life"],
|
|
[492361, "rom-life-link"],
|
|
[499968, "rom-splendor-ripple"],
|
|
[499957, "rom-light-guidance"],
|
|
[491610, "rom-enhanced-holy-strike"],
|
|
[490066, "rom-weaken-weapon-strength"],
|
|
[499926, "rom-evil-shackles"],
|
|
[499923, "rom-blood-punishment"],
|
|
] as const) {
|
|
expect(pairedSkill(sourceId).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.utilityTags?.includes(tag)), `${sourceId} ${tag}`).toBe(true);
|
|
}
|
|
expect(pairedSkill(491648).effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.utilityTags?.includes("rom-last-prayer"))).toBe(true);
|
|
});
|
|
|
|
it("applies passive upgrades to Grace, Urgent Heal, Holy Strike, and Heavenly Arrow casts", () => {
|
|
const ability = (sourceId: number) => ROM_ALL_ABILITIES.find((row) => row.dbcSpellId === sourceId)!;
|
|
const grace = ability(490290);
|
|
const enhancedGrace = romPriestKnightCastEffects(grace, grace.effects, ["rom-enhanced-grace-of-life"], 1_000);
|
|
expect(enhancedGrace.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "resource"
|
|
&& modifier.resource === "mana" && modifier.property === "maximum"))).toBe(true);
|
|
const urgent = ability(491147);
|
|
const linked = romPriestKnightCastEffects(urgent, urgent.effects, ["rom-life-link"], 1_000);
|
|
expect(linked.filter((effect) => effect.kind === "heal" && effect.recipient === "caster")).toHaveLength(1);
|
|
const holyStrike = ability(490148);
|
|
const enhancedStrike = romPriestKnightCastEffects(holyStrike, holyStrike.effects, ["rom-enhanced-holy-strike"], 1_000);
|
|
const originalDamage = holyStrike.effects.find((effect) => effect.kind === "damage");
|
|
const enhancedDamage = enhancedStrike.find((effect) => effect.kind === "damage");
|
|
expect(enhancedDamage?.kind === "damage" && originalDamage?.kind === "damage"
|
|
&& enhancedDamage.coefficient > originalDamage.coefficient).toBe(true);
|
|
const arrow = ability(491354);
|
|
const weakened = romPriestKnightCastEffects(arrow, arrow.effects, ["rom-weaken-weapon-strength"], 1_000);
|
|
expect(weakened.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
|
&& modifier.direction === "dealt" && modifier.school === "physical" && modifier.value < 0))).toBe(true);
|
|
});
|
|
|
|
it("halves Holy Chains duration against ordinary targets after Evil Shackles unlocks", () => {
|
|
const chains = pairedSkill(491613);
|
|
const rootDuration = (effects: readonly AbilityEffect[]) => effects
|
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
|
.find((effect) => effect.control?.kind === "root")?.aura.durationMs;
|
|
const ordinary = romPriestKnightCastEffects(chains, chains.effects, ["rom-evil-shackles"], 1_000);
|
|
const undead = romPriestKnightCastEffects(chains, chains.effects,
|
|
["rom-evil-shackles"], 1_000, ["rom-target-undead-demon"]);
|
|
expect(rootDuration(ordinary)).toBe(rootDuration(chains.effects)! / 2);
|
|
expect(rootDuration(undead)).toBe(rootDuration(chains.effects));
|
|
});
|
|
|
|
it("adds source 499967's extra Wave Armor heal only with Splendor Ripple", () => {
|
|
const waveArmor = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490273)!;
|
|
const ordinary = romPriestKnightCastEffects(waveArmor, waveArmor.effects, [], 1_000);
|
|
const upgraded = romPriestKnightCastEffects(waveArmor, waveArmor.effects, ["rom-splendor-ripple"], 1_000);
|
|
expect(ordinary.filter((effect) => effect.kind === "heal")).toHaveLength(0);
|
|
expect(upgraded).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "heal", basePoints: 106.25, pointsPerLevel: 21.25 }),
|
|
]));
|
|
});
|
|
|
|
it("adds current-CoA Blood Punishment modifiers to Blood Arrow's actor-owned aura", () => {
|
|
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
|
|
const ordinary = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, [], 1_000);
|
|
const upgraded = romPriestKnightCastEffects(bloodArrow, bloodArrow.effects, ["rom-blood-punishment"], 1_000);
|
|
const arrowAura = (effects: readonly AbilityEffect[]) => effects.find((effect) => effect.kind === "apply-aura"
|
|
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes("rom-self-health-drain"));
|
|
const sourceAura = arrowAura(bloodArrow.effects);
|
|
const plainAura = arrowAura(ordinary);
|
|
const upgradedAura = arrowAura(upgraded);
|
|
expect(sourceAura).toBeDefined();
|
|
expect(plainAura).toEqual(sourceAura);
|
|
expect(upgradedAura?.kind).toBe("apply-aura");
|
|
if (upgradedAura?.kind !== "apply-aura") throw new Error("Blood Arrow active aura is missing");
|
|
expect(upgradedAura.aura.durationMs).toBe(sourceAura?.kind === "apply-aura" ? sourceAura.aura.durationMs : null);
|
|
expect(upgradedAura.aura.modifiers).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "healing", direction: "taken", operation: "percent", value: 0.15 }),
|
|
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", operation: "percent", value: 0.05 }),
|
|
]));
|
|
expect(upgraded.filter((effect) => effect.kind === "apply-aura")).toHaveLength(
|
|
bloodArrow.effects.filter((effect) => effect.kind === "apply-aura").length,
|
|
);
|
|
});
|
|
|
|
it("builds source 499966 Devotion Halo and grants conditional Light Guidance healing", () => {
|
|
const [halo] = romPriestKnightAdditionalAbilities(ROM_ALL_ABILITIES);
|
|
expect(halo).toMatchObject({
|
|
id: "rom-priest-rom-knight-elite-70-devotion-halo",
|
|
dbcSpellId: 499966,
|
|
classId: "rom-priest",
|
|
secondaryClassId: "rom-knight",
|
|
unlockLevel: 70,
|
|
radius: 20,
|
|
passive: false,
|
|
});
|
|
expect(abilitiesForCharacter("rom-priest", "rom-knight").some((ability) => ability.dbcSpellId === 499966)).toBe(true);
|
|
expect(halo!.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.scope === "party" && effect.aura.durationMs === 900_000
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "wisdom" && modifier.value === 0.05))).toBe(true);
|
|
const ordinary = romPriestKnightCastEffects(halo!, halo!.effects, [], 1_000);
|
|
const guided = romPriestKnightCastEffects(halo!, halo!.effects, ["rom-light-guidance"], 1_000);
|
|
expect(ordinary.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"))).toBe(false);
|
|
expect(guided.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
|
&& modifier.direction === "done" && modifier.value === 0.05))).toBe(true);
|
|
});
|
|
|
|
it("heals multiple nearby allies with Priest Group Heal", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-group-heal", 30, useCombatStore.getState().health, "healer", 3, "rom");
|
|
const members = usePartyStore.getState().members.slice(0, 2);
|
|
expect(members).toHaveLength(2);
|
|
members.forEach((member, index) => {
|
|
usePartyStore.getState().damageMember(member.id, Math.max(100, Math.floor(member.maxHealth * 0.4)));
|
|
registerPartyRuntimePosition(member.id, [3 + index * 2, 0, 0]);
|
|
});
|
|
const injured = members.map((member) => usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health);
|
|
expect(useCombatStore.getState().castAbility("rom-priest-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 4_100);
|
|
members.forEach((member, index) => {
|
|
const healed = usePartyStore.getState().members.find((entry) => entry.id === member.id)!.health;
|
|
expect(healed, `Group Heal recipient ${index + 1}`).toBeGreaterThan(injured[index]!);
|
|
});
|
|
});
|
|
|
|
it("preserves Soul Source's 90-source-unit all-teammate heal", () => {
|
|
const soulSource = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490288)!;
|
|
expect(soulSource.radius).toBe(9);
|
|
expect(soulSource.effects.some((effect) => effect.kind === "heal"
|
|
&& effect.scope === "party")).toBe(true);
|
|
});
|
|
|
|
it("gives Holy Shield physical immunity to the party", () => {
|
|
const holyShield = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490495)!;
|
|
expect(holyShield.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.scope === "party" && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
|
|
&& modifier.direction === "taken" && modifier.school === "physical" && modifier.value <= -1))).toBe(true);
|
|
});
|
|
|
|
it("taunts nearby enemies with Knight/Scout Heavenly Arrow", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
|
|
});
|
|
for (const [id, x] of [["arrow-a", 3], ["arrow-b", 5]] as const) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
|
|
setMobPosition(id, [x, 0, 0]);
|
|
}
|
|
useCombatStore.getState().selectMob("arrow-a");
|
|
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-30", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().mobs["arrow-a"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
|
|
expect(useCombatStore.getState().mobs["arrow-b"]!.forcedTarget?.actorId).toBe(PLAYER_AGGRO_ID);
|
|
});
|
|
|
|
it.each([
|
|
[30, "living", false, 0],
|
|
[30, "undead", true, 10_000],
|
|
[60, "living", true, 5_000],
|
|
[60, "demon", true, 10_000],
|
|
] as const)("uses Holy Chains against %s-level %s for the correct duration", (
|
|
level, creatureFamily, allowed, durationMs,
|
|
) => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level, role: "tank",
|
|
});
|
|
useCombatStore.getState().registerMob("chains-target", {
|
|
name: "Chains Target", maxHealth: 10_000, creatureFamily,
|
|
});
|
|
setMobPosition("chains-target", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("chains-target");
|
|
const cast = useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-25", undefined, 1_000);
|
|
if (!allowed) {
|
|
expect(cast).toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
return;
|
|
}
|
|
expect(cast).toMatchObject({ ok: true });
|
|
const root = useCombatStore.getState().mobs["chains-target"]!.statuses.find((status) => status.kind === "root");
|
|
expect(root?.endsAt).toBe(1_000 + durationMs);
|
|
});
|
|
|
|
it("damages multiple nearby enemies with Knight Whirlwind Shield", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, role: "tank",
|
|
});
|
|
for (const [id, x] of [["shield-a", 3], ["shield-b", 4]] as const) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000 });
|
|
setMobPosition(id, [x, 0, 0]);
|
|
}
|
|
useCombatStore.getState().selectMob("shield-a");
|
|
expect(useCombatStore.getState().castAbility("rom-knight-primary-3", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().mobs["shield-a"]!.health).toBeLessThan(10_000);
|
|
expect(useCombatStore.getState().mobs["shield-b"]!.health).toBeLessThan(10_000);
|
|
});
|
|
|
|
it("pays Sacrificial Cure's HP cost while healing an ally", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-sacrifice", 60, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.6));
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
const casterHealth = useCombatStore.getState().health;
|
|
const allyHealth = usePartyStore.getState().members[0]!.health;
|
|
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-40", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().health).toBeLessThan(casterHealth);
|
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
|
|
});
|
|
|
|
it("Life Link lets Urgent Heal also heal the Priest", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-life-link", 30, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5));
|
|
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
const casterHealth = useCombatStore.getState().health;
|
|
const allyHealth = usePartyStore.getState().members[0]!.health;
|
|
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 2_100);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(casterHealth);
|
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
|
|
});
|
|
|
|
it("Last Prayer shortens only the next cast to one second", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-last-prayer", 30, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-25", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 2_600))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeLessThanOrEqual(1_000);
|
|
useCombatStore.getState().tick(0.1, 3_700);
|
|
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 4_300))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(1_000);
|
|
});
|
|
|
|
it("Splendor Ripple adds healing when the Priest casts Wave Armor at level 60", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-ripple", 60, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.getState().damageMember(member.id, Math.floor(member.maxHealth * 0.5));
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
const injured = usePartyStore.getState().members[0]!.health;
|
|
expect(useCombatStore.getState().castAbility("rom-priest-primary-1", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injured);
|
|
});
|
|
|
|
it("casts Devotion Halo on the party and gains Light Guidance at level 70", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-halo", 70, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
|
&& aura.definition.id === "rom-pair-devotion-halo")).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
|
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.id === "rom-pair-devotion-halo-mana")).toBe(false);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.id === "rom-pair-light-guidance-healing")).toBe(true);
|
|
});
|
|
|
|
it("reduces a companion's actual Mana spending while Devotion Halo is active", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 70, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("priest-halo-cost", 70, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const partyMember = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(partyMember.id, [3, 0, 0]);
|
|
const member = {
|
|
...partyMember,
|
|
classId: "rom-priest" as const,
|
|
secondaryClassId: "rom-knight" as const,
|
|
resourceType: "mana" as const,
|
|
resource: 100_000,
|
|
maxResource: 100_000,
|
|
resourcePools: { mana: 100_000 },
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
};
|
|
const spell = abilitiesForCharacter("rom-priest", "rom-knight")
|
|
.find((ability) => !ability.passive && ability.unlockLevel <= 70
|
|
&& ability.cost.resource === "mana" && ability.cost.amount >= 10)!;
|
|
const ordinary = commitPartyAbilityUse(member, spell, 2_000)!;
|
|
expect(ordinary).not.toBeNull();
|
|
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-70-devotion-halo", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
const discounted = commitPartyAbilityUse(member, spell, 2_000)!;
|
|
expect(discounted).not.toBeNull();
|
|
expect(discounted.resourcePools.mana).toBeGreaterThan(ordinary.resourcePools.mana!);
|
|
});
|
|
|
|
it("keeps Blood Punishment's two benefits on the live Knight/Scout Blood Arrow aura", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
|
|
});
|
|
expect(useCombatStore.getState().castAbility("rom-scout-general-4", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
const arrowAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes("rom-self-health-drain"));
|
|
expect(arrowAuras).toHaveLength(1);
|
|
expect(arrowAuras[0]?.definition.modifiers).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "healing", direction: "taken", value: 0.15 }),
|
|
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", value: 0.05 }),
|
|
]));
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-pair-blood-arrow-healing-received"))
|
|
.toBe(false);
|
|
});
|
|
|
|
it("summons the Priest/Knight Light Fairy companion", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 60, role: "healer",
|
|
});
|
|
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-50", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 7_100);
|
|
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
|
|
useCombatStore.getState().tick(1, 8_100);
|
|
expect(useCombatStore.getState().summons.some((summon) => summon.creatureId === 102108)).toBe(true);
|
|
});
|
|
|
|
it("places Disregard Danger's redirect on a teammate and Parry on the Knight", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 60, role: "tank",
|
|
});
|
|
usePartyStore.getState().initializeParty("knight-redirect", 60, useCombatStore.getState().health, "tank", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-50", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
|
&& (aura.definition.redirectDamagePercent ?? 0) > 0)).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === "parry"))).toBe(true);
|
|
});
|
|
|
|
it("shares 40% of one incoming party hit, then leaves later hits with the ally", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 60, role: "tank",
|
|
});
|
|
usePartyStore.getState().initializeParty("knight-redirect-hit", 60, useCombatStore.getState().health, "tank", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-50", undefined, 1_000))
|
|
.toMatchObject({ ok: true });
|
|
const redirectAura = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
|
|
&& aura.definition.utilityTags?.includes("rom-disregard-danger-one-hit"));
|
|
expect(redirectAura?.definition.redirectDamagePercent).toBe(0.4);
|
|
expect(redirectAura?.expiresAt).toBe(11_000);
|
|
|
|
useCombatStore.getState().registerMob("disregard-danger-attacker", {
|
|
name: "Disregard Danger attacker", maxHealth: 200, xpReward: 0,
|
|
});
|
|
setMobPosition("disregard-danger-attacker", [5, 0, 0]);
|
|
useCombatStore.getState().damageMob("disregard-danger-attacker", 5, 1_000, {
|
|
actorId: member.id,
|
|
role: "tank",
|
|
});
|
|
|
|
const playerBeforeFirstHit = useCombatStore.getState().health;
|
|
const memberBeforeFirstHit = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
|
const playerLossOnFirstHit = playerBeforeFirstHit - useCombatStore.getState().health;
|
|
const memberLossOnFirstHit = memberBeforeFirstHit
|
|
- usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
|
const firstHitDamage = playerLossOnFirstHit + memberLossOnFirstHit;
|
|
expect(playerLossOnFirstHit).toBeGreaterThan(0);
|
|
expect(memberLossOnFirstHit).toBeGreaterThan(0);
|
|
expect(playerLossOnFirstHit).toBe(Math.round(firstHitDamage * 0.4));
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === redirectAura?.instanceId)).toBe(false);
|
|
|
|
const playerBeforeSecondHit = useCombatStore.getState().health;
|
|
const memberBeforeSecondHit = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
|
expect(useCombatStore.getState().advanceMobCombat(4_000)).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().health).toBe(playerBeforeSecondHit);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health)
|
|
.toBeLessThan(memberBeforeSecondHit);
|
|
});
|
|
|
|
it("keeps Master of Parry through one block and removes it after the second", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 60, role: "tank",
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, rage: 100 },
|
|
}));
|
|
const castResult = useCombatStore.getState().castAbility("rom-knight-rom-warrior-elite-40", undefined, 1_000);
|
|
expect(castResult.ok, JSON.stringify(castResult)).toBe(true);
|
|
|
|
const masterOfParry = useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.utilityTags?.includes(ROM_KNIGHT_MASTER_OF_PARRY_TAG)
|
|
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "parry")
|
|
));
|
|
expect(masterOfParry?.definition.durationMs).toBe(15_000);
|
|
expect(masterOfParry?.expiresAt).toBe(16_000);
|
|
expect(masterOfParry?.definition.modifiers).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "stat", stat: "parry", operation: "flat", value: 5_000 }),
|
|
]));
|
|
expect(masterOfParry?.procStates).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ procId: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, remainingCharges: 2 }),
|
|
]));
|
|
const timedTriggerAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AGGRO_ID
|
|
&& aura.definition.procs?.some((proc) => (
|
|
proc.action.kind === "custom"
|
|
&& proc.action.id === "rom-trigger-skill"
|
|
&& proc.action.data?.spellId === 493094
|
|
))
|
|
));
|
|
expect(timedTriggerAura?.definition.durationMs).toBe(15_000);
|
|
expect(timedTriggerAura?.expiresAt).toBe(16_000);
|
|
|
|
// Make each incoming melee attack a deterministic successful block so the
|
|
// test exercises the same block event used by normal combat.
|
|
const forcedBlockAuras = applyCombatAura(useCombatStore.getState().auras, {
|
|
definition: {
|
|
id: "test-forced-block",
|
|
name: "Test forced block",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
modifiers: [
|
|
{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 1_000 },
|
|
{ kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: -1_000 },
|
|
{ kind: "stat", stat: "dodge", operation: "flat", value: -1_000 },
|
|
{ kind: "stat", stat: "parry", operation: "flat", value: -10_000 },
|
|
{ kind: "stat", stat: "block-chance", operation: "flat", value: 1_000 },
|
|
],
|
|
},
|
|
sourceId: PLAYER_AGGRO_ID,
|
|
targetId: PLAYER_AGGRO_ID,
|
|
now: 1_000,
|
|
}).auras;
|
|
useCombatStore.setState({ auras: [...forcedBlockAuras] });
|
|
const forcedDefender = characterCombatDefender(useCombatStore.getState());
|
|
expect(forcedDefender.dodgePercent).toBe(0);
|
|
expect(forcedDefender.parryPercent).toBe(0);
|
|
expect(forcedDefender.blockPercent).toBeGreaterThan(100);
|
|
useCombatStore.getState().registerMob("master-of-parry-attacker", {
|
|
name: "Master of Parry attacker", maxHealth: 200, xpReward: 0,
|
|
});
|
|
setMobPosition("master-of-parry-attacker", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("master-of-parry-attacker");
|
|
useCombatStore.getState().damageMob("master-of-parry-attacker", 5, 1_000);
|
|
expect(useCombatStore.getState().mobs["master-of-parry-attacker"]).toMatchObject({ engaged: true });
|
|
|
|
useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
|
|
const firstMobState = useCombatStore.getState().mobs["master-of-parry-attacker"];
|
|
expect(firstMobState?.attackRevision, JSON.stringify(firstMobState)).toBe(1);
|
|
expect(useCombatStore.getState().lastCombatResult?.blocked).toBe(true);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === masterOfParry?.instanceId)
|
|
?.procStates).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ procId: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, remainingCharges: 1 }),
|
|
]));
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === masterOfParry?.instanceId)).toBe(true);
|
|
|
|
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
|
|
expect(useCombatStore.getState().mobs["master-of-parry-attacker"]?.attackRevision).toBe(2);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === masterOfParry?.instanceId)).toBe(false);
|
|
});
|
|
});
|