Update Healer Man server
This commit is contained in:
1 parent
35dfdaed61
commit
4fb7a0b9c8
161 files changed
+24618
-529
No files matched your search
@@ -12,6 +12,7 @@ import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
|
||||
import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
|
||||
import { applyGeneratedBeastWithinTalent } from "./wow335HunterRuntime";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -138,6 +139,7 @@ export interface AbilityAmountScaling {
|
||||
readonly coefficientPerLevel?: number;
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly attackPowerCoefficient?: number;
|
||||
readonly rangedAttackPowerCoefficient?: number;
|
||||
readonly spiritCoefficient?: number;
|
||||
readonly sourceLevel?: number;
|
||||
readonly maxScalingLevel?: number;
|
||||
@@ -214,7 +216,7 @@ export type AbilityEffect =
|
||||
| { readonly kind: "interrupt"; readonly lockoutMs: number }
|
||||
| { readonly kind: "pull"; readonly toward: "caster" | "destination" }
|
||||
| { readonly kind: "execute"; readonly coefficient: number }
|
||||
| { readonly kind: "resource-drain"; readonly amount: number; readonly burn: boolean; readonly resource?: ResourceType; readonly percentage?: boolean }
|
||||
| { readonly kind: "resource-drain"; readonly amount: number; readonly burn: boolean; readonly resource?: ResourceType; readonly percentage?: boolean; readonly recipient?: "caster" }
|
||||
| {
|
||||
readonly kind: "threat";
|
||||
readonly mode: "flat" | "percent" | "redirect";
|
||||
@@ -428,10 +430,7 @@ const WARRIOR = [
|
||||
castTimeMs: 0, cooldownMs: 15_000, gcdMs: 1500, cost: { resource: "rage", amount: 0 },
|
||||
description: "Charges a distant enemy, briefly stunning it and generating Rage.",
|
||||
effects: [
|
||||
{ kind: "damage", coefficient: 0.25 },
|
||||
{ kind: "status", status: "stun", durationMs: 1000 },
|
||||
{ kind: "status", status: "pulled", durationMs: 500 },
|
||||
{ kind: "resource", amount: 15 },
|
||||
],
|
||||
}),
|
||||
defineAbility({
|
||||
@@ -971,9 +970,10 @@ async function preloadClassicAbilityCatalog(): Promise<void> {
|
||||
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
}) => {
|
||||
for (const classId of BASE_CLASS_IDS) {
|
||||
const abilities = mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]);
|
||||
registerClassAbilities(
|
||||
classId,
|
||||
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
|
||||
classId === "hunter" ? abilities.map(applyGeneratedBeastWithinTalent) : abilities,
|
||||
);
|
||||
}
|
||||
for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
|
||||
|
||||
@@ -72,7 +72,7 @@ export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
|
||||
}
|
||||
|
||||
export function abilityRequiresOutOfCombat(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
return (ability.source === "runewaker" || ability.source === "wow335-progression")
|
||||
&& /\bcannot be used (?:when already )?in combat\b/i.test(ability.description);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface CreatureRuntimeBaseStats {
|
||||
readonly armor: number;
|
||||
readonly attackPower: number;
|
||||
readonly rangedAttackPower: number;
|
||||
/** Portion of average physical melee damage contributed by Attack Power. */
|
||||
readonly attackPowerDamageContribution: number;
|
||||
readonly averageAttackDamage: number;
|
||||
readonly baseAttackTimeMs: number;
|
||||
readonly mechanicImmunities: readonly AzerothCoreMechanic[];
|
||||
@@ -159,6 +161,7 @@ export function creatureRuntimeBaseStats(
|
||||
const baseDamage = stats.baseDamage[expansion] ?? stats.baseDamage[0];
|
||||
const attackPowerContribution = stats.attackPower / 14 * template.baseVariance;
|
||||
const attackSpeedMultiplier = Math.max(0.1, template.baseAttackTimeMs / 1_000);
|
||||
const damageScale = template.damageModifier * attackSpeedMultiplier;
|
||||
const minimumDamage = (
|
||||
baseDamage + attackPowerContribution
|
||||
) * template.damageModifier * attackSpeedMultiplier;
|
||||
@@ -174,6 +177,7 @@ export function creatureRuntimeBaseStats(
|
||||
armor,
|
||||
attackPower: stats.attackPower,
|
||||
rangedAttackPower: stats.rangedAttackPower,
|
||||
attackPowerDamageContribution: Math.max(0, attackPowerContribution * damageScale),
|
||||
averageAttackDamage: Math.max(1, Math.round((minimumDamage + maximumDamage) / 2)),
|
||||
baseAttackTimeMs: template.baseAttackTimeMs,
|
||||
mechanicImmunities: template.mechanicImmunities,
|
||||
|
||||
@@ -123,6 +123,8 @@ export type AuraProcAction =
|
||||
export interface AuraProcDefinition {
|
||||
readonly id: string;
|
||||
readonly triggers: ProcTrigger | readonly ProcTrigger[];
|
||||
/** Limit proc rolls to events generated by these catalog ability IDs. */
|
||||
readonly abilityIds?: readonly string[];
|
||||
/** Limit attack-triggered procs to the listed landed attack types. */
|
||||
readonly attackKinds?: readonly AttackKind[];
|
||||
/** Probability from 0 through 1. */
|
||||
@@ -163,7 +165,7 @@ export interface AuraDefinition {
|
||||
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||
readonly utilityTags?: readonly string[];
|
||||
/** Enemy control mechanics ignored while this aura is active. */
|
||||
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence")[];
|
||||
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt")[];
|
||||
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||
readonly reflectSpellChance?: number;
|
||||
/** Portion of incoming target damage transferred to the aura source. */
|
||||
@@ -189,6 +191,8 @@ export interface ActiveAura {
|
||||
readonly procStates: readonly AuraProcState[];
|
||||
/** Cumulative redirected damage for an aura that owns a shared redirect cap. */
|
||||
readonly redirectedDamage?: number;
|
||||
/** Healing reserved by WoW Druid Living Seed until its target takes a direct hit. */
|
||||
readonly druidLivingSeedAmount?: number;
|
||||
/** Number of Fire critical hits landed during the current Combustion buff. */
|
||||
readonly combustionCriticalHits?: number;
|
||||
}
|
||||
@@ -580,6 +584,8 @@ export function evaluateAuraProcs(
|
||||
if (
|
||||
proc === undefined ||
|
||||
!procHasTrigger(proc, event.trigger) ||
|
||||
(proc.abilityIds !== undefined
|
||||
&& (event.abilityId === undefined || !proc.abilityIds.includes(event.abilityId))) ||
|
||||
(proc.attackKinds !== undefined
|
||||
&& (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) ||
|
||||
state.readyAt > now ||
|
||||
|
||||
@@ -53,6 +53,27 @@ function registerTarget(
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
function stableUnit(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (const character of seed) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function paladinVindicationProcTime(
|
||||
sourceActorId: string,
|
||||
abilityId: string,
|
||||
targetId: string,
|
||||
chance: number,
|
||||
): number {
|
||||
for (let now = 1_000; now < 100_000; now += 1) {
|
||||
if (stableUnit(`${sourceActorId}:${abilityId}:${targetId}:${now}:wow335-paladin-vindication`) < chance) return now;
|
||||
}
|
||||
throw new Error("Could not find a seeded Vindication proc time.");
|
||||
}
|
||||
|
||||
function unlockReadySpell(id: string): void {
|
||||
const ability = useCombatStore.getState().abilities.find((entry) => entry.id === id);
|
||||
expect(ability, id).toBeDefined();
|
||||
@@ -92,6 +113,33 @@ describe("combat store", () => {
|
||||
usePartyStore.getState().initializeParty("combat-store-tests", 1, useCombatStore.getState().health);
|
||||
});
|
||||
|
||||
it("procs Vindication from a Paladin Holy hit with the generated rank chance, amount, duration, and owner", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80, xp: 0 });
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1633": 3 },
|
||||
}));
|
||||
registerTarget("vindication-target", [2, 0, 0], 10_000, 0);
|
||||
const abilityId = "wow335-paladin-exorcism";
|
||||
const now = paladinVindicationProcTime(PLAYER_AGGRO_ID, abilityId, "vindication-target", 0.65);
|
||||
|
||||
expect(useCombatStore.getState().damageMob(
|
||||
"vindication-target",
|
||||
25,
|
||||
now,
|
||||
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId },
|
||||
"holy",
|
||||
).amount).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs["vindication-target"].auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "wow335:paladin:vindication",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
sourceAbilityId: abilityId,
|
||||
endsAt: now + 10_000,
|
||||
magnitude: 357,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("completes a ranged cast after its exact WoW 3.3.5a cast time", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
const startingResource = useCombatStore.getState().maxResource;
|
||||
|
||||
+4216
-251
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./party
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { talentNodesForTree } from "./talentCatalog";
|
||||
|
||||
@@ -60,6 +61,31 @@ function registerXpTarget(id: string, xpReward: number) {
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
}
|
||||
|
||||
function installAntiMagicShell(targetId: string, maximumHealth: number) {
|
||||
const result = applyCombatAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: "wow335:48707:anti-magic-shell",
|
||||
name: "Anti-Magic Shell",
|
||||
disposition: "buff",
|
||||
durationMs: 5_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: ["wow335-death-knight-anti-magic-shell"],
|
||||
absorbs: [{
|
||||
id: "wow335:48707:anti-magic-shell-absorb",
|
||||
amount: maximumHealth * 0.5,
|
||||
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
|
||||
fraction: 0.75,
|
||||
}],
|
||||
},
|
||||
sourceId: targetId,
|
||||
targetId,
|
||||
now,
|
||||
});
|
||||
useCombatStore.setState({ auras: [...result.auras] });
|
||||
return result.aura;
|
||||
}
|
||||
|
||||
describe("Death Knight source-backed runtime mechanics", () => {
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
@@ -117,6 +143,7 @@ describe("Death Knight source-backed runtime mechanics", () => {
|
||||
it("absorbs three quarters of magical damage up to half of maximum health", () => {
|
||||
const shell = ability("Anti-Magic Shell");
|
||||
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
|
||||
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
||||
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& candidate.definition.name === "Anti-Magic Shell");
|
||||
expect(aura?.absorbs[0]).toMatchObject({
|
||||
@@ -127,10 +154,141 @@ describe("Death Knight source-backed runtime mechanics", () => {
|
||||
|
||||
now += 1;
|
||||
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(1.5);
|
||||
expect(useCombatStore.getState().resource).toBe(1.5);
|
||||
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId)
|
||||
?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75);
|
||||
});
|
||||
|
||||
it("caps AMS absorption at half maximum health before awarding proportional Runic Power", () => {
|
||||
useCombatStore.setState({
|
||||
health: 1_000,
|
||||
maxHealth: 1_000,
|
||||
resource: 20,
|
||||
resourcePools: { "runic-power": 20 },
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
||||
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
||||
|
||||
expect(useCombatStore.getState().damagePlayer(1_000, "fire", undefined, now + 1)).toBe(500);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(10);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))?.absorbs[0].remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("grants Runic Power for magic absorbed during the normal enemy-attack path", () => {
|
||||
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
||||
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
||||
const shell = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))!;
|
||||
const capacityBefore = shell.absorbs[0].remaining;
|
||||
useCombatStore.getState().registerMob("ams-player-attacker", {
|
||||
name: "AMS player attacker",
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
id: "ams-player-fire",
|
||||
name: "Fire strike",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "fire",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
damageMultiplier: 1,
|
||||
}],
|
||||
});
|
||||
setMobPosition("ams-player-attacker", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("ams-player-attacker", 1, now);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
const shellAfterHit = useCombatStore.getState().auras.find((aura) => aura.instanceId === shell.instanceId);
|
||||
const absorbed = capacityBefore - (shellAfterHit?.absorbs[0].remaining ?? 0);
|
||||
expect(absorbed).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02);
|
||||
});
|
||||
|
||||
it("clamps AMS Runic Power gain and ignores physical, missing, and expired shells", () => {
|
||||
expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true);
|
||||
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
|
||||
useCombatStore.getState().damagePlayer(100, "physical", undefined, now + 1);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
||||
|
||||
useCombatStore.setState({ auras: [] });
|
||||
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 2);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
|
||||
|
||||
const shell = installAntiMagicShell(PLAYER_AURA_ENTITY_ID, useCombatStore.getState().maxHealth);
|
||||
useCombatStore.setState({
|
||||
resource: 99,
|
||||
resourcePools: { "runic-power": 99 },
|
||||
auras: useCombatStore.getState().auras.map((aura) => aura.instanceId === shell.instanceId
|
||||
? { ...aura, expiresAt: now + 3 }
|
||||
: aura),
|
||||
});
|
||||
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 4);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(99);
|
||||
|
||||
useCombatStore.setState({
|
||||
resource: 99,
|
||||
resourcePools: { "runic-power": 99 },
|
||||
auras: [shell],
|
||||
});
|
||||
useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 1);
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100);
|
||||
});
|
||||
|
||||
it("grants a Death Knight companion Runic Power from its own live AMS absorb", () => {
|
||||
const target = usePartyStore.getState().members[0];
|
||||
if (!target) throw new Error("Expected a party member for the companion AMS test.");
|
||||
const companion = {
|
||||
...target,
|
||||
classId: "death-knight" as const,
|
||||
resourceType: "runic-power" as const,
|
||||
resource: 0,
|
||||
maxResource: 100,
|
||||
resourcePools: { ...target.resourcePools, "runic-power": 0 },
|
||||
};
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === target.id ? companion : member),
|
||||
}));
|
||||
useCombatStore.setState((state) => {
|
||||
const actors = { ...state.actors };
|
||||
delete actors[target.id];
|
||||
return { ...state, actors };
|
||||
});
|
||||
registerPartyRuntimePosition(target.id, [0, 0, 0]);
|
||||
const aura = installAntiMagicShell(target.id, companion.maxHealth);
|
||||
useCombatStore.getState().registerMob("ams-companion-attacker", {
|
||||
name: "AMS companion attacker",
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
id: "ams-companion-fire",
|
||||
name: "Fire strike",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "fire",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
damageMultiplier: 1,
|
||||
}],
|
||||
});
|
||||
setMobPosition("ams-companion-attacker", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("ams-companion-attacker", 1, now, {
|
||||
actorId: target.id,
|
||||
role: target.role,
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
const updated = usePartyStore.getState().members.find((member) => member.id === target.id)!;
|
||||
const shellAfterHit = useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura.instanceId);
|
||||
const absorbed = companion.maxHealth * 0.5 - (shellAfterHit?.absorbs[0].remaining ?? 0);
|
||||
expect(absorbed).toBeGreaterThan(0);
|
||||
expect(updated.resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02);
|
||||
});
|
||||
|
||||
it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => {
|
||||
const shell = ability("Anti-Magic Shell");
|
||||
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
|
||||
|
||||
@@ -20,7 +20,7 @@ export type EnemyDamageSchool =
|
||||
| "shadow"
|
||||
| "holy";
|
||||
|
||||
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence";
|
||||
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt";
|
||||
|
||||
/** Frost-style slows preserve actions while reducing authored movement speed. */
|
||||
export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6;
|
||||
@@ -28,13 +28,13 @@ export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6;
|
||||
export function enemyControlPreventsActions(
|
||||
mechanic: EnemyControlMechanic | null,
|
||||
): boolean {
|
||||
return mechanic !== null && mechanic !== "root" && mechanic !== "slow";
|
||||
return mechanic !== null && mechanic !== "root" && mechanic !== "slow" && mechanic !== "interrupt";
|
||||
}
|
||||
|
||||
export function enemyControlPreventsMovement(
|
||||
mechanic: EnemyControlMechanic | null,
|
||||
): boolean {
|
||||
return mechanic !== null && mechanic !== "slow";
|
||||
return mechanic !== null && mechanic !== "slow" && mechanic !== "interrupt";
|
||||
}
|
||||
|
||||
export function enemyControlMovementMultiplier(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import {
|
||||
normalizeEnemyAttack,
|
||||
@@ -59,6 +60,51 @@ describe("enemy casting and interrupts", () => {
|
||||
expect(normalizeEnemyAttack({ ...withoutCastTime, animation: "attack" }).castTimeMs).toBe(0);
|
||||
});
|
||||
|
||||
it("reduces modeled enemy melee damage by the active Vindication AP share", () => {
|
||||
const swingDamage = (vindicated: boolean): number => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("ap-attacker", {
|
||||
name: "AP attacker",
|
||||
serverEntry: 598,
|
||||
level: 80,
|
||||
maxHealth: 10_000,
|
||||
xpReward: 0,
|
||||
attacks: [DEFAULT_ENEMY_ATTACK],
|
||||
});
|
||||
setMobPosition("ap-attacker", [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob("ap-attacker");
|
||||
useCombatStore.getState().damageMob("ap-attacker", 1, 1_000);
|
||||
if (vindicated) {
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
"ap-attacker": {
|
||||
...state.mobs["ap-attacker"],
|
||||
auras: [{
|
||||
id: "wow335:paladin:vindication",
|
||||
sourceAbilityId: "wow335-paladin-exorcism",
|
||||
sourceActorId: "paladin-caster",
|
||||
endsAt: 11_000,
|
||||
magnitude: 357,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
const healthBeforeSwing = useCombatStore.getState().health;
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
return healthBeforeSwing - useCombatStore.getState().health;
|
||||
};
|
||||
|
||||
const unmodified = swingDamage(false);
|
||||
const reduced = swingDamage(true);
|
||||
expect(unmodified).toBeGreaterThan(0);
|
||||
expect(reduced).toBeGreaterThan(0);
|
||||
expect(reduced).toBeLessThan(unmodified);
|
||||
});
|
||||
|
||||
it("delays an enemy spell effect until its cast completes", () => {
|
||||
registerCaster([spell("shadow-bolt", "shadow")]);
|
||||
const startingHealth = useCombatStore.getState().health;
|
||||
@@ -130,6 +176,61 @@ describe("enemy casting and interrupts", () => {
|
||||
expect(useCombatStore.getState().mobs.caster.schoolLockedUntil).toEqual({});
|
||||
});
|
||||
|
||||
it("grants Improved Kick's capstone only for a successful Kick interrupt", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||
registerCaster([spell("shadow-bolt", "shadow", 2_000)]);
|
||||
useCombatStore.getState().advanceMobCombat(1_000);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-206": 2 },
|
||||
resource: 70,
|
||||
resourcePools: { ...state.resourcePools, energy: 70 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true);
|
||||
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().resourcePools.energy).toBe(85);
|
||||
const bonus = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("wow335-rogue-improved-kick")
|
||||
));
|
||||
expect(bonus).toMatchObject({ appliedAt: 1_100, expiresAt: 7_100 });
|
||||
expect(bonus?.definition.modifiers).toEqual([
|
||||
{ kind: "stat", stat: "melee-haste", operation: "flat", value: 35 },
|
||||
{ kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 },
|
||||
]);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
autoAttackTargetId: "caster",
|
||||
nextAutoAttackAt: 1_101,
|
||||
}));
|
||||
useCombatStore.getState().tick(0, 1_101);
|
||||
const buffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 1_101;
|
||||
useCombatStore.setState((state) => ({ nextAutoAttackAt: 7_101 }));
|
||||
useCombatStore.getState().tick(0, 7_101);
|
||||
const unbuffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 7_101;
|
||||
expect(buffedSwingInterval).toBeLessThan(unbuffedSwingInterval);
|
||||
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("wow335-rogue-improved-kick")
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not grant Improved Kick's interrupt rider when Kick finds no cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||
registerCaster([spell("shadow-bolt", "shadow", 2_000)]);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-206": 2 },
|
||||
resource: 70,
|
||||
resourcePools: { ...state.resourcePools, energy: 70 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true);
|
||||
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().resourcePools.energy).toBe(45);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("wow335-rogue-improved-kick")
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels casts immediately when crowd control lands", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 4 });
|
||||
registerCaster([spell("shadow-bolt", "shadow", 2_000)]);
|
||||
|
||||
@@ -300,6 +300,17 @@ describe("catalog-driven party rotations", () => {
|
||||
})).toBe(17);
|
||||
});
|
||||
|
||||
it("applies Savage Fury only to its named party Druid damage abilities", () => {
|
||||
const base = { ...memberFor("druid", "damage"), gearStats: EMPTY_ITEM_STATS, talentRanks: {} };
|
||||
const talented = { ...base, talentRanks: { "talent-805": 3 } };
|
||||
const claw = abilitiesForClass("druid").find((candidate) => candidate.name === "Claw")!;
|
||||
const wrath = abilitiesForClass("druid").find((candidate) => candidate.name === "Wrath")!;
|
||||
const damage = { kind: "damage" as const, coefficient: 0, basePoints: 100, dieSides: 1, sourceLevel: 80 };
|
||||
|
||||
expect(partyAbilityAmount(talented, claw, damage)).toBeCloseTo(partyAbilityAmount(base, claw, damage) * 1.12);
|
||||
expect(partyAbilityAmount(talented, wrath, damage)).toBe(partyAbilityAmount(base, wrath, damage));
|
||||
});
|
||||
|
||||
it("scales Envenom from consumed Deadly Poison doses", () => {
|
||||
const member = {
|
||||
...memberFor("rogue", "damage"),
|
||||
|
||||
+125
-11
@@ -19,6 +19,18 @@ import type { PartyMember } from "./partyStore";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy";
|
||||
import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime";
|
||||
import {
|
||||
WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID,
|
||||
wow335MageImprovedCounterspellCooldownReductionMs,
|
||||
} from "./wow335MageImprovedCounterspellRuntime";
|
||||
import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime";
|
||||
import {
|
||||
romPriestWarriorStanceRequired,
|
||||
romPriestWarriorStanceUseAllowed,
|
||||
} from "./romPriestKnightOverrides";
|
||||
import { romKnightWarriorWhirlwindAdjustment } from "./romKnightOverrides";
|
||||
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
readonly now: number;
|
||||
@@ -51,9 +63,82 @@ function currentResource(member: PartyMember, type: ResourceType): number {
|
||||
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
|
||||
}
|
||||
|
||||
function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id);
|
||||
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => {
|
||||
export function partyHunterLockAndLoadReady(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
now?: number,
|
||||
): boolean {
|
||||
if (member.classId !== "hunter"
|
||||
|| !["Arcane Shot", "Explosive Shot"].includes(ability.name)
|
||||
|| Math.max(0, Math.trunc(member.talentRanks["talent-1306"] ?? 0)) <= 0) return false;
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.stacks > 0
|
||||
&& aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
|
||||
}
|
||||
|
||||
export function partyAbilityWithHunterLockAndLoad(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
now: number,
|
||||
): AbilityDefinition {
|
||||
if (!partyHunterLockAndLoadReady(member, ability, now)) return ability;
|
||||
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
|
||||
cost.resource === "mana" ? { ...cost, amount: 0 } : cost
|
||||
));
|
||||
return { ...ability, cooldownMs: 0, cost: costs[0] ?? ability.cost, costs };
|
||||
}
|
||||
|
||||
function partyKnightWarriorWhirlwindAdjustment(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): ReturnType<typeof romKnightWarriorWhirlwindAdjustment> {
|
||||
if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-warrior") {
|
||||
return { rageCostReduction: 0, cooldownReductionMs: 0 };
|
||||
}
|
||||
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.source === "runewaker"
|
||||
&& candidate.classId === "rom-knight"
|
||||
&& candidate.secondaryClassId === "rom-warrior"
|
||||
&& candidate.dbcSpellId === 491639
|
||||
&& candidate.passive
|
||||
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
|
||||
.map((candidate) => candidate.dbcSpellId);
|
||||
return romKnightWarriorWhirlwindAdjustment(
|
||||
member.classId,
|
||||
member.secondaryClassId,
|
||||
ability,
|
||||
learnedPassiveSpellIds,
|
||||
);
|
||||
}
|
||||
|
||||
function partyAbilityWithKnightWarriorWhirlwind(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): AbilityDefinition {
|
||||
const adjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
|
||||
if (adjustment.rageCostReduction <= 0 && adjustment.cooldownReductionMs <= 0) return ability;
|
||||
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
|
||||
cost.resource === "rage" && !cost.percentage
|
||||
? { ...cost, amount: Math.max(0, cost.amount - adjustment.rageCostReduction) }
|
||||
: cost
|
||||
));
|
||||
return {
|
||||
...ability,
|
||||
cooldownMs: Math.max(0, ability.cooldownMs - adjustment.cooldownReductionMs),
|
||||
cost: costs[0] ?? ability.cost,
|
||||
costs,
|
||||
};
|
||||
}
|
||||
|
||||
export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinition, now?: number): readonly { resource: ResourceType; amount: number }[] {
|
||||
const adjustedAbility = partyAbilityWithKnightWarriorWhirlwind(member, ability);
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
|
||||
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
|
||||
const mageClearcasting = member.classId === "mage" && auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)
|
||||
));
|
||||
return (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
|
||||
const maximum = cost.percentageBasis === "base-mana"
|
||||
? baseResourcesFor(member.classId, member.level).mana
|
||||
: cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource);
|
||||
@@ -64,7 +149,12 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly
|
||||
"power",
|
||||
"cost",
|
||||
);
|
||||
return { resource: cost.resource, amount: Math.max(0, Math.round(adjusted)) };
|
||||
return {
|
||||
resource: cost.resource,
|
||||
amount: cost.resource === "mana" && partyHunterLockAndLoadReady(member, ability, now)
|
||||
? 0
|
||||
: cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,10 +165,17 @@ export function partyAbilityIsReady(
|
||||
ignoreResourceCosts = false,
|
||||
): boolean {
|
||||
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
||||
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now) || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if (romPriestWarriorStanceRequired(ability)) {
|
||||
const activeAuraTags = useCombatStore.getState().auras
|
||||
.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []);
|
||||
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
|
||||
}
|
||||
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
|
||||
|| (!partyHunterLockAndLoadReady(member, ability, now) && (member.cooldowns[ability.id] ?? 0) > now)) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health"
|
||||
if (!ignoreResourceCosts && partyAbilityCosts(member, ability, now).some(cost => cost.resource === "health"
|
||||
? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false;
|
||||
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
|
||||
@@ -116,7 +213,7 @@ function shouldReserveForHigherPriority(
|
||||
if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
const categories = aiHintsForAbility(candidate).categories;
|
||||
if (!categories.includes("damage") || categories.includes("execute")) return false;
|
||||
const blockedCosts = abilityCosts(member, candidate).filter((cost) => (
|
||||
const blockedCosts = partyAbilityCosts(member, candidate).filter((cost) => (
|
||||
cost.amount > currentResource(member, cost.resource)
|
||||
));
|
||||
if (!blockedCosts.length) return false;
|
||||
@@ -216,7 +313,7 @@ export function commitPartyAbilityUse(
|
||||
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
|
||||
let resource = member.resource;
|
||||
let health = member.health;
|
||||
for (const cost of options.ignoreResourceCosts ? [] : abilityCosts(member, ability)) {
|
||||
for (const cost of options.ignoreResourceCosts ? [] : partyAbilityCosts(member, ability, now)) {
|
||||
const amount = cost.amount;
|
||||
if (cost.resource === "health") { health -= amount; continue; }
|
||||
const current = currentResource(member, cost.resource);
|
||||
@@ -227,6 +324,14 @@ export function commitPartyAbilityUse(
|
||||
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
|
||||
if (!runes) return null;
|
||||
const cooldownMs = member.classId === "mage"
|
||||
? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs(
|
||||
member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0,
|
||||
ability.name,
|
||||
))
|
||||
: ability.cooldownMs;
|
||||
const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs;
|
||||
const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs);
|
||||
return {
|
||||
...member,
|
||||
health,
|
||||
@@ -234,8 +339,8 @@ export function commitPartyAbilityUse(
|
||||
resourcePools,
|
||||
lastResourceSpentAt,
|
||||
runes,
|
||||
cooldowns: ability.cooldownMs > 0
|
||||
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
||||
cooldowns: adjustedCooldownMs > 0
|
||||
? { ...member.cooldowns, [ability.id]: now + adjustedCooldownMs }
|
||||
: member.cooldowns,
|
||||
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
|
||||
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||
@@ -273,11 +378,20 @@ export function partyAbilityAmount(
|
||||
: effect.kind === "finisher-damage"
|
||||
? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
|
||||
: 0;
|
||||
return equipmentAdjustedCombatAmount(
|
||||
const baseAmount = equipmentAdjustedCombatAmount(
|
||||
"coefficient" in effect ? effect : coefficient,
|
||||
member.level,
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
|
||||
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
|
||||
return applyTalentModifierValue(
|
||||
baseAmount,
|
||||
talentModifiersForRanks(member.talentRanks, "druid"),
|
||||
"damage-percent",
|
||||
{ abilityName: ability.name },
|
||||
);
|
||||
}
|
||||
return baseAmount;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useCombatStore, type EnemyActiveCast } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
@@ -23,8 +23,9 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { baseClassFor } from "../app/characterCatalog";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
@@ -36,6 +37,60 @@ function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
}
|
||||
}
|
||||
|
||||
function stableUnit(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (const character of seed) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function partyHealCriticalAt(sourceId: string, abilityId: string, targetId: string, chance: number): number {
|
||||
for (let now = 10_000; now < 1_000_000; now += 1) {
|
||||
if (stableUnit(`${sourceId}:${abilityId}:${targetId}:${now}:heal`) < chance) return now;
|
||||
}
|
||||
throw new Error("Could not find a seeded companion healing crit.");
|
||||
}
|
||||
|
||||
function createFrostbitePartyMage(seed: string) {
|
||||
const mage = createConfiguredPartyMember({
|
||||
name: "Frostbite Mage",
|
||||
classId: "mage",
|
||||
specialization: "Frost",
|
||||
specializationId: "mage:frost",
|
||||
talentTreeId: "mage-frost",
|
||||
talentRanks: { "talent-38": 3 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...mage,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...mage.resourcePools, mana: 10_000 },
|
||||
}] });
|
||||
registerPartyRuntimePosition(mage.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
return mage;
|
||||
}
|
||||
|
||||
function registerFrostbiteTarget(id: string): void {
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
level: 1,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(id, 1, 100);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
updatePartyRuntimeTargetVisibility(member.id, id, true);
|
||||
}
|
||||
usePartyStore.setState({ command: "attack", activeMobId: id });
|
||||
}
|
||||
|
||||
describe("party combat runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
@@ -75,6 +130,361 @@ describe("party combat runtime", () => {
|
||||
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
|
||||
});
|
||||
|
||||
it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const priest = createConfiguredPartyMember({
|
||||
name: "Shadow Priest",
|
||||
classId: "priest",
|
||||
specialization: "Shadow",
|
||||
specializationId: "priest:shadow",
|
||||
talentTreeId: "priest-shadow",
|
||||
role: "damage",
|
||||
}, 80, 0, "pain-and-suffering-companion");
|
||||
const priestAbilities = abilitiesForClass("priest");
|
||||
const shadowWordPainBase = priestAbilities.find((ability) => ability.name === "Shadow Word: Pain")!;
|
||||
const shadowWordPain = abilityAtLevel(shadowWordPainBase, 80);
|
||||
const shadowWordPainDot = shadowWordPain.effects.find((effect) => effect.kind === "dot")!;
|
||||
const mindBlast = abilityAtLevel(priestAbilities.find((ability) => ability.name === "Mind Blast")!, 80);
|
||||
const targetId = "pain-and-suffering-companion-target";
|
||||
usePartyStore.getState().initializeParty("pain-and-suffering-companion", 80,
|
||||
useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [{
|
||||
...priest,
|
||||
talentRanks: { "talent-1909": 3 },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
activeCast: {
|
||||
abilityId: mindBlast.id,
|
||||
school: "shadow",
|
||||
mode: "cast",
|
||||
startedAt: 10_000,
|
||||
completesAt: 10_000 + mindBlast.castTimeMs,
|
||||
originalDurationMs: mindBlast.castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(priest.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 100_000);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(
|
||||
priest.id, targetId, shadowWordPain, shadowWordPainDot, 1_000,
|
||||
)).toBe(true);
|
||||
const before = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId
|
||||
))!;
|
||||
|
||||
advanceDungeonPartyCombat(mindBlast.castTimeMs + 10_000);
|
||||
const after = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId
|
||||
))!;
|
||||
expect(after.remainingTicks).toBe(shadowWordPainDot.ticks);
|
||||
expect(after.nextTickAt).toBe(10_000 + mindBlast.castTimeMs + after.intervalMs);
|
||||
expect(after.fixedAmount).toBe(before.fixedAmount);
|
||||
expect(after.intervalMs).toBe(before.intervalMs);
|
||||
});
|
||||
|
||||
it("lets a party Mage Frostbite freeze after landed direct Frostbolt damage", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "party-mage-frostbite-direct";
|
||||
const mage = createFrostbitePartyMage(seed);
|
||||
const frostbolt = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!, 80);
|
||||
const targetId = "party-mage-frostbite-direct-target";
|
||||
const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
||||
.find((now) => stableUnit(`${mage.id}:${frostbolt.id}:${targetId}:${now}:wow335-mage-frostbite`) < 0.15);
|
||||
if (impactAt === undefined) throw new Error("Could not find a seeded party Frostbite proc time.");
|
||||
usePartyStore.setState({ members: [{
|
||||
...usePartyStore.getState().members[0]!,
|
||||
activeCast: {
|
||||
abilityId: frostbolt.id,
|
||||
school: "frost",
|
||||
mode: "cast",
|
||||
startedAt: impactAt - frostbolt.castTimeMs,
|
||||
completesAt: impactAt,
|
||||
originalDurationMs: frostbolt.castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerFrostbiteTarget(targetId);
|
||||
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
||||
|
||||
advanceDungeonPartyCombat(impactAt - frostbolt.castTimeMs);
|
||||
advanceDungeonPartyCombat(impactAt);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth);
|
||||
expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({
|
||||
kind: "freeze",
|
||||
sourceAbilityId: frostbolt.id,
|
||||
sourceActorId: mage.id,
|
||||
endsAt: impactAt + 4_000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("lets a party Mage Frostbite proc on a landed Blizzard channel pulse", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "party-mage-frostbite-blizzard";
|
||||
const mage = createFrostbitePartyMage(seed);
|
||||
const blizzard = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Blizzard")!, 80);
|
||||
const targetId = "party-mage-frostbite-blizzard-target";
|
||||
const durationMs = blizzard.castTimeMs;
|
||||
expect(blizzard.castMode).toBe("channel");
|
||||
expect(durationMs).toBeGreaterThan(0);
|
||||
|
||||
let startedAt = 0;
|
||||
let procAt = 0;
|
||||
for (let start = 10_000; start < 20_000 && procAt === 0; start += 1) {
|
||||
const pulses = Array.from({ length: channelPulseCount(blizzard) }, (_, index) => (
|
||||
channelPulseAt(blizzard, start, durationMs, index)
|
||||
));
|
||||
const proc = pulses.find((pulseAt) => (
|
||||
stableUnit(`${mage.id}:${blizzard.id}:${targetId}:${pulseAt}:wow335-mage-frostbite`) < 0.15
|
||||
));
|
||||
if (proc !== undefined) {
|
||||
startedAt = start;
|
||||
procAt = proc;
|
||||
}
|
||||
}
|
||||
if (procAt === 0) throw new Error("Could not find a seeded party Blizzard Frostbite pulse.");
|
||||
usePartyStore.setState({ members: [{
|
||||
...usePartyStore.getState().members[0]!,
|
||||
activeCast: {
|
||||
abilityId: blizzard.id,
|
||||
school: "frost",
|
||||
mode: "channel",
|
||||
startedAt,
|
||||
completesAt: startedAt + durationMs,
|
||||
originalDurationMs: durationMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
channelTicksCompleted: 0,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerFrostbiteTarget(targetId);
|
||||
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
||||
|
||||
advanceDungeonPartyCombat(startedAt);
|
||||
advanceDungeonPartyCombat(procAt);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth);
|
||||
expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({
|
||||
kind: "freeze",
|
||||
sourceAbilityId: blizzard.id,
|
||||
sourceActorId: mage.id,
|
||||
endsAt: procAt + 4_000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("lets a party Mage proc its learned Eruption passive on Fireball", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80 });
|
||||
const seed = "party-rom-mage-eruption";
|
||||
const mage = createConfiguredPartyMember({
|
||||
name: "Eruption Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const fireball = abilitiesForClass("rom-mage").find((ability) => ability.name === "Fireball")!;
|
||||
const targetId = "party-rom-mage-eruption-target";
|
||||
const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
||||
.find((now) => stableUnit(`${mage.id}:${fireball.id}:${targetId}:${now}:rom-eruption`) < 0.1);
|
||||
if (impactAt === undefined) throw new Error("Could not find a seeded party Eruption proc time.");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
usePartyStore.setState({ members: [{
|
||||
...mage,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...mage.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: fireball.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: impactAt,
|
||||
completesAt: impactAt,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(mage.id, [0, 0, 0]);
|
||||
registerFrostbiteTarget(targetId);
|
||||
updatePartyRuntimeTargetVisibility(mage.id, targetId, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(impactAt);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(99_999);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === mage.id
|
||||
&& aura.definition.id === "rom-mage-druid-control-flame-buff")).toBe(true);
|
||||
});
|
||||
|
||||
it("procs a party Paladin's source-owned Vindication from its completed Holy spell", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "vindication-paladin-party";
|
||||
const paladin = createConfiguredPartyMember({
|
||||
name: "Vindication Paladin",
|
||||
classId: "paladin",
|
||||
specialization: "Retribution",
|
||||
specializationId: "paladin:retribution",
|
||||
talentTreeId: "paladin-retribution",
|
||||
talentRanks: { "talent-1633": 3 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const exorcism = abilityAtLevel(abilitiesForClass("paladin").find((ability) => ability.name === "Exorcism")!, 80);
|
||||
const targetId = "vindication-paladin-party-target";
|
||||
const completionAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
||||
.find((now) => stableUnit(`${paladin.id}:${exorcism.id}:${targetId}:${now}:wow335-paladin-vindication`) < 0.65);
|
||||
if (completionAt === undefined) throw new Error("Could not find a seeded party Vindication proc time.");
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...paladin,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
activeCast: {
|
||||
abilityId: exorcism.id,
|
||||
school: "holy",
|
||||
mode: "cast",
|
||||
startedAt: completionAt - exorcism.castTimeMs,
|
||||
completesAt: completionAt,
|
||||
originalDurationMs: exorcism.castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(paladin.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 100_000);
|
||||
|
||||
advanceDungeonPartyCombat(completionAt);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId].auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "wow335:paladin:vindication",
|
||||
sourceActorId: paladin.id,
|
||||
sourceAbilityId: exorcism.id,
|
||||
endsAt: completionAt + 10_000,
|
||||
magnitude: 357,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("grants a party Rogue Improved Kick only after it interrupts an active cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "improved-kick-party-runtime";
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "Combat Rogue",
|
||||
classId: "rogue",
|
||||
specialization: "Combat",
|
||||
specializationId: "rogue:combat",
|
||||
talentTreeId: "rogue-combat",
|
||||
talentRanks: { "talent-206": 2 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const kick = abilitiesForClass("rogue").find((ability) => ability.name === "Kick");
|
||||
if (!kick) throw new Error("Missing generated Rogue Kick");
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...rogue,
|
||||
resource: 0,
|
||||
resourcePools: { ...rogue.resourcePools, energy: 0 },
|
||||
activeCast: {
|
||||
abilityId: kick.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: 900,
|
||||
completesAt: 900,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: "improved-kick-party-target",
|
||||
},
|
||||
activeCastTargetId: "improved-kick-party-target",
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const targetId = "improved-kick-party-target";
|
||||
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(rogue.id, targetId, true);
|
||||
const hostileCast: EnemyActiveCast = {
|
||||
attackId: "party-test-shadow-bolt",
|
||||
name: "Shadow Bolt",
|
||||
spellId: 686,
|
||||
school: "shadow",
|
||||
interruptible: true,
|
||||
targetActorId: null,
|
||||
sourcePosition: [2, 0, 0],
|
||||
targetPosition: [0, 0, 0],
|
||||
sourceYaw: 0,
|
||||
startedAt: 500,
|
||||
completesAt: 2_500,
|
||||
};
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, activeCast: hostileCast } },
|
||||
}));
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: rogue.id,
|
||||
targetId: rogue.id,
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 7_000,
|
||||
definition: expect.objectContaining({
|
||||
utilityTags: ["wow335-rogue-improved-kick"],
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "melee-haste", operation: "flat", value: 35 },
|
||||
{ kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
expect(usePartyStore.getState().members[0]?.resourcePools.energy).toBe(40);
|
||||
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => ({
|
||||
...member,
|
||||
nextActionAt: 10_000,
|
||||
gearStats: { ...member.gearStats, hasteRating: 0 },
|
||||
})),
|
||||
}));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const autoAttackTimes: number[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event"
|
||||
&& message.event.sourceActorId === rogue.id
|
||||
&& message.event.abilityId === "party-auto-attack"
|
||||
&& message.event.phase === "impact") {
|
||||
autoAttackTimes.push(message.event.occurredAt);
|
||||
}
|
||||
});
|
||||
advanceDungeonPartyCombat(2_200);
|
||||
advanceDungeonPartyCombat(3_400);
|
||||
unsubscribe();
|
||||
expect(autoAttackTimes).toEqual([2_200, 3_400]);
|
||||
});
|
||||
|
||||
it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const tank = createConfiguredPartyMember({
|
||||
@@ -354,6 +764,62 @@ describe("party combat runtime", () => {
|
||||
.toBeGreaterThan(startingHealth);
|
||||
});
|
||||
|
||||
it("applies a Shaman owner's Elemental Oath to party-caster healing crits", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80 });
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-2049": 3 },
|
||||
}));
|
||||
const tank = createConfiguredPartyMember({
|
||||
name: "Tank", classId: "warrior", specialization: "Protection",
|
||||
specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank",
|
||||
}, 80, 0, "elemental-oath-party-heal");
|
||||
const healer = createConfiguredPartyMember({
|
||||
name: "Healer", classId: "priest", specialization: "Holy",
|
||||
specializationId: "priest:holy", talentTreeId: "priest-holy", role: "healer",
|
||||
}, 80, 1, "elemental-oath-party-heal");
|
||||
const greaterHeal = abilitiesForClass("priest").find((ability) => ability.name === "Greater Heal")!;
|
||||
const startingHealth = Math.round(tank.maxHealth * 0.3);
|
||||
usePartyStore.getState().initializeParty("elemental-oath-party-heal", 80,
|
||||
useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({
|
||||
members: [
|
||||
{ ...tank, health: startingHealth, gearStats: EMPTY_ITEM_STATS },
|
||||
{
|
||||
...healer,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resourceType: "mana",
|
||||
resourceName: "Mana",
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { mana: 10_000 },
|
||||
cooldowns: Object.fromEntries(abilitiesForClass("priest")
|
||||
.filter((ability) => ability.id !== greaterHeal.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
},
|
||||
],
|
||||
});
|
||||
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(healer.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("elemental-oath-party-heal-boss", 100_000);
|
||||
|
||||
const impactAt = partyHealCriticalAt(healer.id, greaterHeal.id, tank.id, 0.03);
|
||||
const castStartAt = impactAt - greaterHeal.castTimeMs;
|
||||
advanceDungeonPartyCombat(castStartAt);
|
||||
const activeCast = usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast;
|
||||
expect(activeCast?.abilityId).toBe(greaterHeal.id);
|
||||
expect(activeCast?.completesAt).toBe(impactAt);
|
||||
advanceDungeonPartyCombat(impactAt);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||
kind: "healing",
|
||||
sourceActorId: healer.id,
|
||||
targetActorId: tank.id,
|
||||
abilityId: greaterHeal.id,
|
||||
critical: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps companion Penance active for three healing pulses", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const tank = createConfiguredPartyMember({
|
||||
|
||||
+1257
-80
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -62,6 +62,17 @@ export {
|
||||
|
||||
export type PartyCommand = "attack" | "defend" | "stop";
|
||||
export type PartyMemberStatus = "ready" | "advancing" | "guarding" | "healing" | "attacking" | "holding" | "down";
|
||||
type PartyActiveCast = CombatActorCastState & {
|
||||
readonly canCastWhileMoving?: boolean;
|
||||
/** Backdraft's already-hasted GCD duration, retained until cast completion. */
|
||||
readonly backdraftGcdMs?: number;
|
||||
/** Tidal Waves charges spent at cast start, retained for Lesser Healing Wave's crit roll. */
|
||||
readonly tidalWavesChargesConsumed?: number;
|
||||
/** Arcane Potency's next-spell critical chance, snapshotted when a Mage cast starts. */
|
||||
readonly arcanePotencyCriticalChanceBonusPercent?: number;
|
||||
/** Mana committed for this cast, used by Master of Elements critical refunds. */
|
||||
readonly manaSpent?: number;
|
||||
};
|
||||
|
||||
export interface PartyMember {
|
||||
readonly id: string;
|
||||
@@ -86,11 +97,12 @@ export interface PartyMember {
|
||||
readonly maxResource: number;
|
||||
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
||||
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
||||
readonly warriorAngerManagementRageRemainderMs?: number;
|
||||
readonly comboPoints: number;
|
||||
readonly comboTargetId: string | null;
|
||||
readonly cooldowns: Readonly<Record<string, number>>;
|
||||
readonly globalCooldownEndsAt: number;
|
||||
readonly activeCast: CombatActorCastState | null;
|
||||
readonly activeCast: PartyActiveCast | null;
|
||||
readonly activeCastTargetId: string | null;
|
||||
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||
readonly runes: readonly DeathKnightRuneState[];
|
||||
|
||||
@@ -19,9 +19,14 @@ import { overrideRomKnightEffects } from "./romKnightOverrides";
|
||||
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
|
||||
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
|
||||
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||
import { overrideRomMageEffects } from "./romMageOverrides";
|
||||
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
|
||||
import { overrideRomDruidEffects } from "./romDruidOverrides";
|
||||
import { romPriestDescription } from "./romPriestTooltips";
|
||||
import { overrideRomPriestMageEffects } from "./romPriestMageRuntime";
|
||||
import { overrideRomPriestWarlockEffects } from "./romPriestWarlockOverrides";
|
||||
import { overrideRomChampionEffects } from "./romChampionOverrides";
|
||||
import { ROM_WARDEN_BRIAR_SHIELD_TAG } from "./romWardenOverrides";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -344,17 +349,19 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
||||
if (semantic.kind === "dot" || semantic.kind === "hot") {
|
||||
const intervalMs = durationMs(semantic.intervalSeconds || 2);
|
||||
const ticks = Math.max(1, Math.round((semantic.durationSeconds || semantic.intervalSeconds || 2) / (semantic.intervalSeconds || 2)));
|
||||
const percentHealthHot = semantic.kind === "hot" && semantic.resource === "health-percent";
|
||||
return {
|
||||
kind: semantic.kind,
|
||||
coefficient: 0,
|
||||
...(semantic.kind === "dot" && combatSchool(semantic.school)
|
||||
? { school: combatSchool(semantic.school) }
|
||||
: {}),
|
||||
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
|
||||
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
|
||||
basePoints: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
|
||||
pointsPerLevel: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
|
||||
sourceLevel: 1,
|
||||
ticks,
|
||||
intervalMs,
|
||||
...(percentHealthHot ? { percentMaxHealth: Math.max(0, semantic.basePerTick) / 100 } : {}),
|
||||
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
|
||||
} as AbilityEffect;
|
||||
}
|
||||
@@ -557,6 +564,52 @@ function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name:
|
||||
}
|
||||
|
||||
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
|
||||
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior" && row.sourceSkillId === 499955) {
|
||||
// Briar Technique's modifiers are applied by its named source skills on a
|
||||
// landed hit; it is a marker here, not a permanent stat aura.
|
||||
return [stateAuraEffect(row, "druid-warrior-briar-technique-learned", row.name, null)];
|
||||
}
|
||||
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior"
|
||||
&& [494016, 494216].includes(row.sourceSkillId)) {
|
||||
// The local trigger component 623080 is duplicated onto its two existing
|
||||
// trigger skills; the hit-time runtime owns its stack application.
|
||||
return translateRomAbilitySemantics(row)
|
||||
.map((semantic, index) => semanticEffect(row, semantic, index))
|
||||
.filter((effect) => effect.kind !== "apply-aura"
|
||||
|| !effect.aura.id.includes("-623080-"));
|
||||
}
|
||||
if (row.sourceSkillId === 493642) {
|
||||
// RuneWaker defines a 900-second +5% DEF self aura. Its separate
|
||||
// component 503959 is handled on a qualifying physical hit. Do not treat
|
||||
// it as damage dealt when Briar Shield is cast.
|
||||
const defense = translateRomAbilitySemantics(row)
|
||||
.filter((semantic): semantic is Extract<RomAbilitySemantic, { kind: "aura" }> => (
|
||||
semantic.kind === "aura" && semantic.modifierType === 135
|
||||
))
|
||||
.map((semantic, index) => semanticEffect(row, semantic, index))
|
||||
.find((effect) => effect.kind === "apply-aura");
|
||||
if (!defense || defense.kind !== "apply-aura") return [];
|
||||
return [{
|
||||
...defense,
|
||||
aura: {
|
||||
...defense.aura,
|
||||
id: "rom-briar-shield",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
utilityTags: [...(defense.aura.utilityTags ?? []), ROM_WARDEN_BRIAR_SHIELD_TAG],
|
||||
},
|
||||
}];
|
||||
}
|
||||
if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494207) {
|
||||
// Tough Oak's 1-second cooldown rider is handled per qualifying cast. Its
|
||||
// +5% pet defense rider has no pet-stat representation in this runtime.
|
||||
return [stateAuraEffect(row, "warden-tough-oak", row.name, null)];
|
||||
}
|
||||
if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494011) {
|
||||
// Oak Blessing's local 620679 effect is conditional on Heart of the Oak;
|
||||
// its buff and periodic heal are added when that parent skill is cast.
|
||||
return [stateAuraEffect(row, "warden-oak-blessing", row.name, null)];
|
||||
}
|
||||
if (row.classId === "rom-warden" && row.sourceSkillId === 494610) {
|
||||
// This passive is a trigger marker; its source damage reduction is applied
|
||||
// to Frantic Briar's target, not permanently to the Warden.
|
||||
@@ -739,7 +792,13 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
|
||||
},
|
||||
} : effect);
|
||||
const druidOverride = row.classId === "rom-druid" ? overrideRomDruidEffects(row, translated) : null;
|
||||
const priestMageOverride = overrideRomPriestMageEffects(row, translated);
|
||||
if (priestMageOverride) return priestMageOverride;
|
||||
const championOverride = overrideRomChampionEffects(row, translated);
|
||||
if (championOverride) return championOverride;
|
||||
const druidOverride = row.classId === "rom-druid" || row.sourceSkillId === 494331
|
||||
? overrideRomDruidEffects(row, translated)
|
||||
: null;
|
||||
if (druidOverride) return druidOverride;
|
||||
if (row.sourceSkillId === 498825) return [
|
||||
...translated,
|
||||
@@ -806,9 +865,12 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6;
|
||||
return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))];
|
||||
}
|
||||
const mageOverride = overrideRomMageEffects(row, translated);
|
||||
if (mageOverride) return mageOverride;
|
||||
const pairOverride = overrideRomKnightEffects(row, translated)
|
||||
?? overrideRomWarriorEffects(row, translated)
|
||||
?? overrideRomPriestKnightEffects(row, translated)
|
||||
?? overrideRomPriestWarlockEffects(row, translated)
|
||||
?? overrideRomScoutRogueEffects(row, translated)
|
||||
?? overrideRomMageWarlockEffects(row, translated)
|
||||
?? overrideRomWarlockEffects(row, translated);
|
||||
|
||||
@@ -438,12 +438,18 @@ describe("executable RuneWaker ability mappings", () => {
|
||||
});
|
||||
|
||||
it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => {
|
||||
expect(sourceSkill(493642).effects).toEqual(expect.arrayContaining([
|
||||
const briarShield = sourceSkill(493642);
|
||||
expect(briarShield.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["rom-briar-shield"]) }),
|
||||
aura: expect.objectContaining({
|
||||
durationMs: 900_000,
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }],
|
||||
utilityTags: expect.arrayContaining(["rom-briar-shield"]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(briarShield.effects.filter((effect) => effect.kind === "damage")).toHaveLength(0);
|
||||
expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "damage" }),
|
||||
]));
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface RomSourceComponent {
|
||||
|
||||
export interface RomGeneratedAbilityLike {
|
||||
readonly classId?: string;
|
||||
readonly secondaryClassId?: string;
|
||||
readonly sourceSkillId?: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
@@ -365,7 +366,14 @@ function addIfMissing(
|
||||
if (row.kind === "proc" && semantic.kind === "proc") {
|
||||
return row.trigger === semantic.trigger && row.triggerSkillId === semantic.triggerSkillId;
|
||||
}
|
||||
if (row.kind === "aura" && semantic.kind === "aura") return row.effect === semantic.effect;
|
||||
if (row.kind === "aura" && semantic.kind === "aura") {
|
||||
const seedBranches = new Set([503815, 620458, 503814, 620457]);
|
||||
const previousComponent = row.sourceComponentIds[0];
|
||||
const nextComponent = semantic.sourceComponentIds[0];
|
||||
if (row.effect === semantic.effect && previousComponent !== nextComponent
|
||||
&& seedBranches.has(previousComponent) && seedBranches.has(nextComponent)) return false;
|
||||
return row.effect === semantic.effect;
|
||||
}
|
||||
if (row.kind === "resource" && semantic.kind === "resource") return row.resource === semantic.resource && row.operation === semantic.operation;
|
||||
if (row.kind === "custom" && semantic.kind === "custom") return row.mechanicId === semantic.mechanicId;
|
||||
return discriminator.length === 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { AuraDefinition } from "./combatAuras";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = {
|
||||
id: "rom-champion-forge-stack",
|
||||
@@ -20,6 +21,28 @@ export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = {
|
||||
|
||||
export const ROM_CHAMPION_RUNE_OVERLOAD_TAG = "rom-champion-rune-overload";
|
||||
export const ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND = 15;
|
||||
export const ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID = "rom-champion-soul-forge-critical";
|
||||
export const ROM_CHAMPION_SOUL_FORGE_PROFILE = Object.freeze({
|
||||
sourceSpellId: 498652,
|
||||
criticalAuraComponentId: 621339,
|
||||
criticalAuraDurationMs: 300_000,
|
||||
darkPowerComponentId: 621341,
|
||||
trigger: "crit" as const,
|
||||
chance: 1,
|
||||
darkMagicAuraDurationMs: 10_000,
|
||||
darkMagicBonusPercent: 0.1,
|
||||
darkMagicSchool: "shadow" as const,
|
||||
});
|
||||
export const ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID = "rom-champion-way-of-frenzy-hit";
|
||||
export const ROM_CHAMPION_WAY_OF_FRENZY_PROFILE = Object.freeze({
|
||||
sourceSpellId: 498720,
|
||||
sourceComponentId: 621458,
|
||||
triggerSpellId: 498715,
|
||||
triggerAbilityId: "rom-champion-rom-priest-elite-20",
|
||||
durationMs: 60_000,
|
||||
maxStacks: 5,
|
||||
staminaPercentPerStack: 0.02,
|
||||
});
|
||||
|
||||
const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = {
|
||||
id: "rom-champion-forge-active",
|
||||
@@ -37,6 +60,56 @@ const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = {
|
||||
}],
|
||||
};
|
||||
|
||||
/** Replace the source component's unconditional stat aura with its described hit trigger. */
|
||||
export function overrideRomChampionEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
_translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.classId !== "rom-champion" || row.secondaryClassId !== "rom-priest"
|
||||
|| row.sourceSkillId !== ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId) return null;
|
||||
|
||||
const component = row.source?.components?.find((entry) => (
|
||||
entry.id === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId
|
||||
));
|
||||
const staminaModifier = component?.abilityModifiers?.find((modifier) => modifier.type === 162);
|
||||
const durationMs = Math.max(0, Math.round((component?.effectTime ?? 0) * 1_000));
|
||||
if (!component || !staminaModifier || staminaModifier.value <= 0 || durationMs <= 0) return [];
|
||||
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "rom-champion-way-of-frenzy-passive",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
hideFromAuraStrip: true,
|
||||
procs: [{
|
||||
id: "rom-champion-way-of-frenzy-divine-vengeance",
|
||||
triggers: ["hit", "crit"],
|
||||
abilityIds: [ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerAbilityId],
|
||||
chance: 1,
|
||||
action: {
|
||||
kind: "custom",
|
||||
id: ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID,
|
||||
data: {
|
||||
sourceSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId,
|
||||
sourceComponentId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId,
|
||||
triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId,
|
||||
durationMs,
|
||||
maxStacks: 5,
|
||||
staminaPercentPerStack: staminaModifier.value / 100,
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
recipient: "caster",
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId,
|
||||
}];
|
||||
}
|
||||
|
||||
function scaleDamage(effect: Extract<AbilityEffect, { kind: "damage" }>, factor: number): AbilityEffect {
|
||||
return {
|
||||
...effect,
|
||||
@@ -48,6 +121,83 @@ function scaleDamage(effect: Extract<AbilityEffect, { kind: "damage" }>, factor:
|
||||
};
|
||||
}
|
||||
|
||||
function casterHealFromDamage(effect: Extract<AbilityEffect, { kind: "damage" }>): AbilityEffect {
|
||||
return {
|
||||
kind: "heal",
|
||||
coefficient: effect.coefficient,
|
||||
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints }),
|
||||
...(effect.dieSides === undefined ? {} : { dieSides: effect.dieSides }),
|
||||
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel }),
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel }),
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient }),
|
||||
...(effect.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: effect.attackPowerCoefficient }),
|
||||
...(effect.spiritCoefficient === undefined ? {} : { spiritCoefficient: effect.spiritCoefficient }),
|
||||
...(effect.sourceLevel === undefined ? {} : { sourceLevel: effect.sourceLevel }),
|
||||
...(effect.maxScalingLevel === undefined ? {} : { maxScalingLevel: effect.maxScalingLevel }),
|
||||
recipient: "caster",
|
||||
};
|
||||
}
|
||||
|
||||
function soulForgeMysteryEffects(effects: readonly AbilityEffect[]): readonly AbilityEffect[] {
|
||||
const critAuraIndex = effects.findIndex((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`));
|
||||
const darkAuraIndex = effects.findIndex((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`));
|
||||
const critAuraEffect = effects[critAuraIndex];
|
||||
const darkAuraEffect = effects[darkAuraIndex];
|
||||
if (critAuraIndex < 0 || darkAuraIndex < 0
|
||||
|| critAuraEffect?.kind !== "apply-aura" || darkAuraEffect?.kind !== "apply-aura") return effects;
|
||||
|
||||
const darkModifier = darkAuraEffect.aura.modifiers?.find((modifier) => modifier.kind === "damage"
|
||||
&& modifier.direction === "dealt" && modifier.school === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool
|
||||
&& modifier.operation === "percent");
|
||||
if (darkModifier?.kind !== "damage") return effects;
|
||||
|
||||
const output: AbilityEffect[] = [];
|
||||
effects.forEach((effect, index) => {
|
||||
if (index === darkAuraIndex) return;
|
||||
if (index === critAuraIndex && effect.kind === "apply-aura") {
|
||||
output.push({
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
procs: [
|
||||
...(effect.aura.procs ?? []),
|
||||
{
|
||||
id: "rom-champion-soul-forge-dark-magic-on-crit",
|
||||
triggers: ROM_CHAMPION_SOUL_FORGE_PROFILE.trigger,
|
||||
chance: ROM_CHAMPION_SOUL_FORGE_PROFILE.chance,
|
||||
action: {
|
||||
kind: "custom",
|
||||
id: ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID,
|
||||
data: {
|
||||
sourceSpellId: ROM_CHAMPION_SOUL_FORGE_PROFILE.sourceSpellId,
|
||||
sourceComponentId: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId,
|
||||
auraId: "rom-champion-soul-forge-dark-magic",
|
||||
auraName: darkAuraEffect.aura.name,
|
||||
durationMs: darkAuraEffect.aura.durationMs ?? ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicAuraDurationMs,
|
||||
disposition: "buff",
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
target: "aura-source",
|
||||
modifierKind: "damage",
|
||||
modifierDirection: "dealt",
|
||||
modifierSchool: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool,
|
||||
modifierOperation: darkModifier.operation,
|
||||
modifierValue: darkModifier.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
output.push(effect);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Apply source-backed Champion cast branches that need runtime state. */
|
||||
export function romChampionCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
@@ -58,6 +208,49 @@ export function romChampionCastEffects(
|
||||
if (ability.source !== "runewaker" || ability.classId !== "rom-champion") return effects;
|
||||
|
||||
switch (ability.dbcSpellId) {
|
||||
case 498652: // Soul Forge Mystery adds its 10-second dark magic buff on a critical attack.
|
||||
if (ability.secondaryClassId !== "rom-warlock") return effects;
|
||||
return soulForgeMysteryEffects(effects);
|
||||
case 498595: { // Rune Siphon's positive component 621264 restores the caster's HP.
|
||||
if (ability.secondaryClassId !== "rom-warlock") return effects;
|
||||
let healIndex = -1;
|
||||
for (let index = effects.length - 1; index >= 0; index -= 1) {
|
||||
if (effects[index]?.kind === "damage") {
|
||||
healIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (healIndex < 0) return effects;
|
||||
return effects.flatMap((effect, index): AbilityEffect[] => {
|
||||
if (index === healIndex && effect.kind === "damage") return [casterHealFromDamage(effect)];
|
||||
if (effect.kind === "heal" && effect.coefficient === 0 && effect.basePoints === undefined
|
||||
&& effect.pointsPerLevel === undefined && effect.percentMaxHealth === undefined) return [];
|
||||
return [effect];
|
||||
});
|
||||
}
|
||||
case 498529: { // Rune Pulse restores Rage on a landed hit (component 622327).
|
||||
const restoreAura = effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes("-622327-")
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "rage" && modifier.property === "regeneration"));
|
||||
if (restoreAura?.kind !== "apply-aura") return effects;
|
||||
const rageModifier = restoreAura.aura.modifiers?.find((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "rage" && modifier.property === "regeneration");
|
||||
const ragePerHit = rageModifier
|
||||
? Math.max(0, Math.round(rageModifier.value * (restoreAura.aura.durationMs ?? 0) / 1_000))
|
||||
: 0;
|
||||
const damageIndex = effects.findIndex((effect) => effect.kind === "damage");
|
||||
if (ragePerHit <= 0 || damageIndex < 0) return effects;
|
||||
const output: AbilityEffect[] = [];
|
||||
effects.forEach((effect, index) => {
|
||||
if (index === damageIndex && effect.kind === "damage") {
|
||||
output.push({ ...effect, onDamageResource: { resource: "rage", amount: ragePerHit } });
|
||||
} else if (effect !== restoreAura) {
|
||||
output.push(effect);
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
case 498584: // Champion/Warrior Determination Rune is a 30%-of-max-HP caster shield.
|
||||
if (ability.secondaryClassId !== "rom-warrior") return effects;
|
||||
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
|
||||
import { romChampionCastEffects } from "./romChampionOverrides";
|
||||
import {
|
||||
ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID,
|
||||
ROM_CHAMPION_SOUL_FORGE_PROFILE,
|
||||
ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID,
|
||||
ROM_CHAMPION_WAY_OF_FRENZY_PROFILE,
|
||||
romChampionCastEffects,
|
||||
} from "./romChampionOverrides";
|
||||
|
||||
const empty: RomCastMechanics = {
|
||||
sealStacks: 0,
|
||||
@@ -133,6 +139,234 @@ describe("RoM Champion source-specific behavior", () => {
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("restores Rune Pulse's source-defined Rage only when its damage lands", () => {
|
||||
const runePulse = sourceAbility(498529);
|
||||
expect(runePulse.description).toContain("When it hits, it will restore");
|
||||
|
||||
const effects = castEffects(498529);
|
||||
const hit = effects.find((effect) => effect.kind === "damage");
|
||||
expect(hit?.kind).toBe("damage");
|
||||
if (hit?.kind === "damage") expect(hit.onDamageResource).toEqual({ resource: "rage", amount: 5 });
|
||||
expect(effects.some((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("-622327-")))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("routes Rune Siphon's positive source component to the caster as healing", () => {
|
||||
const runeSiphon = sourceAbility(498595);
|
||||
expect(runeSiphon.secondaryClassId).toBe("rom-warlock");
|
||||
expect(runeSiphon.description).toContain("restores (DMG8) HP to the caster");
|
||||
|
||||
const effects = castEffects(498595);
|
||||
expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
||||
expect(effects.filter((effect) => effect.kind === "heal")).toEqual([
|
||||
expect.objectContaining({ kind: "heal", recipient: "caster", basePoints: 24, pointsPerLevel: 4 }),
|
||||
]);
|
||||
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-champion", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level: 70,
|
||||
});
|
||||
useCombatStore.getState().registerMob("champion-rune-siphon-target", {
|
||||
name: "Rune Siphon Target",
|
||||
maxHealth: 100_000,
|
||||
});
|
||||
setMobPosition("champion-rune-siphon-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("champion-rune-siphon-target");
|
||||
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth / 2));
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, rage: 100, focus: 100 },
|
||||
}));
|
||||
const healthBeforeCast = useCombatStore.getState().health;
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-20", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(healthBeforeCast);
|
||||
});
|
||||
|
||||
it("arms Soul Forge Mystery's source-backed dark-magic bonus on critical attacks", () => {
|
||||
const soulForge = sourceAbility(498652);
|
||||
expect(soulForge.secondaryClassId).toBe("rom-warlock");
|
||||
expect(soulForge.description).toContain("If critical attack occurs while in effect");
|
||||
|
||||
const localCritAuras = soulForge.effects.filter((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`));
|
||||
const localDarkAura = soulForge.effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`));
|
||||
expect(localCritAuras.length).toBeGreaterThan(0);
|
||||
expect(localCritAuras.every((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.durationMs === 300_000)).toBe(true);
|
||||
expect(localDarkAura).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
durationMs: 10_000,
|
||||
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", school: "shadow", value: 0.1 })],
|
||||
},
|
||||
});
|
||||
|
||||
const effects = castEffects(498652);
|
||||
const criticalAura = effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`));
|
||||
expect(criticalAura?.kind).toBe("apply-aura");
|
||||
if (criticalAura?.kind === "apply-aura") {
|
||||
expect(criticalAura.aura.procs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
triggers: "crit",
|
||||
chance: 1,
|
||||
action: expect.objectContaining({
|
||||
kind: "custom",
|
||||
id: ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID,
|
||||
data: expect.objectContaining({
|
||||
sourceSpellId: 498652,
|
||||
sourceComponentId: 621341,
|
||||
durationMs: 10_000,
|
||||
disposition: "buff",
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
target: "aura-source",
|
||||
modifierSchool: "shadow",
|
||||
modifierValue: 0.1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
}
|
||||
expect(effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`))).toBe(false);
|
||||
});
|
||||
|
||||
it("grants Soul Forge Mystery's shadow bonus after a critical hit", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-champion",
|
||||
secondaryClassId: "rom-warlock",
|
||||
raceId: "rom-dwarf",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
});
|
||||
const soulForge = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 498652)!;
|
||||
expect(useCombatStore.getState().castAbility(soulForge.id, undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-soul-forge-dark-magic"))
|
||||
.toBe(false);
|
||||
|
||||
useCombatStore.getState().registerMob("soul-forge-crit-target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
useCombatStore.getState().damageMob(
|
||||
"soul-forge-crit-target",
|
||||
100,
|
||||
2_000,
|
||||
undefined,
|
||||
"shadow",
|
||||
{
|
||||
attackKind: "spell",
|
||||
seed: "soul-forge-guaranteed-critical",
|
||||
abilityId: soulForge.id,
|
||||
criticalChanceBonusPercent: 100,
|
||||
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
|
||||
},
|
||||
);
|
||||
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
definition: expect.objectContaining({
|
||||
id: "rom-champion-soul-forge-dark-magic",
|
||||
durationMs: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicAuraDurationMs,
|
||||
modifiers: [expect.objectContaining({
|
||||
kind: "damage",
|
||||
direction: "dealt",
|
||||
school: "shadow",
|
||||
operation: "percent",
|
||||
value: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicBonusPercent,
|
||||
})],
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("arms Way of Frenzy only on Divine Vengeance and caps its local 2% Stamina stacks at five", () => {
|
||||
const wayOfFrenzy = sourceAbility(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId);
|
||||
const divineVengeance = sourceAbility(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId);
|
||||
expect(wayOfFrenzy).toMatchObject({ classId: "rom-champion", secondaryClassId: "rom-priest", passive: true });
|
||||
expect(divineVengeance.id).toBe(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerAbilityId);
|
||||
expect(wayOfFrenzy.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: expect.objectContaining({
|
||||
id: "rom-champion-way-of-frenzy-passive",
|
||||
durationMs: null,
|
||||
procs: [expect.objectContaining({
|
||||
triggers: ["hit", "crit"],
|
||||
abilityIds: [divineVengeance.id],
|
||||
chance: 1,
|
||||
action: expect.objectContaining({
|
||||
kind: "custom",
|
||||
id: ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID,
|
||||
data: expect.objectContaining({
|
||||
sourceSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId,
|
||||
sourceComponentId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId,
|
||||
triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId,
|
||||
durationMs: 60_000,
|
||||
maxStacks: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks,
|
||||
staminaPercentPerStack: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.staminaPercentPerStack,
|
||||
}),
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(wayOfFrenzy.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "stamina")))
|
||||
.toBe(false);
|
||||
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-champion",
|
||||
secondaryClassId: "rom-priest",
|
||||
raceId: "rom-dwarf",
|
||||
level: 70,
|
||||
});
|
||||
useCombatStore.getState().registerMob("way-of-frenzy-target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
const stackAuraId = "rom-champion-way-of-frenzy-stacks";
|
||||
const stackAura = () => useCombatStore.getState().auras.find((aura) => aura.definition.id === stackAuraId);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-way-of-frenzy-passive"))
|
||||
.toBe(true);
|
||||
expect(stackAura()).toBeUndefined();
|
||||
|
||||
const unrelatedAbility = sourceAbility(498528);
|
||||
useCombatStore.getState().damageMob("way-of-frenzy-target", 100, 2_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: "way-of-frenzy-unrelated-hit",
|
||||
abilityId: unrelatedAbility.id,
|
||||
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(stackAura()).toBeUndefined();
|
||||
|
||||
for (let hit = 0; hit < 6; hit += 1) {
|
||||
useCombatStore.getState().damageMob("way-of-frenzy-target", 100, 3_000 + hit * 1_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: `way-of-frenzy-divine-vengeance-${hit}`,
|
||||
abilityId: divineVengeance.id,
|
||||
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
}
|
||||
|
||||
expect(stackAura()).toMatchObject({
|
||||
stacks: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks,
|
||||
expiresAt: 68_000,
|
||||
definition: {
|
||||
durationMs: 60_000,
|
||||
maxStacks: 5,
|
||||
stackBehavior: "add",
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "stamina",
|
||||
operation: "percent",
|
||||
value: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.staminaPercentPerStack,
|
||||
perStack: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("scales Determination Rune's source max-health shield and leaves unrelated casts unchanged", () => {
|
||||
const determinationRune = sourceAbility(498584);
|
||||
const sourceShield = determinationRune.effects.find((effect) => effect.kind === "apply-aura"
|
||||
@@ -191,4 +425,16 @@ describe("RoM Champion source-specific behavior", () => {
|
||||
expect(castEffects(498599).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
|
||||
expect(castEffects(498535).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleanses every modeled harmful aura on the party with Righteous Explosion", () => {
|
||||
const cleanse = castEffects(499836).find((effect) => effect.kind === "dispel");
|
||||
|
||||
expect(cleanse).toMatchObject({
|
||||
kind: "dispel",
|
||||
mode: "cleanse",
|
||||
relationship: "friendly",
|
||||
scope: "party",
|
||||
maxCount: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("RoM primary class conditional combat effects", () => {
|
||||
expect(empowered).toHaveLength(2);
|
||||
expect(empowered[0]?.kind).toBe("hot");
|
||||
if (empowered[0]?.kind === "hot") expect(empowered[0].basePoints).toBeCloseTo(23.6);
|
||||
expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
|
||||
expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" });
|
||||
expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain"))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,22 @@ export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean {
|
||||
|
||||
export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629].includes(ability.dbcSpellId);
|
||||
&& [
|
||||
490420, // Scout Joint Blow uses a ranged weapon.
|
||||
490423, // Scout Shot uses a ranged weapon.
|
||||
490438, // Scout Neck Strike requires a ranged weapon.
|
||||
491128, // Scout Wind Arrows uses a ranged weapon.
|
||||
491292, // Scout Vampire Arrows uses a ranged weapon.
|
||||
491452,
|
||||
491616,
|
||||
492589, // Scout Autoshot uses a ranged weapon.
|
||||
494021,
|
||||
494037,
|
||||
494039,
|
||||
494040,
|
||||
494042,
|
||||
494629,
|
||||
].includes(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
|
||||
@@ -178,6 +193,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
ability,
|
||||
priestKnightEffects,
|
||||
context.friendlyTargetHealthPercent ?? 1,
|
||||
context.passiveTags ?? [],
|
||||
);
|
||||
const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []);
|
||||
const mageWarlockEffects = romMageWarlockCastEffects(
|
||||
@@ -199,6 +215,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
wardenEffects,
|
||||
context.targetTags ?? [],
|
||||
context.learnedPassiveSpellIds ?? [],
|
||||
context.targetHealthPercent,
|
||||
);
|
||||
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
|
||||
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
|
||||
@@ -230,7 +247,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
const selectedHot = empowered ? hots[0] : hots[1] ?? hots[0];
|
||||
return [
|
||||
...(selectedHot ? [selectedHot] : []),
|
||||
...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const }] : []),
|
||||
...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const, recipient: "caster" as const }] : []),
|
||||
];
|
||||
}
|
||||
case 493247: // The Final Battle only adds its second hit below 30% target HP.
|
||||
|
||||
@@ -27,6 +27,43 @@ function passiveTag(spellId: number): string {
|
||||
}
|
||||
|
||||
describe("RoM Druid Nature Power branches", () => {
|
||||
it("only applies Grace of the Forest's conditional component while Nature Power is held", () => {
|
||||
const grace = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 494937);
|
||||
expect(grace).toMatchObject({ classId: "rom-druid", secondaryClassId: "rom-warden" });
|
||||
const withoutPower = romEffectsForCast(grace!, { ...empty, naturePower: 0 });
|
||||
const withPower = romEffectsForCast(grace!, { ...empty, naturePower: 1 });
|
||||
const bonus = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "mana" && modifier.property === "maximum"
|
||||
&& modifier.operation === "percent" && modifier.value === 0.1));
|
||||
|
||||
expect(bonus(withoutPower)).toHaveLength(0);
|
||||
expect(bonus(withPower)).toHaveLength(1);
|
||||
expect(bonus(withPower)[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: { durationMs: 4_000 },
|
||||
});
|
||||
const healingBonuses = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "done" && modifier.value === 0.03));
|
||||
expect(healingBonuses(withoutPower)).toHaveLength(1);
|
||||
expect(healingBonuses(withPower)).toHaveLength(2);
|
||||
expect(healingBonuses(withPower).find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === "rom-druid-grace-of-the-forest-powered-healing"))
|
||||
.toMatchObject({ kind: "apply-aura", aura: { durationMs: 4_000 } });
|
||||
});
|
||||
|
||||
it("uses Essence Extraction's 10% mana cast cost once and restores four Nature Power", () => {
|
||||
const essence = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 494373);
|
||||
expect(essence).toBeDefined();
|
||||
expect(essence!.costs).toContainEqual({ resource: "mana", amount: 10, percentage: true });
|
||||
|
||||
const effects = cast(494373, 0);
|
||||
expect(effects.filter((effect) => effect.kind === "resource-drain" && effect.resource === "mana")).toHaveLength(0);
|
||||
expect(effects.filter((effect) => effect.kind === "resource" && effect.resource === "nature-power"))
|
||||
.toEqual([{ kind: "resource", resource: "nature-power", amount: 4 }]);
|
||||
});
|
||||
|
||||
it("selects the matching Mother Earth's Fountain heal and spends one point for DMG0", () => {
|
||||
const baseline = cast(493529, 0).filter((effect) => effect.kind === "heal");
|
||||
const empowered = cast(493529, 1);
|
||||
@@ -37,7 +74,7 @@ describe("RoM Druid Nature Power branches", () => {
|
||||
kind: "heal", basePoints: 67.2, scope: "party", center: "ability-target", radius: 10,
|
||||
});
|
||||
expect(empowered.filter((effect) => effect.kind === "resource-drain")).toEqual([
|
||||
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
|
||||
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -55,6 +92,40 @@ describe("RoM Druid Nature Power branches", () => {
|
||||
expect(cast(493549, 1).filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("selects one RuneWaker Seed modifier component for the current Nature Power branch", () => {
|
||||
const seedBranches = [
|
||||
{ spellId: 493546, baseComponentId: 503815, poweredComponentId: 620458, stat: "magical-defense" },
|
||||
{ spellId: 493545, baseComponentId: 503814, poweredComponentId: 620457, stat: "armor" },
|
||||
] as const;
|
||||
|
||||
for (const { spellId, baseComponentId, poweredComponentId, stat } of seedBranches) {
|
||||
const branchAuras = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === stat));
|
||||
const baseline = branchAuras(cast(spellId, 0));
|
||||
const powered = branchAuras(cast(spellId, 1));
|
||||
|
||||
expect(baseline).toHaveLength(1);
|
||||
expect(baseline[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
durationMs: 20_000,
|
||||
modifiers: [{ kind: "stat", stat, operation: "percent", value: -0.02 }],
|
||||
},
|
||||
});
|
||||
expect(baseline[0]?.kind === "apply-aura" && baseline[0].aura.id).toContain(`-${baseComponentId}-`);
|
||||
|
||||
expect(powered).toHaveLength(1);
|
||||
expect(powered[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
durationMs: 20_000,
|
||||
modifiers: [{ kind: "stat", stat, operation: "percent", value: -0.02 }],
|
||||
},
|
||||
});
|
||||
expect(powered[0]?.kind === "apply-aura" && powered[0].aura.id).toContain(`-${poweredComponentId}-`);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects the powered Sandstorm and Earth Chain payloads without simultaneous alternatives", () => {
|
||||
const sandstormBase = cast(493542, 0);
|
||||
const sandstormPowered = cast(493542, 1);
|
||||
@@ -85,7 +156,7 @@ describe("RoM Druid Nature Power branches", () => {
|
||||
expect(baseline.filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
||||
expect(empowered.filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
||||
expect(empowered).toContainEqual({ kind: "resource", resource: "rage", amount: 15 });
|
||||
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
|
||||
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" });
|
||||
});
|
||||
|
||||
it("selects Cross of Thorns' two-hit branch and consumes one point without an unknown refund chance", () => {
|
||||
@@ -97,7 +168,7 @@ describe("RoM Druid Nature Power branches", () => {
|
||||
expect(empoweredHits).toHaveLength(2);
|
||||
expect(empoweredHits[0]).not.toEqual(baselineHits[0]);
|
||||
expect(empowered.filter((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power"))
|
||||
.toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }]);
|
||||
.toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }]);
|
||||
expect(empowered.some((effect) => effect.kind === "resource" && effect.resource === "nature-power"))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
export const ROM_DRUID_GREEN_GUARDIAN_TAG = "rom-druid-green-guardian";
|
||||
export const ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG = "rom-druid-mother-earth-protection";
|
||||
|
||||
function sourceDurationMs(row: RomGeneratedAbilityLike, fallbackSeconds: number): number {
|
||||
const duration = row.source?.components?.find((component) => (component.effectTime ?? 0) > 0)?.effectTime;
|
||||
return Math.max(1_000, Math.round((duration ?? fallbackSeconds) * 1_000));
|
||||
@@ -83,6 +86,26 @@ export function overrideRomDruidEffects(
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
switch (row.sourceSkillId) {
|
||||
case 493533:
|
||||
case 493558: { // Both Mother Earth's Protection ranks can trigger Green Guardian on expiry.
|
||||
let tagged = false;
|
||||
return translated.map((effect) => {
|
||||
if (tagged || effect.kind !== "apply-aura" || effect.aura.durationMs === null) return effect;
|
||||
tagged = true;
|
||||
return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
case 494331: // Keep source HoT payloads on the learned passive for its expiry trigger.
|
||||
return [
|
||||
passiveState(494331, row.name, ROM_DRUID_GREEN_GUARDIAN_TAG),
|
||||
...translated.filter((effect) => effect.kind === "hot"),
|
||||
];
|
||||
case 493531: // Savage Blessing buffs a selected friendly target.
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
@@ -134,7 +157,7 @@ function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boo
|
||||
}
|
||||
|
||||
function naturePowerDrain(): AbilityEffect {
|
||||
return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" };
|
||||
return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" };
|
||||
}
|
||||
|
||||
function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] {
|
||||
@@ -144,6 +167,18 @@ function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readon
|
||||
return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)];
|
||||
}
|
||||
|
||||
function selectSeedModifierBranch(
|
||||
effects: readonly AbilityEffect[],
|
||||
componentIds: readonly [number, number],
|
||||
selectedComponentId: number,
|
||||
): readonly AbilityEffect[] {
|
||||
return effects.filter((effect) => {
|
||||
if (effect.kind !== "apply-aura") return true;
|
||||
const sourceComponentId = componentIds.find((id) => effect.aura.id.includes(`-${id}-`));
|
||||
return sourceComponentId === undefined || sourceComponentId === selectedComponentId;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve Druid source branches that depend on current Nature Power or learned passive state. */
|
||||
export function romDruidCastEffects(
|
||||
ability: import("./abilityCatalog").AbilityDefinition,
|
||||
@@ -161,6 +196,32 @@ export function romDruidCastEffects(
|
||||
};
|
||||
|
||||
switch (ability.dbcSpellId) {
|
||||
case 494373: // Essence Extraction's 10% MP conversion is already represented as its cast cost.
|
||||
return effects.filter((effect) => !(effect.kind === "resource-drain" && effect.resource === "mana"));
|
||||
case 494937: { // Grace of the Forest only applies RuneWaker's second component while Nature Power is held.
|
||||
if (ability.classId !== "rom-druid" || ability.secondaryClassId !== "rom-warden") return effects;
|
||||
if (naturePower < 1) return effects.filter((effect) => effect.kind !== "apply-aura"
|
||||
|| !effect.aura.id.includes("-494937-505711-"));
|
||||
const poweredHealingPresent = effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes("-494937-505711-")
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "done" && modifier.operation === "percent" && modifier.value === 0.03));
|
||||
return poweredHealingPresent ? effects : [...effects, {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-druid-grace-of-the-forest-powered-healing",
|
||||
name: `${ability.name}: Nature Power`,
|
||||
disposition: "buff",
|
||||
durationMs: 4_000,
|
||||
modifiers: [{ kind: "healing", direction: "done", operation: "percent", value: 0.03 }],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 149,
|
||||
miscValue: 3,
|
||||
triggerSpellId: 494937,
|
||||
}];
|
||||
}
|
||||
case 493529: { // DMG1 is baseline; DMG0 is the one-point Nature Power branch.
|
||||
const selected = heals[empowered ? 1 : 0] ?? heals[0];
|
||||
if (!selected) return effects;
|
||||
@@ -175,9 +236,14 @@ export function romDruidCastEffects(
|
||||
...(empowered ? [naturePowerDrain()] : []),
|
||||
];
|
||||
}
|
||||
case 493546: // Weakening Seed consumes and refunds one point when powered.
|
||||
case 493545: // Withering Seed has the same source resource branch.
|
||||
return empowered ? spendBeforeNaturePowerRefund(effects) : effects;
|
||||
case 493546: { // Buff1 is the base branch; Buff0 is the powered branch.
|
||||
const selected = selectSeedModifierBranch(effects, [503815, 620458], empowered ? 620458 : 503815);
|
||||
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
||||
}
|
||||
case 493545: { // Buff1 is the base branch; Buff0 is the powered branch.
|
||||
const selected = selectSeedModifierBranch(effects, [503814, 620457], empowered ? 620457 : 503814);
|
||||
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
||||
}
|
||||
case 493549: { // Select DMG1/DMG0, keep the single source DoT, and consume one point if powered.
|
||||
const selectedDamage = damage[empowered ? 1 : 0];
|
||||
if (!selectedDamage) return effects;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { equipmentAdjustedCombatAmount, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
@@ -22,7 +22,7 @@ function finishCast(now: number) {
|
||||
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
|
||||
}
|
||||
|
||||
function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") {
|
||||
function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout" | "rom-mage") {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
...(secondaryClassId ? { secondaryClassId } : {}),
|
||||
@@ -37,9 +37,25 @@ function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") {
|
||||
}));
|
||||
}
|
||||
|
||||
function prepareMageDruid() {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
raceId: "rom-elf",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
health: 1,
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, mana: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: 1_000 },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("RuneWaker Druid runtime", () => {
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["rom-druid"]);
|
||||
await preloadAbilityCatalogsForClasses(["rom-druid", "rom-mage"]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -136,6 +152,77 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(companionResourceMaximum(companion, "nature-power")).toBe(15);
|
||||
});
|
||||
|
||||
it("converts exactly 10% MP into four Nature Power with Essence Extraction", () => {
|
||||
prepareDruid("rom-warden");
|
||||
const essence = ability(494373);
|
||||
expect(essence.costs).toContainEqual({ resource: "mana", amount: 10, percentage: true });
|
||||
expect(useCombatStore.getState().resourcePools).toMatchObject({ mana: 1_000, "nature-power": 0 });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(essence.id, { now: 1_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(900);
|
||||
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(4);
|
||||
});
|
||||
|
||||
it("applies Grace of the Forest's Nature Power bonus only when a point is held", () => {
|
||||
prepareDruid("rom-warden");
|
||||
const grace = ability(494937);
|
||||
expect(useCombatStore.getState().castAbility(grace.id, { now: 1_000 }).ok).toBe(true);
|
||||
const baseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID);
|
||||
expect(baseAuras.some((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "mana" && modifier.property === "maximum"
|
||||
&& modifier.operation === "percent" && modifier.value === 0.1))).toBe(false);
|
||||
expect(baseAuras.some((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "healing"
|
||||
&& modifier.direction === "done" && modifier.operation === "percent" && modifier.value === 0.03))).toBe(true);
|
||||
|
||||
prepareDruid("rom-warden");
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, "nature-power": 1 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(grace.id, { now: 2_000 }).ok).toBe(true);
|
||||
const poweredAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID);
|
||||
expect(poweredAuras.find((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "resource"
|
||||
&& modifier.resource === "mana" && modifier.property === "maximum"
|
||||
&& modifier.operation === "percent" && modifier.value === 0.1))?.expiresAt).toBe(6_000);
|
||||
expect(poweredAuras.find((aura) => aura.definition.id === "rom-druid-grace-of-the-forest-powered-healing")?.expiresAt)
|
||||
.toBe(6_000);
|
||||
});
|
||||
|
||||
it("selects and applies the source Seed defense branch to its target", () => {
|
||||
const branches = [
|
||||
{ spellId: 493546, baseComponentId: 503815, poweredComponentId: 620458, stat: "magical-defense" },
|
||||
{ spellId: 493545, baseComponentId: 503814, poweredComponentId: 620457, stat: "armor" },
|
||||
] as const;
|
||||
|
||||
branches.forEach(({ spellId, baseComponentId, poweredComponentId, stat }, index) => {
|
||||
for (const { naturePower, componentId } of [
|
||||
{ naturePower: 0, componentId: baseComponentId },
|
||||
{ naturePower: 1, componentId: poweredComponentId },
|
||||
]) {
|
||||
const targetId = `${TARGET_ID}-seed-${spellId}-${naturePower}`;
|
||||
prepareDruid();
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, "nature-power": naturePower },
|
||||
}));
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Seed Target", maxHealth: 10_000 });
|
||||
setMobPosition(targetId, [5, 0, 0]);
|
||||
|
||||
const seed = ability(spellId);
|
||||
const now = 2_000 + index * 4_000 + naturePower * 1_000;
|
||||
expect(useCombatStore.getState().castAbility(seed.id, { targetActorId: targetId, now }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(1);
|
||||
const seedAura = useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === stat));
|
||||
expect(seedAura).toMatchObject({
|
||||
expiresAt: now + 20_000,
|
||||
definition: expect.objectContaining({
|
||||
id: expect.stringContaining(`-${componentId}-`),
|
||||
modifiers: [expect.objectContaining({ kind: "stat", stat, operation: "percent", value: -0.02 })],
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("adds exactly one percent of each healing target's max health to the Spirit of Life tick", () => {
|
||||
expect(romDruidPeriodicHealAmount(500, 10_000, 0.01)).toBe(600);
|
||||
expect(romDruidPeriodicHealAmount(500, 5_000, 0.01)).toBe(550);
|
||||
@@ -185,4 +272,178 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(buffs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers both Green Guardian HoTs only after the caster's protection expires", () => {
|
||||
prepareMageDruid();
|
||||
const state = useCombatStore.getState();
|
||||
const passive = ability(494331);
|
||||
const protection = ability(493533);
|
||||
expect(state.abilities.some((entry) => entry.id === passive.id && entry.passive)).toBe(true);
|
||||
|
||||
const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined);
|
||||
const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined);
|
||||
expect(percentHot).toMatchObject({ kind: "hot", percentMaxHealth: 0.02, ticks: 5, intervalMs: 2_000 });
|
||||
expect(flatHot).toMatchObject({ kind: "hot", basePoints: 39, pointsPerLevel: 9, ticks: 5, intervalMs: 2_000 });
|
||||
|
||||
const targetMaxHealth = state.maxHealth;
|
||||
const protectionCast = useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: null,
|
||||
now: 1_000,
|
||||
});
|
||||
expect(protectionCast.ok).toBe(true);
|
||||
const taggedProtection = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection")
|
||||
));
|
||||
expect(taggedProtection?.expiresAt).toBe(6_000);
|
||||
|
||||
const startingHealth = useCombatStore.getState().health;
|
||||
useCombatStore.getState().tick(0, 5_999);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
expect(useCombatStore.getState().health).toBe(startingHealth);
|
||||
useCombatStore.getState().tick(0, 6_000);
|
||||
const triggered = useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id);
|
||||
expect(triggered).toHaveLength(2);
|
||||
expect(triggered.every((effect) => effect.kind === "hot" && effect.targetId === null
|
||||
&& effect.remainingTicks === 5 && effect.nextTickAt === 8_000)).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(startingHealth);
|
||||
|
||||
const flatTickAmount = equipmentAdjustedCombatAmount(
|
||||
flatHot as Extract<NonNullable<typeof flatHot>, { kind: "hot" }>,
|
||||
80,
|
||||
state.gearStats,
|
||||
"rom-mage",
|
||||
passive,
|
||||
);
|
||||
expect(flatTickAmount).toBe(750);
|
||||
const expectedPerTick = Math.round(targetMaxHealth * 0.02 + flatTickAmount);
|
||||
const expectedTotal = expectedPerTick * 5;
|
||||
let observedTicks = 0;
|
||||
for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) {
|
||||
useCombatStore.getState().tick(0, now);
|
||||
observedTicks += 1;
|
||||
expect(useCombatStore.getState().health - startingHealth).toBe(expectedPerTick * observedTicks);
|
||||
}
|
||||
expect(useCombatStore.getState().health - startingHealth).toBe(expectedTotal);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not trigger Green Guardian for another pair or another aura caster", () => {
|
||||
prepareDruid("rom-mage");
|
||||
const protection = ability(493533);
|
||||
const wrongPairPassive = useCombatStore.getState().abilities.find((entry) => entry.dbcSpellId === 494331);
|
||||
expect(wrongPairPassive).toBeUndefined();
|
||||
expect(useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: null,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
useCombatStore.getState().tick(1, 6_000);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot")).toHaveLength(0);
|
||||
|
||||
prepareMageDruid();
|
||||
const passive = ability(494331);
|
||||
expect(useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: null,
|
||||
now: 10_000,
|
||||
}).ok).toBe(true);
|
||||
const taggedProtection = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection")
|
||||
));
|
||||
expect(taggedProtection).toBeDefined();
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.instanceId === taggedProtection!.instanceId
|
||||
? { ...aura, sourceId: "another-caster" }
|
||||
: aura),
|
||||
}));
|
||||
useCombatStore.getState().tick(1, taggedProtection!.expiresAt!);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("heals the friendly recipient of Mother Earth's Protection when it expires", () => {
|
||||
prepareMageDruid();
|
||||
usePartyStore.getState().initializeParty("green-guardian-recipient", 80, 1, "damage", 5, "rom");
|
||||
const recipient = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === recipient.id
|
||||
? { ...member, health: 1 }
|
||||
: member) }));
|
||||
registerPartyRuntimePosition(recipient.id, [4, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const passive = ability(494331);
|
||||
const protection = ability(493533);
|
||||
const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined);
|
||||
const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined);
|
||||
const percentMaxHealth = percentHot?.kind === "hot" ? percentHot.percentMaxHealth ?? 0 : 0;
|
||||
expect(percentMaxHealth).toBe(0.02);
|
||||
expect(useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: recipient.id,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
const protectionAura = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.targetId === recipient.id
|
||||
&& aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection")
|
||||
));
|
||||
expect(protectionAura?.expiresAt).toBe(6_000);
|
||||
useCombatStore.getState().tick(0, protectionAura!.expiresAt!);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id
|
||||
&& effect.targetId === recipient.id)).toHaveLength(2);
|
||||
|
||||
const targetMaxHealth = recipient.maxHealth;
|
||||
const flatTickAmount = equipmentAdjustedCombatAmount(
|
||||
flatHot as Extract<NonNullable<typeof flatHot>, { kind: "hot" }>,
|
||||
80,
|
||||
useCombatStore.getState().gearStats,
|
||||
"rom-mage",
|
||||
passive,
|
||||
);
|
||||
const expectedPerTick = Math.round(targetMaxHealth * percentMaxHealth + flatTickAmount);
|
||||
for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) {
|
||||
const before = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health;
|
||||
useCombatStore.getState().tick(0, now);
|
||||
const after = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health;
|
||||
expect(after - before).toBe(expectedPerTick);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a party Mage/Druid's Green Guardian when their protection expires", () => {
|
||||
prepareMageDruid();
|
||||
const caster = createConfiguredPartyMember({
|
||||
name: "Mage Druid",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
specialization: "Mage",
|
||||
specializationId: "rom-mage-druid",
|
||||
talentTreeId: "rom-mage-druid",
|
||||
role: "damage",
|
||||
}, 80, 0, "green-guardian-companion");
|
||||
usePartyStore.getState().initializeParty("green-guardian-companion", 80,
|
||||
useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({ members: [caster] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const passive = ability(494331);
|
||||
const protection = ability(493533);
|
||||
const protectionEffect = protection.effects.find((effect) => (
|
||||
effect.kind === "apply-aura"
|
||||
&& effect.aura.utilityTags?.includes("rom-druid-mother-earth-protection")
|
||||
));
|
||||
if (!protectionEffect || protectionEffect.kind !== "apply-aura") {
|
||||
throw new Error("Mother Earth's Protection did not expose its tagged aura effect.");
|
||||
}
|
||||
expect(useCombatStore.getState().applyActorAura(
|
||||
caster.id,
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
protection,
|
||||
protectionEffect,
|
||||
1_000,
|
||||
)).toBe(true);
|
||||
|
||||
useCombatStore.getState().tick(0, 6_000);
|
||||
const triggered = useCombatStore.getState().effects.filter((effect) => (
|
||||
effect.sourceAbilityId === passive.id && effect.sourceActorId === caster.id
|
||||
));
|
||||
expect(triggered).toHaveLength(2);
|
||||
expect(triggered.every((effect) => effect.kind === "hot"
|
||||
&& effect.targetId === null && effect.nextTickAt === 8_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent,
|
||||
romKnightWarriorWhirlwindAdjustment,
|
||||
} from "./romKnightOverrides";
|
||||
|
||||
describe("RoM Knight/Warrior Deadly Whirlwind", () => {
|
||||
const whirlwind = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490133)!;
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491639)!;
|
||||
|
||||
it("applies the local 10-Rage and 20-second reductions to Warrior Whirlwind for the learned pair passive", () => {
|
||||
expect(passive).toMatchObject({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-warrior",
|
||||
passive: true,
|
||||
});
|
||||
expect(whirlwind).toMatchObject({ classId: "rom-warrior", source: "runewaker" });
|
||||
|
||||
expect(romKnightWarriorWhirlwindAdjustment(
|
||||
"rom-knight",
|
||||
"rom-warrior",
|
||||
whirlwind,
|
||||
[491639],
|
||||
)).toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 });
|
||||
});
|
||||
|
||||
it("does not apply without the exact pair, learned passive, source, or Whirlwind spell", () => {
|
||||
const adjustments = [
|
||||
romKnightWarriorWhirlwindAdjustment("rom-warrior", "rom-knight", whirlwind, [491639]),
|
||||
romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", whirlwind, []),
|
||||
romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", {
|
||||
...whirlwind,
|
||||
dbcSpellId: 490175,
|
||||
}, [491639]),
|
||||
romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", {
|
||||
...whirlwind,
|
||||
source: "core",
|
||||
}, [491639]),
|
||||
];
|
||||
|
||||
expect(adjustments).toEqual(Array.from({ length: 4 }, () => ({
|
||||
rageCostReduction: 0,
|
||||
cooldownReductionMs: 0,
|
||||
})));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoM Knight/Rogue God's Sword", () => {
|
||||
const holyStrike = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490148)!;
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491621)!;
|
||||
|
||||
it("provides the local 40% critical-rate increase only to Holy Strike for the learned pair passive", () => {
|
||||
expect(passive).toMatchObject({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
passive: true,
|
||||
});
|
||||
expect(holyStrike).toMatchObject({ classId: "rom-knight", source: "runewaker" });
|
||||
|
||||
expect(romKnightRogueHolyStrikeCriticalRateIncreasePercent(
|
||||
"rom-knight",
|
||||
"rom-rogue",
|
||||
holyStrike,
|
||||
[491621],
|
||||
)).toBe(40);
|
||||
});
|
||||
|
||||
it("does not apply God's Sword to other pairings, abilities, unlearned passives, or non-RuneWaker spells", () => {
|
||||
const bonuses = [
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-rogue", "rom-knight", holyStrike, [491621]),
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", holyStrike, []),
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", {
|
||||
...holyStrike,
|
||||
dbcSpellId: 490149,
|
||||
}, [491621]),
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", {
|
||||
...holyStrike,
|
||||
source: "core",
|
||||
}, [491621]),
|
||||
];
|
||||
|
||||
expect(bonuses).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,69 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { AuraProcDefinition } from "./combatAuras";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
export const ROM_KNIGHT_DIVINE_PUNISHMENT_TAG = "rom-divine-punishment";
|
||||
export const ROM_KNIGHT_GODS_VENGEANCE_TAG = "rom-gods-vengeance";
|
||||
export const ROM_KNIGHT_SHIELD_OF_VALOR_TAG = "rom-shield-of-valor";
|
||||
export const ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG = "rom-holy-lights-fury";
|
||||
export const ROM_KNIGHT_MASTER_OF_PARRY_TAG = "rom-knight-master-of-parry";
|
||||
export const ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC = "rom-knight-master-of-parry-block";
|
||||
|
||||
/** Shield of Valor reduces Holy Seal's remaining cooldown only on an active-aura parry. */
|
||||
export function romKnightShieldOfValorCooldownReductionMs(auraActive: boolean, parried: boolean): number {
|
||||
return auraActive && parried ? 1_000 : 0;
|
||||
}
|
||||
|
||||
export interface RomKnightWarriorWhirlwindAdjustment {
|
||||
readonly rageCostReduction: number;
|
||||
readonly cooldownReductionMs: number;
|
||||
}
|
||||
|
||||
/** RuneWaker Knight/Warrior passive 491639 modifies only Warrior Whirlwind. */
|
||||
export function romKnightWarriorWhirlwindAdjustment(
|
||||
characterClassId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
): RomKnightWarriorWhirlwindAdjustment {
|
||||
if (characterClassId !== "rom-knight"
|
||||
|| secondaryClassId !== "rom-warrior"
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-warrior"
|
||||
|| ability.dbcSpellId !== 490133
|
||||
|| !learnedPassiveSpellIds.includes(491639)) {
|
||||
return { rageCostReduction: 0, cooldownReductionMs: 0 };
|
||||
}
|
||||
|
||||
// The local 491639 tooltip explicitly says 10 Rage and 20 seconds.
|
||||
return { rageCostReduction: 10, cooldownReductionMs: 20_000 };
|
||||
}
|
||||
|
||||
/** RuneWaker Knight/Rogue passive 491621 boosts only Holy Strike's critical rate. */
|
||||
export function romKnightRogueHolyStrikeCriticalRateIncreasePercent(
|
||||
characterClassId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
): number {
|
||||
if (characterClassId !== "rom-knight"
|
||||
|| secondaryClassId !== "rom-rogue"
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-knight"
|
||||
|| ability.dbcSpellId !== 490148
|
||||
|| !learnedPassiveSpellIds.includes(491621)) return 0;
|
||||
|
||||
// The populated local tooltip specifies a 40% Holy Strike critical-rate increase.
|
||||
return 40;
|
||||
}
|
||||
|
||||
const ROM_KNIGHT_MASTER_OF_PARRY_PROC: AuraProcDefinition = {
|
||||
id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC,
|
||||
triggers: "block",
|
||||
chance: 1,
|
||||
charges: 2,
|
||||
action: { kind: "custom", id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC },
|
||||
};
|
||||
|
||||
function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
||||
return [{
|
||||
@@ -28,6 +88,31 @@ export function overrideRomKnightEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.sourceSkillId === 492935) {
|
||||
const durationMs = Math.max(0, ...(row.source?.components ?? []).map((component) => (
|
||||
Math.max(0, Number(component.effectTime ?? 0)) * 1_000
|
||||
)));
|
||||
return translated.map((effect) => {
|
||||
if (effect.kind !== "apply-aura") return effect;
|
||||
if (effect.aura.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "parry"
|
||||
))) return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_MASTER_OF_PARRY_TAG],
|
||||
procs: [...(effect.aura.procs ?? []), ROM_KNIGHT_MASTER_OF_PARRY_PROC],
|
||||
},
|
||||
};
|
||||
// Source component 503192 drives a periodic trigger for the same 15s
|
||||
// effect. Keep that hidden trigger aura on the component's lifetime too.
|
||||
if (effect.aura.procs?.some((proc) => proc.id.startsWith("rom-proc-trigger-492935-503192"))) return {
|
||||
...effect,
|
||||
aura: { ...effect.aura, durationMs: durationMs || effect.aura.durationMs },
|
||||
};
|
||||
return effect;
|
||||
});
|
||||
}
|
||||
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
|
||||
if (row.sourceSkillId === 499934 && row.passive) return [{
|
||||
kind: "apply-aura",
|
||||
@@ -44,6 +129,21 @@ export function overrideRomKnightEffects(
|
||||
miscValue: 0,
|
||||
triggerSpellId: 499934,
|
||||
}];
|
||||
if (row.sourceSkillId === 491635 && row.passive) return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-knight-passive-491635",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491635,
|
||||
}];
|
||||
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: {
|
||||
@@ -60,6 +160,23 @@ export function romKnightCastEffects(
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: readonly string[] | ReadonlySet<string> = [],
|
||||
): readonly AbilityEffect[] {
|
||||
const hasHolyLightsFury = Array.isArray(passiveTags)
|
||||
? passiveTags.includes(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG);
|
||||
if (ability.source === "runewaker"
|
||||
&& ability.classId === "rom-knight"
|
||||
&& ability.dbcSpellId === 490149
|
||||
&& hasHolyLightsFury) {
|
||||
// RuneWaker's local tooltip gives a flat 3-second reduction to Holy Seal.
|
||||
// The added damage rider has no numeric payload in this source snapshot.
|
||||
return [...effects, {
|
||||
kind: "cooldown-reset",
|
||||
mode: "reduce",
|
||||
abilityName: "Holy Seal",
|
||||
amountMs: 3_000,
|
||||
}];
|
||||
}
|
||||
|
||||
if (ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-knight"
|
||||
|| ability.dbcSpellId !== 490151
|
||||
@@ -67,7 +184,7 @@ export function romKnightCastEffects(
|
||||
? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects;
|
||||
|
||||
return effects.map((effect) => {
|
||||
const increasedDamage = effects.map((effect) => {
|
||||
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
||||
return {
|
||||
...effect,
|
||||
@@ -78,4 +195,25 @@ export function romKnightCastEffects(
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.01 }),
|
||||
};
|
||||
});
|
||||
|
||||
// Generated passive 491608 includes component 502052: a ten-second, +1%
|
||||
// Light-damage vulnerability. The generic modifier map treats type 225 as
|
||||
// Light resistance, which would make the target take less damage despite
|
||||
// the local tooltip explicitly saying it takes more Light damage.
|
||||
return [...increasedDamage, {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-knight-divine-punishment-light-vulnerability",
|
||||
name: "Divine Punishment",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 225,
|
||||
miscValue: 1,
|
||||
triggerSpellId: 502052,
|
||||
}];
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { abilitiesForClass, abilityById } from "./abilityCatalog";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_KNIGHT_DIVINE_PUNISHMENT_TAG,
|
||||
ROM_KNIGHT_GODS_VENGEANCE_TAG,
|
||||
ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG,
|
||||
ROM_KNIGHT_SHIELD_OF_VALOR_TAG,
|
||||
} from "./romKnightOverrides";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
@@ -32,6 +35,7 @@ function godsVengeanceCastTime(wantProc: boolean): number {
|
||||
describe("RuneWaker Knight periodic skills", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
@@ -105,6 +109,13 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
expect(useCombatStore.getState().comboTargetId).toBe("war-prayer-target");
|
||||
expect(useCombatStore.getState().comboPoints).toBe(Math.min(tick, 4));
|
||||
}
|
||||
|
||||
const punishment = abilityById("rom-knight-general-0")!;
|
||||
expect(useCombatStore.getState().castAbility(punishment.id, undefined, 20_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().comboPoints).toBe(0);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.abilityId === punishment.id && event.targetActorId === "war-prayer-target"
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("limits Divine Punishment's sourced 1% bonus to Punishment", () => {
|
||||
@@ -162,6 +173,56 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
})).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context));
|
||||
});
|
||||
|
||||
it("makes Divine Punishment apply its sourced Light-damage vulnerability", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
|
||||
});
|
||||
useCombatStore.getState().registerMob("divine-punishment-target", {
|
||||
name: "Divine Punishment Target", maxHealth: 100_000,
|
||||
});
|
||||
setMobPosition("divine-punishment-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("divine-punishment-target");
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 12_000))
|
||||
.toMatchObject({ ok: true });
|
||||
|
||||
const vulnerability = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === "divine-punishment-target"
|
||||
&& aura.definition.id === "rom-knight-divine-punishment-light-vulnerability"
|
||||
));
|
||||
expect(vulnerability?.definition).toMatchObject({
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }],
|
||||
});
|
||||
expect(vulnerability?.expiresAt).toBe(22_000);
|
||||
});
|
||||
|
||||
it("reduces Holy Seal cooldown by three seconds with Holy Light's Fury", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
||||
});
|
||||
const state = useCombatStore.getState();
|
||||
const holySeal = state.abilities.find((ability) => ability.dbcSpellId === 490149)!;
|
||||
const furyPassive = state.abilities.find((ability) => ability.dbcSpellId === 491635)!;
|
||||
expect(furyPassive.passive).toBe(true);
|
||||
expect(furyPassive.effects).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG] }),
|
||||
}),
|
||||
]);
|
||||
|
||||
useCombatStore.getState().registerMob("holy-lights-fury-target", {
|
||||
name: "Holy Light's Fury Target", maxHealth: 100_000,
|
||||
});
|
||||
setMobPosition("holy-lights-fury-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("holy-lights-fury-target");
|
||||
expect(useCombatStore.getState().castAbility(holySeal.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(8_000);
|
||||
});
|
||||
|
||||
it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => {
|
||||
const godVengeance = abilityById("rom-knight-rom-rogue-elite-50")!;
|
||||
expect(godVengeance).toMatchObject({ dbcSpellId: 499934, passive: true });
|
||||
@@ -246,6 +307,84 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(24_000);
|
||||
});
|
||||
|
||||
it("reduces only the party Knight's Holy Seal cooldown on an active Shield of Valor parry", () => {
|
||||
const runIncomingCase = (
|
||||
suffix: string,
|
||||
auraActive: boolean,
|
||||
parryRating: number,
|
||||
wantedOutcome: "parry" | "hit",
|
||||
) => {
|
||||
const seed = `shield-of-valor-party-${suffix}`;
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
||||
const knight = createConfiguredPartyMember({
|
||||
name: "Shield of Valor Knight",
|
||||
classId: "rom-knight",
|
||||
specialization: "Primary",
|
||||
role: "tank",
|
||||
}, 80, 0, seed);
|
||||
const knightAbilities = abilitiesForClass("rom-knight");
|
||||
const holySeal = knightAbilities.find((ability) => ability.source === "runewaker"
|
||||
&& ability.dbcSpellId === 490_149)!;
|
||||
const shieldOfValor = knightAbilities.find((ability) => ability.source === "runewaker"
|
||||
&& ability.dbcSpellId === 490_468)!;
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "tank", 2, "rom");
|
||||
usePartyStore.setState({ members: [{
|
||||
...knight,
|
||||
gearStats: { ...knight.gearStats, parryRating },
|
||||
cooldowns: { ...knight.cooldowns, [holySeal.id]: 25_000 },
|
||||
}] });
|
||||
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
if (auraActive) {
|
||||
const shieldAura = shieldOfValor.effects.find((effect) => effect.kind === "apply-aura");
|
||||
if (!shieldAura || shieldAura.kind !== "apply-aura") throw new Error("Shield of Valor has no aura row");
|
||||
useCombatStore.getState().applyActorAura(knight.id, knight.id, shieldOfValor, shieldAura, 1_000);
|
||||
}
|
||||
|
||||
const mobId = `shield-of-valor-party-attacker-${suffix}`;
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Shield of Valor party attacker",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [DEFAULT_ENEMY_ATTACK],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_100, {
|
||||
actorId: knight.id,
|
||||
role: "tank",
|
||||
});
|
||||
|
||||
let outcome: string | undefined;
|
||||
for (let now = 1_450; now < 16_000; now += 100) {
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
const event = [...useCombatStore.getState().combatEvents].reverse().find((entry) => (
|
||||
entry.sourceActorId === mobId && entry.targetActorId === knight.id && entry.occurredAt === now
|
||||
));
|
||||
if (event?.outcome === wantedOutcome) {
|
||||
outcome = event.outcome;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
cooldown: useCombatStore.getState().actors[knight.id]?.cooldowns[holySeal.id],
|
||||
};
|
||||
};
|
||||
|
||||
const activeParry = runIncomingCase("active-parry", true, 100_000, "parry");
|
||||
expect(activeParry.outcome).toBe("parry");
|
||||
expect(activeParry.cooldown).toBe(24_000);
|
||||
|
||||
const inactiveParry = runIncomingCase("inactive-parry", false, 100_000, "parry");
|
||||
expect(inactiveParry.outcome).toBe("parry");
|
||||
expect(inactiveParry.cooldown).toBe(25_000);
|
||||
|
||||
const activeHit = runIncomingCase("active-hit", true, 0, "hit");
|
||||
expect(activeHit.outcome).toBe("hit");
|
||||
expect(activeHit.cooldown).toBe(25_000);
|
||||
});
|
||||
|
||||
it("activates the source-backed Evil Shackles passive state", () => {
|
||||
const passive = abilityById("rom-knight-rom-scout-elite-60")!;
|
||||
expect(passive).toMatchObject({ dbcSpellId: 499926, passive: true });
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
function playerWhirlwind(state: ReturnType<typeof useCombatStore.getState>): AbilityDefinition {
|
||||
const ability = state.abilities.find((candidate) => candidate.dbcSpellId === 490133);
|
||||
if (!ability) throw new Error("RuneWaker Warrior Whirlwind is missing from Knight/Warrior abilities.");
|
||||
return ability;
|
||||
}
|
||||
|
||||
describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
const initial = useCombatStore.getState();
|
||||
const whirlwind = playerWhirlwind(initial);
|
||||
expect(initial.abilities.find((ability) => ability.dbcSpellId === 491639)).toMatchObject({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-warrior",
|
||||
passive: true,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, rage: 40 },
|
||||
maxResourcePools: { ...state.maxResourcePools, rage: 100 },
|
||||
}));
|
||||
useCombatStore.getState().registerMob("knight-whirlwind-target", {
|
||||
name: "Whirlwind Target",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("knight-whirlwind-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("knight-whirlwind-target");
|
||||
|
||||
const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000);
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
const final = useCombatStore.getState();
|
||||
expect(final.resourcePools.rage).toBe(0);
|
||||
expect(final.cooldowns[whirlwind.id]).toBe(11_000);
|
||||
expect(final.combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.abilityId === whirlwind.id && event.targetActorId === "knight-whirlwind-target"
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the learned paired passive for companion Whirlwind cost and cooldown", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const knightSeed = createConfiguredPartyMember({
|
||||
name: "Knight Warrior",
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, "deadly-whirlwind-party");
|
||||
const abilities = abilitiesForCharacter("rom-knight", "rom-warrior");
|
||||
const whirlwind = abilities.find((ability) => ability.dbcSpellId === 490133)!;
|
||||
const knight = {
|
||||
...knightSeed,
|
||||
resourcePools: { ...knightSeed.resourcePools, rage: 40 },
|
||||
cooldowns: Object.fromEntries(abilities
|
||||
.filter((ability) => ability.id !== whirlwind.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
};
|
||||
usePartyStore.setState({ members: [knight], command: "attack" });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("party-knight-whirlwind-target", {
|
||||
name: "Party Whirlwind Target",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
// Keep the target inside Whirlwind's area but outside the companion's melee range
|
||||
// so its autoattack cannot add Rage after the discounted spell is spent.
|
||||
setMobPosition("party-knight-whirlwind-target", [10, 0, 0]);
|
||||
useCombatStore.getState().damageMob("party-knight-whirlwind-target", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(knight.id, "party-knight-whirlwind-target", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!;
|
||||
expect(finalMember.resourcePools.rage).toBe(0);
|
||||
expect(finalMember.cooldowns[whirlwind.id]).toBe(11_000);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === knight.id && event.abilityId === whirlwind.id
|
||||
&& event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_MAGE_KNIGHT_LIGHT_OF_DAY,
|
||||
} from "./romMageRuntime";
|
||||
|
||||
const TARGET_ID = "light-of-day-target";
|
||||
const ACTIVE_TAG = "rom-mage-knight-light-of-day-active";
|
||||
|
||||
function initializeMageKnight(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-knight",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
}
|
||||
|
||||
function registerTarget(): void {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function aura(actorId: string, tag: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId
|
||||
&& (entry.expiresAt === null || entry.expiresAt > now)
|
||||
&& entry.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
describe("RoM Mage/Knight Light of Day", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
initializeMageKnight();
|
||||
});
|
||||
|
||||
it("stays suppressed at startup, triggers on both named hits, and observes its 20-second ICD", () => {
|
||||
registerTarget();
|
||||
const holyLightStrike = abilityById("rom-mage-rom-knight-elite-15");
|
||||
const lightCharge = abilityById("rom-mage-rom-knight-elite-35");
|
||||
const passive = abilityById("rom-mage-rom-knight-elite-45");
|
||||
if (!holyLightStrike || !lightCharge || !passive) throw new Error("Missing local Mage/Knight Light of Day rows");
|
||||
expect([holyLightStrike.dbcSpellId, lightCharge.dbcSpellId]).toEqual([491_594, 492_634]);
|
||||
expect(passive.dbcSpellId).toBe(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId);
|
||||
expect(passive.effects).toEqual([]);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 1_000)).toBeUndefined();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(holyLightStrike.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 3_100)?.expiresAt).toBe(18_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 3_100)?.expiresAt).toBe(23_100);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(lightCharge.id, undefined, 7_000)).toMatchObject({ ok: true });
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 7_000)?.expiresAt).toBe(18_100);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(lightCharge.id, undefined, 24_000)).toMatchObject({ ok: true });
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 24_000)?.expiresAt).toBe(39_000);
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 24_000)?.expiresAt).toBe(44_000);
|
||||
});
|
||||
|
||||
it("triggers for a landed party Mage/Knight hit with an independent caster ICD", () => {
|
||||
const seed = "light-of-day-party-mage-knight";
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Light of Day Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-knight",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const holyLightStrike = abilityById("rom-mage-rom-knight-elite-15");
|
||||
if (!holyLightStrike) throw new Error("Missing local Mage/Knight Holy Light Strike row");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...member.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: holyLightStrike.id,
|
||||
school: "holy",
|
||||
mode: "cast",
|
||||
startedAt: 500,
|
||||
completesAt: 2_500,
|
||||
originalDurationMs: 2_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 2_400);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(2_500);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === member.id
|
||||
&& event.abilityId === holyLightStrike.id && event.outcome === "hit")).toBe(true);
|
||||
expect(aura(member.id, ACTIVE_TAG, 2_500)?.expiresAt).toBe(17_500);
|
||||
expect(aura(member.id, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 2_500)?.expiresAt).toBe(22_500);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 2_500)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||
romMageWarlockCastEffects,
|
||||
} from "./romMageWarlockOverrides";
|
||||
import { overrideRomMageEffects } from "./romMageOverrides";
|
||||
import { applyTalentModifierValue } from "./talentModifiers";
|
||||
|
||||
function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void {
|
||||
@@ -362,4 +363,109 @@ describe("RoM Mage mechanics", () => {
|
||||
expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("maps Elemental Weakness to all modeled resistances and only weakens its target for 30 seconds", () => {
|
||||
const weakness = abilityById("rom-mage-primary-11")!;
|
||||
const sourceAura = weakness.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(sourceAura).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: expect.objectContaining({ durationMs: 30_000 }),
|
||||
});
|
||||
if (!sourceAura || sourceAura.kind !== "apply-aura") throw new Error("Elemental Weakness aura missing");
|
||||
|
||||
const rankTwo = weakness.ranks?.find((rank) => rank.spellId === 497977);
|
||||
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "magical-defense" && modifier.value === -0.01))).toBe(true);
|
||||
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat.startsWith("resistance:")))).toBe(false);
|
||||
|
||||
const armorModifier = { kind: "stat" as const, stat: "armor", operation: "flat" as const, value: -12 };
|
||||
const enrichedAura = {
|
||||
...sourceAura,
|
||||
aura: {
|
||||
...sourceAura.aura,
|
||||
modifiers: [
|
||||
{ kind: "stat" as const, stat: "all-resistance", operation: "flat" as const, value: -30 },
|
||||
armorModifier,
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = overrideRomMageEffects({
|
||||
classId: "rom-mage",
|
||||
sourceSkillId: 490247,
|
||||
name: "Elemental Weakness",
|
||||
description: "",
|
||||
}, [enrichedAura]);
|
||||
expect(resolved?.[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
durationMs: 30_000,
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "resistance:arcane", operation: "flat", value: -30 },
|
||||
{ kind: "stat", stat: "resistance:fire", operation: "flat", value: -30 },
|
||||
{ kind: "stat", stat: "resistance:frost", operation: "flat", value: -30 },
|
||||
{ kind: "stat", stat: "resistance:nature", operation: "flat", value: -30 },
|
||||
{ kind: "stat", stat: "resistance:shadow", operation: "flat", value: -30 },
|
||||
armorModifier,
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(overrideRomMageEffects({
|
||||
classId: "rom-priest", sourceSkillId: 490247, name: "Elemental Weakness", description: "",
|
||||
}, [enrichedAura])).toBe(null);
|
||||
expect(overrideRomMageEffects({
|
||||
classId: "rom-mage", sourceSkillId: 497977, name: "Elemental Weakness", description: "",
|
||||
}, [enrichedAura])).toBe(null);
|
||||
|
||||
const flameDamage = (useWeakness: boolean, waitForExpiry = false): number => {
|
||||
initializeMage(undefined, 60);
|
||||
const targetId = "elemental-weakness-resistance-dummy";
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: targetId,
|
||||
maxHealth: 10_000_000,
|
||||
resistances: { fire: 1_000 },
|
||||
});
|
||||
useCombatStore.getState().registerMob("elemental-weakness-untouched-dummy", {
|
||||
name: "elemental-weakness-untouched-dummy",
|
||||
maxHealth: 10_000_000,
|
||||
resistances: { fire: 1_000 },
|
||||
});
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
setMobPosition("elemental-weakness-untouched-dummy", [4, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
if (useWeakness) {
|
||||
const result = useCombatStore.getState().castAbility(weakness.id, undefined, 1_000);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
}
|
||||
const castAt = waitForExpiry ? 31_100 : 2_600;
|
||||
if (waitForExpiry) useCombatStore.getState().tick(30.1, castAt);
|
||||
const target = useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "resistance:fire"));
|
||||
expect(Boolean(target)).toBe(useWeakness && !waitForExpiry);
|
||||
if (useWeakness && !waitForExpiry) expect(target?.expiresAt).toBe(31_000);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "elemental-weakness-untouched-dummy"
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat.startsWith("resistance:")))).toBe(false);
|
||||
return Array.from({ length: 64 }, (_, index) => useCombatStore.getState().damageMob(
|
||||
targetId,
|
||||
10_000,
|
||||
castAt + index,
|
||||
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: "rom-mage-primary-1" },
|
||||
"fire",
|
||||
{ attackKind: "spell", seed: `elemental-weakness-${index}`, abilityId: "rom-mage-primary-1" },
|
||||
).amount).reduce((total, damage) => total + damage, 0);
|
||||
};
|
||||
|
||||
const baselineDamage = flameDamage(false);
|
||||
const weakenedDamage = flameDamage(true);
|
||||
const baselineAfterWaitDamage = flameDamage(false, true);
|
||||
const expiredDamage = flameDamage(true, true);
|
||||
expect(weakenedDamage).toBeGreaterThan(baselineDamage);
|
||||
expect(expiredDamage).toBeCloseTo(baselineAfterWaitDamage, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_MAGE_MEDITATIVE_CURRENT } from "./romMageRuntime";
|
||||
|
||||
const TARGET_ID = "meditative-current-target";
|
||||
|
||||
function initializeMageWarrior(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
}
|
||||
|
||||
function registerTarget(): void {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function aura(actorId: string, tag: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId
|
||||
&& (entry.expiresAt === null || entry.expiresAt > now)
|
||||
&& entry.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
describe("RoM Mage Meditative Current", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
initializeMageWarrior();
|
||||
});
|
||||
|
||||
it("triggers on landed Flame and Plasma Arrow hits, not Fireball, and observes its 30-second ICD", () => {
|
||||
registerTarget();
|
||||
const fireball = abilityById("rom-mage-general-0");
|
||||
const flame = abilityById("rom-mage-primary-1");
|
||||
const plasmaArrow = abilityById("rom-mage-primary-52");
|
||||
const passive = abilityById("rom-mage-rom-warrior-elite-50");
|
||||
if (!fireball || !flame || !plasmaArrow) throw new Error("Missing local Mage trigger spell rows");
|
||||
expect([fireball.dbcSpellId, flame.dbcSpellId, plasmaArrow.dbcSpellId]).toEqual([490_204, 491_150, 491_169]);
|
||||
expect(passive?.dbcSpellId).toBe(493_262);
|
||||
expect(passive?.effects).toEqual([]);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 1_000)).toBeUndefined();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 2_600)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 5_700);
|
||||
expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 5_700)?.expiresAt).toBe(15_700);
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag, 5_700)?.expiresAt).toBe(35_700);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(plasmaArrow.id, undefined, 6_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 8_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 8_100)?.expiresAt).toBe(15_700);
|
||||
});
|
||||
|
||||
it("applies a separate Meditative Current proc and ICD to a party Mage caster", () => {
|
||||
const seed = "meditative-current-party-mage";
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Meditative Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const flame = abilityById("rom-mage-primary-1");
|
||||
if (!flame) throw new Error("Missing local Mage Flame row");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...member.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: flame.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: 500,
|
||||
completesAt: 3_500,
|
||||
originalDurationMs: 3_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(3_500);
|
||||
|
||||
expect(aura(member.id, "rom-mage-meditative-current-active", 3_500)?.expiresAt).toBe(13_500);
|
||||
expect(aura(member.id, ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag, 3_500)?.expiresAt).toBe(33_500);
|
||||
expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 3_500)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
// NPC spell mitigation models these five resistance fields. RuneWaker's
|
||||
// Elemental Weakness component (500365) is an all-resistance −30 modifier;
|
||||
// expand it so the debuff affects the same fields used by damage resolution.
|
||||
const MODELED_MAGIC_RESISTANCES = ["arcane", "fire", "frost", "nature", "shadow"] as const;
|
||||
|
||||
export function overrideRomMageEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.classId !== "rom-mage") return null;
|
||||
|
||||
// Meditative Current is a hit-triggered passive, not an always-on haste
|
||||
// aura. Its exact trigger and cooldown are applied by romMageRuntime.
|
||||
if (row.sourceSkillId === 493262) return [];
|
||||
// Light of Day is a landed-hit proc for Mage/Knight builds, not an always-on
|
||||
// damage aura. Its trigger and internal cooldown are applied at hit time.
|
||||
if (row.sourceSkillId === 493034) return [];
|
||||
if (row.sourceSkillId !== 490247) return null;
|
||||
|
||||
return translated.map((effect) => {
|
||||
if (effect.kind !== "apply-aura") return effect;
|
||||
const modifiers = effect.aura.modifiers ?? [];
|
||||
if (!modifiers.some((modifier) => modifier.kind === "stat" && modifier.stat === "all-resistance")) {
|
||||
return effect;
|
||||
}
|
||||
|
||||
return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
modifiers: modifiers.flatMap((modifier) => modifier.kind === "stat" && modifier.stat === "all-resistance"
|
||||
? MODELED_MAGIC_RESISTANCES.map((school) => ({ ...modifier, stat: `resistance:${school}` }))
|
||||
: [modifier]),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import {
|
||||
ROM_MAGE_KNIGHT_LIGHT_OF_DAY,
|
||||
ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT,
|
||||
ROM_MAGE_MEDITATIVE_CURRENT,
|
||||
ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT,
|
||||
romMageKnightLightOfDayProc,
|
||||
romMageKnightLightOfDayReadyAt,
|
||||
romMageMeditativeCurrentReadyAt,
|
||||
romMageMeditativeCurrentProc,
|
||||
} from "./romMageRuntime";
|
||||
|
||||
const passiveIds = [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId];
|
||||
|
||||
describe("RoM Mage runtime", () => {
|
||||
it("grants Meditative Current only after landed Flame or Plasma Arrow hits with the passive learned", () => {
|
||||
for (const dbcSpellId of ROM_MAGE_MEDITATIVE_CURRENT.triggerSpellIds) {
|
||||
const result = romMageMeditativeCurrentProc(
|
||||
"rom-mage",
|
||||
{ source: "runewaker", dbcSpellId },
|
||||
passiveIds,
|
||||
true,
|
||||
1_000,
|
||||
1_000,
|
||||
);
|
||||
|
||||
expect(result.nextReadyAt).toBe(31_000);
|
||||
expect(result.effect).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
triggerSpellId: 493_262,
|
||||
aura: {
|
||||
id: "rom-mage-meditative-current",
|
||||
durationMs: 10_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "spell-haste",
|
||||
operation: "flat",
|
||||
value: 10,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves readiness and rejects misses, other spells/classes, unlearned passives, and non-RuneWaker rows", () => {
|
||||
const rejectedInputs = [
|
||||
{ classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_150 }, passives: [], landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 490_204 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: false, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-priest", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", ability: { source: "core", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 101, now: 100 },
|
||||
] as const;
|
||||
|
||||
for (const input of rejectedInputs) {
|
||||
expect(romMageMeditativeCurrentProc(
|
||||
input.classId,
|
||||
input.ability,
|
||||
input.passives,
|
||||
input.landed,
|
||||
input.readyAt,
|
||||
input.now,
|
||||
)).toEqual({ effect: null, nextReadyAt: input.readyAt });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the active cooldown per caster from its marker aura", () => {
|
||||
const aura = {
|
||||
targetId: "mage-one",
|
||||
expiresAt: 30_000,
|
||||
definition: ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT.aura,
|
||||
};
|
||||
expect(romMageMeditativeCurrentReadyAt([aura], "mage-one", 10_000)).toBe(30_000);
|
||||
expect(romMageMeditativeCurrentReadyAt([aura], "mage-two", 10_000)).toBe(10_000);
|
||||
expect(romMageMeditativeCurrentReadyAt([aura], "mage-one", 31_000)).toBe(31_000);
|
||||
});
|
||||
|
||||
it("suppresses Light of Day's generic aura and grants its source values on either landed trigger", () => {
|
||||
expect(abilityById("rom-mage-rom-knight-elite-45")?.effects).toEqual([]);
|
||||
|
||||
for (const dbcSpellId of ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds) {
|
||||
const result = romMageKnightLightOfDayProc(
|
||||
"rom-mage",
|
||||
"rom-knight",
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId },
|
||||
[ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId],
|
||||
true,
|
||||
4_000,
|
||||
4_000,
|
||||
);
|
||||
|
||||
expect(result.nextReadyAt).toBe(24_000);
|
||||
expect(result.effect).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
triggerSpellId: 493_034,
|
||||
aura: {
|
||||
id: "rom-mage-knight-light-of-day",
|
||||
durationMs: 15_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [{
|
||||
kind: "damage",
|
||||
direction: "dealt",
|
||||
school: "holy",
|
||||
operation: "percent",
|
||||
value: 0.08,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Light of Day misses, other builds or spells, nonlocal rows, and hits on cooldown", () => {
|
||||
const passiveIds = [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId];
|
||||
const rejectedInputs = [
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: [], landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 490_204 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: false, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-warrior", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-warrior", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-priest", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "core", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 },
|
||||
{ classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 101, now: 100 },
|
||||
] as const;
|
||||
|
||||
for (const input of rejectedInputs) {
|
||||
expect(romMageKnightLightOfDayProc(
|
||||
input.classId,
|
||||
input.secondaryClassId,
|
||||
input.ability,
|
||||
input.passives,
|
||||
input.landed,
|
||||
input.readyAt,
|
||||
input.now,
|
||||
)).toEqual({ effect: null, nextReadyAt: input.readyAt });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads Light of Day's cooldown separately for each caster", () => {
|
||||
const aura = {
|
||||
targetId: "knight-mage-one",
|
||||
expiresAt: 20_000,
|
||||
definition: ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT.aura,
|
||||
};
|
||||
expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-one", 5_000)).toBe(20_000);
|
||||
expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-two", 5_000)).toBe(5_000);
|
||||
expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-one", 20_000)).toBe(20_000);
|
||||
});
|
||||
});
|
||||
+193
-1
@@ -1,8 +1,200 @@
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { ActiveAura } from "./combatAuras";
|
||||
|
||||
const ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID = 498_871;
|
||||
const ELECTROSTATIC_CHARGE_SPELL_ID = 491_156;
|
||||
|
||||
export const ROM_MAGE_MEDITATIVE_CURRENT = Object.freeze({
|
||||
passiveSpellId: 493_262,
|
||||
triggerSpellIds: [491_150, 491_169] as const,
|
||||
durationMs: 10_000,
|
||||
cooldownMs: 30_000,
|
||||
spellHaste: 10,
|
||||
cooldownAuraTag: "rom-mage-meditative-current-cooldown",
|
||||
});
|
||||
|
||||
export const ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-mage-meditative-current-cooldown",
|
||||
name: "Meditative Current cooldown",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_MAGE_MEDITATIVE_CURRENT.cooldownMs,
|
||||
hideFromAuraStrip: true,
|
||||
utilityTags: [ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId,
|
||||
};
|
||||
|
||||
export const ROM_MAGE_KNIGHT_LIGHT_OF_DAY = Object.freeze({
|
||||
passiveSpellId: 493_034,
|
||||
triggerSpellIds: [491_594, 492_634] as const,
|
||||
durationMs: 15_000,
|
||||
cooldownMs: 20_000,
|
||||
lightDamagePercent: 8,
|
||||
cooldownAuraTag: "rom-mage-knight-light-of-day-cooldown",
|
||||
});
|
||||
|
||||
export const ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-mage-knight-light-of-day-cooldown",
|
||||
name: "Light of Day cooldown",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownMs,
|
||||
hideFromAuraStrip: true,
|
||||
utilityTags: [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId,
|
||||
};
|
||||
|
||||
/** Find Light of Day's active internal cooldown marker for one caster. */
|
||||
export function romMageKnightLightOfDayReadyAt(
|
||||
activeAuras: readonly Pick<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
casterId: string,
|
||||
now: number,
|
||||
): number {
|
||||
const cooldown = activeAuras.find((aura) => aura.targetId === casterId
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now));
|
||||
return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now;
|
||||
}
|
||||
|
||||
export interface RomMageKnightLightOfDayResult {
|
||||
readonly effect: AbilityEffect | null;
|
||||
readonly nextReadyAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RuneWaker Light of Day (493034): a landed Holy Light Strike (491594) or
|
||||
* Light Charge (492634) hit grants +8% Holy damage for 15 seconds, with a
|
||||
* 20-second internal cooldown. The effect only belongs to Mage/Knight builds.
|
||||
*/
|
||||
export function romMageKnightLightOfDayProc(
|
||||
classId: string | null,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "classId" | "secondaryClassId" | "source" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
landed: boolean,
|
||||
readyAt: number,
|
||||
now: number,
|
||||
): RomMageKnightLightOfDayResult {
|
||||
const eligible = classId === "rom-mage"
|
||||
&& secondaryClassId === "rom-knight"
|
||||
&& ability.classId === "rom-mage"
|
||||
&& ability.secondaryClassId === "rom-knight"
|
||||
&& ability.source === "runewaker"
|
||||
&& ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds.includes(ability.dbcSpellId as 491_594 | 492_634)
|
||||
&& learnedPassiveSpellIds.includes(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId)
|
||||
&& landed
|
||||
&& now >= readyAt;
|
||||
if (!eligible) return { effect: null, nextReadyAt: readyAt };
|
||||
|
||||
return {
|
||||
effect: {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-mage-knight-light-of-day",
|
||||
name: "Light of Day",
|
||||
description: "A landed Holy Light Strike or Light Charge hit grants +8% Holy damage for 15 seconds. This effect has a 20-second internal cooldown.",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: ["rom-mage-knight-light-of-day-active"],
|
||||
modifiers: [{
|
||||
kind: "damage",
|
||||
direction: "dealt",
|
||||
school: "holy",
|
||||
operation: "percent",
|
||||
value: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.lightDamagePercent / 100,
|
||||
}],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 49,
|
||||
miscValue: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.lightDamagePercent,
|
||||
triggerSpellId: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId,
|
||||
},
|
||||
nextReadyAt: now + ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Find the active per-caster internal cooldown marker, if one exists. */
|
||||
export function romMageMeditativeCurrentReadyAt(
|
||||
activeAuras: readonly Pick<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
casterId: string,
|
||||
now: number,
|
||||
): number {
|
||||
const cooldown = activeAuras.find((aura) => aura.targetId === casterId
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now));
|
||||
return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now;
|
||||
}
|
||||
|
||||
export interface RomMageMeditativeCurrentResult {
|
||||
readonly effect: AbilityEffect | null;
|
||||
readonly nextReadyAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RuneWaker Meditative Current (493262): a landed Flame (491150) or Plasma
|
||||
* Arrow (491169) hit grants +10 spell haste for 10 seconds, with a 30-second
|
||||
* internal cooldown. Cooldown readiness is passed in by the combat event owner.
|
||||
*/
|
||||
export function romMageMeditativeCurrentProc(
|
||||
classId: string | null,
|
||||
ability: Pick<AbilityDefinition, "source" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
landed: boolean,
|
||||
readyAt: number,
|
||||
now: number,
|
||||
): RomMageMeditativeCurrentResult {
|
||||
const eligible = classId === "rom-mage"
|
||||
&& ability.source === "runewaker"
|
||||
&& ROM_MAGE_MEDITATIVE_CURRENT.triggerSpellIds.some((spellId) => spellId === ability.dbcSpellId)
|
||||
&& learnedPassiveSpellIds.includes(ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId)
|
||||
&& landed
|
||||
&& now >= readyAt;
|
||||
if (!eligible) return { effect: null, nextReadyAt: readyAt };
|
||||
|
||||
return {
|
||||
effect: {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-mage-meditative-current",
|
||||
name: "Meditative Current",
|
||||
description: "A landed Flame or Plasma Arrow hit grants +10 spell haste for 10 seconds. This effect has a 30-second internal cooldown.",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_MAGE_MEDITATIVE_CURRENT.durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: ["rom-mage-meditative-current-active"],
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "spell-haste",
|
||||
operation: "flat",
|
||||
value: ROM_MAGE_MEDITATIVE_CURRENT.spellHaste,
|
||||
}],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 51,
|
||||
miscValue: -ROM_MAGE_MEDITATIVE_CURRENT.spellHaste,
|
||||
triggerSpellId: ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId,
|
||||
},
|
||||
nextReadyAt: now + ROM_MAGE_MEDITATIVE_CURRENT.cooldownMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mage/Champion's level-45 passive shortens Electrostatic Charge by 30 seconds. */
|
||||
export function romMageCooldownReductionMs(
|
||||
classId: string | null,
|
||||
|
||||
@@ -272,7 +272,7 @@ export function romMageWarlockCriticalEffects(
|
||||
passiveTags: ReadonlySet<string>,
|
||||
): readonly AbilityEffect[] {
|
||||
if (!passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction)
|
||||
|| ![491150, 491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return [];
|
||||
|| ![491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return [];
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
|
||||
@@ -350,26 +350,26 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
});
|
||||
|
||||
it("keeps Elemental Extraction's defense debuff on a critical-hit target", () => {
|
||||
for (const id of ["rom-mage-primary-1", "rom-mage-primary-54"]) {
|
||||
const attack = abilityById(id)!;
|
||||
expect(romMageWarlockCriticalEffects(attack, new Set())).toEqual([]);
|
||||
expect(romMageWarlockCriticalEffects(attack,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
disposition: "debuff", durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "armor", value: -0.3 }),
|
||||
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
|
||||
]),
|
||||
}),
|
||||
const passive = new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]);
|
||||
expect(romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!, passive)).toEqual([]);
|
||||
|
||||
const electricExplosion = abilityById("rom-mage-primary-54")!;
|
||||
expect(romMageWarlockCriticalEffects(electricExplosion, new Set())).toEqual([]);
|
||||
expect(romMageWarlockCriticalEffects(electricExplosion, passive)).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
disposition: "debuff", durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "armor", value: -0.3 }),
|
||||
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("triggers Elemental Extraction's target debuff on a live critical Flame hit", () => {
|
||||
it("triggers Elemental Extraction's target debuff on a live critical Electric Explosion hit", () => {
|
||||
initialize(79);
|
||||
registerTarget("critical-extraction-dummy");
|
||||
useCombatStore.setState((state) => ({
|
||||
@@ -386,7 +386,7 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-54", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "critical-extraction-dummy"
|
||||
@@ -448,19 +448,19 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
expect((reduced.threat - 1) / (baseline.threat - 1)).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("applies Elemental Extraction's defense reduction to the target's live magic mitigation", () => {
|
||||
it("applies Elemental Extraction's defense reduction to Electric Explosion's live magic mitigation", () => {
|
||||
const targetId = "extraction-defense-dummy";
|
||||
const castFlame = (useDebuff: boolean) => {
|
||||
const castElectricExplosion = (useDebuff: boolean) => {
|
||||
initialize(60);
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: targetId,
|
||||
maxHealth: 100_000,
|
||||
resistances: { fire: 1_000 },
|
||||
resistances: { nature: 1_400 },
|
||||
});
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
if (useDebuff) {
|
||||
const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!,
|
||||
const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-54")!,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))[0];
|
||||
expect(rider.kind).toBe("apply-aura");
|
||||
if (rider.kind !== "apply-aura") throw new Error("Elemental Extraction debuff missing");
|
||||
@@ -473,14 +473,14 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
}).auras,
|
||||
}));
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-54", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
return useCombatStore.getState().mobs[targetId]!.maxHealth
|
||||
- useCombatStore.getState().mobs[targetId]!.health;
|
||||
};
|
||||
const baselineDamage = castFlame(false);
|
||||
const reducedDefenseDamage = castFlame(true);
|
||||
const baselineDamage = castElectricExplosion(false);
|
||||
const reducedDefenseDamage = castElectricExplosion(true);
|
||||
expect(baselineDamage).toBeGreaterThan(0);
|
||||
expect(reducedDefenseDamage).toBeGreaterThan(baselineDamage);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
partyCastCanMoveDuringMeditationPath,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG,
|
||||
ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG,
|
||||
} from "./romMageWarriorRuntime";
|
||||
|
||||
const TARGET_ID = "meditation-path-target";
|
||||
|
||||
function initializeMageWarrior(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, mana: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: 1_000 },
|
||||
}));
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function registerTarget(): void {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function auraWithTag(tag: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
describe("RoM Mage/Warrior Meditation Path", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
initializeMageWarrior();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-mage-general-0", 490204, "Fireball"],
|
||||
["rom-mage-general-1", 490212, "Lightning"],
|
||||
["rom-mage-primary-4", 490242, "Discharge"],
|
||||
["rom-mage-primary-6", 490239, "Phoenix"],
|
||||
] as const)("grants 7 seconds of mobile casting after %s (%i, %s)", (abilityId, spellId, _name) => {
|
||||
registerTarget();
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.abilities.some((ability) => ability.dbcSpellId === 499588 && ability.passive)).toBe(true);
|
||||
expect(abilityById(abilityId)?.dbcSpellId).toBe(spellId);
|
||||
|
||||
expect(state.castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)?.expiresAt).toBe(8_000);
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 1_000)?.expiresAt).toBe(16_000);
|
||||
});
|
||||
|
||||
it("does not proc on Flame and does not retrigger during the 15-second lockout", () => {
|
||||
registerTarget();
|
||||
const flame = abilityById("rom-mage-primary-1");
|
||||
const fireball = abilityById("rom-mage-general-0");
|
||||
if (!flame || !fireball) throw new Error("Missing local Mage ability rows");
|
||||
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 4_100)).toBeUndefined();
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 4_100)).toBeUndefined();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 6_000)).toMatchObject({ ok: true });
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 6_000)?.expiresAt).toBe(13_000);
|
||||
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 14_000)).toMatchObject({ ok: true });
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 14_000)).toBeUndefined();
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 14_000)?.expiresAt).toBe(21_000);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 21_000)).toMatchObject({ ok: true });
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 21_000)?.expiresAt).toBe(28_000);
|
||||
});
|
||||
|
||||
it("lets a cast started during Meditation Path continue after movement", () => {
|
||||
registerTarget();
|
||||
const fireball = abilityById("rom-mage-general-0");
|
||||
const flame = abilityById("rom-mage-primary-1");
|
||||
if (!fireball || !flame) throw new Error("Missing local Mage ability rows");
|
||||
|
||||
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 2_600)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true });
|
||||
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true });
|
||||
useCombatStore.getState().tick(0.1, 5_700);
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "cast"
|
||||
&& event.abilityId === flame.id && event.outcome === "completed")).toBe(true);
|
||||
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 5_700)?.expiresAt).toBe(8_000);
|
||||
});
|
||||
|
||||
it("grants the companion aura after a supported cast and preserves movement casting", () => {
|
||||
const seed = "meditation-path-party";
|
||||
const partyMember = createConfiguredPartyMember({
|
||||
name: "Meditation Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const fireball = abilityById("rom-mage-general-0");
|
||||
const flame = abilityById("rom-mage-primary-1");
|
||||
if (!fireball || !flame) throw new Error("Missing local Mage ability rows");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
usePartyStore.setState({ members: [{
|
||||
...partyMember,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...partyMember.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: fireball.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: 500,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 500,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(partyMember.id, [0, 0, 0]);
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 900);
|
||||
updatePartyRuntimeTargetVisibility(partyMember.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const partyAuras = useCombatStore.getState().auras;
|
||||
expect(partyAuras.find((aura) => aura.targetId === partyMember.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG))?.expiresAt).toBe(8_000);
|
||||
expect(partyAuras.find((aura) => aura.targetId === partyMember.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG))?.expiresAt).toBe(16_000);
|
||||
|
||||
const canMoveDuringCast = partyCastCanMoveDuringMeditationPath(partyMember, flame, 7_000);
|
||||
expect(canMoveDuringCast).toBe(true);
|
||||
usePartyStore.setState({ members: usePartyStore.getState().members.map((member) => member.id === partyMember.id
|
||||
? { ...member, activeCast: {
|
||||
abilityId: flame.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: 7_000,
|
||||
completesAt: 10_000,
|
||||
originalDurationMs: 3_000,
|
||||
pushbackHits: 0,
|
||||
canCastWhileMoving: canMoveDuringCast,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
}, activeCastTargetId: TARGET_ID, status: "attacking" }
|
||||
: member) });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(7_500);
|
||||
updatePartyRuntimePosition(partyMember.id, 1, 0, 0);
|
||||
advanceDungeonPartyCombat(10_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === partyMember.id)?.activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === partyMember.id
|
||||
&& event.abilityId === flame.id && event.outcome === "hit")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { ActiveAura, AuraDefinition } from "./combatAuras";
|
||||
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID = 499588;
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_TRIGGER_SPELL_IDS = new Set([490204, 490212, 490242, 490239]);
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG = "rom-mage-warrior-meditation-path-active";
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG = "rom-mage-warrior-meditation-path-lockout";
|
||||
|
||||
const MEDITATION_PATH_ACTIVE_AURA: AuraDefinition = {
|
||||
id: "rom:499588:mobile-casting",
|
||||
name: "Meditation Path",
|
||||
disposition: "buff",
|
||||
durationMs: 7_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: [ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG],
|
||||
};
|
||||
|
||||
const MEDITATION_PATH_LOCKOUT_AURA: AuraDefinition = {
|
||||
id: "rom:499588:retrigger-lockout",
|
||||
name: "Meditation Path lockout",
|
||||
disposition: "buff",
|
||||
durationMs: 15_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: [ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG],
|
||||
};
|
||||
|
||||
function auraEffect(aura: AuraDefinition): Extract<AbilityEffect, { kind: "apply-aura" }> {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura,
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT = auraEffect(MEDITATION_PATH_ACTIVE_AURA);
|
||||
export const ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT = auraEffect(MEDITATION_PATH_LOCKOUT_AURA);
|
||||
|
||||
export function romMageWarriorMeditationPathCanProc(
|
||||
classId: ClassId,
|
||||
secondaryClassId: ClassId | null | undefined,
|
||||
hasPassive: boolean,
|
||||
ability: AbilityDefinition,
|
||||
): boolean {
|
||||
return classId === "rom-mage"
|
||||
&& secondaryClassId === "rom-warrior"
|
||||
&& hasPassive
|
||||
&& ability.source === "runewaker"
|
||||
&& ROM_MAGE_WARRIOR_MEDITATION_PATH_TRIGGER_SPELL_IDS.has(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
export function romMageWarriorMeditationPathHasState(
|
||||
auras: readonly ActiveAura[],
|
||||
actorId: string,
|
||||
tag: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
return auras.some((aura) => aura.targetId === actorId
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
export function romMageWarriorMeditationPathAllowsMovement(
|
||||
classId: ClassId,
|
||||
secondaryClassId: ClassId | null | undefined,
|
||||
hasPassive: boolean,
|
||||
auras: readonly ActiveAura[],
|
||||
actorId: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
return classId === "rom-mage"
|
||||
&& secondaryClassId === "rom-warrior"
|
||||
&& hasPassive
|
||||
&& romMageWarriorMeditationPathHasState(
|
||||
auras,
|
||||
actorId,
|
||||
ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG,
|
||||
now,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
|
||||
const righteousExplosion = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499836)!;
|
||||
const castContext = {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 1_000,
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
|
||||
function finishCast(now: number) {
|
||||
const cast = useCombatStore.getState().activeCast;
|
||||
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
|
||||
}
|
||||
|
||||
describe("RoM Champion/Priest Righteous Explosion", () => {
|
||||
beforeEach(() => {
|
||||
useCombatStore.setState({ auras: [], activeCast: null, combatEvents: [] });
|
||||
});
|
||||
|
||||
it("cleanses every modeled harmful category from the whole party", () => {
|
||||
const effects = romEffectsForCast(righteousExplosion, castContext);
|
||||
expect(effects).toContainEqual(expect.objectContaining({
|
||||
kind: "dispel",
|
||||
mode: "cleanse",
|
||||
relationship: "friendly",
|
||||
categories: ["magic", "curse", "poison", "disease", "enrage"],
|
||||
maxCount: 99,
|
||||
scope: "party",
|
||||
}));
|
||||
});
|
||||
|
||||
it("removes more than one harmful effect from each party member", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-champion", secondaryClassId: "rom-priest", raceId: "rom-dwarf", level: 70,
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-champion-priest-righteous-explosion", 70, useCombatStore.getState().health, "tank", 2, "rom",
|
||||
);
|
||||
|
||||
const memberIds = usePartyStore.getState().members.map((member) => member.id);
|
||||
let auras = useCombatStore.getState().auras;
|
||||
for (const targetId of [PLAYER_AURA_ENTITY_ID, ...memberIds]) {
|
||||
for (const [id, category] of [["magic", "magic"], ["curse", "curse"], ["enrage", "enrage"]] as const) {
|
||||
auras = applyAura(auras, {
|
||||
definition: {
|
||||
id: `righteous-explosion-${id}`,
|
||||
name: `Test ${id}`,
|
||||
disposition: "debuff",
|
||||
dispelCategory: category,
|
||||
durationMs: 30_000,
|
||||
},
|
||||
sourceId: "test:enemy",
|
||||
targetId,
|
||||
now: 1_000,
|
||||
}).auras;
|
||||
}
|
||||
auras = applyAura(auras, {
|
||||
definition: {
|
||||
id: "righteous-explosion-buff",
|
||||
name: "Unrelated buff",
|
||||
disposition: "buff",
|
||||
durationMs: 30_000,
|
||||
},
|
||||
sourceId: "test:ally",
|
||||
targetId,
|
||||
now: 1_000,
|
||||
}).auras;
|
||||
}
|
||||
useCombatStore.setState({ auras });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(righteousExplosion.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
finishCast(1_000);
|
||||
|
||||
const after = useCombatStore.getState().auras;
|
||||
for (const targetId of [PLAYER_AURA_ENTITY_ID, ...memberIds]) {
|
||||
expect(after.filter((aura) => aura.targetId === targetId && aura.definition.id.startsWith("righteous-explosion-"))
|
||||
.map((aura) => aura.definition.id)).toEqual(["righteous-explosion-buff"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,27 @@
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_ID = "rom-priest-protection-rune-seal-reset";
|
||||
export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID = "rom-priest-protection-rune-seal-reset-heart-rune";
|
||||
export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID = "rom-priest-protection-rune-seal-regenerate";
|
||||
export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE = 0.08;
|
||||
|
||||
const ROM_PRIEST_REGENERATE_ABILITY_ID = "rom-priest-general-2";
|
||||
|
||||
const PROTECTION_RUNE_SEAL_REGENERATE_PROC_AURA = {
|
||||
id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID,
|
||||
name: "Protection Rune Seal: Regenerate",
|
||||
disposition: "buff" as const,
|
||||
// Keep the exact 20-second final HoT tick eligible before aura expiry runs.
|
||||
durationMs: 20_001,
|
||||
hideFromAuraStrip: true,
|
||||
procs: [{
|
||||
id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_ID,
|
||||
triggers: "periodic" as const,
|
||||
abilityIds: [ROM_PRIEST_REGENERATE_ABILITY_ID],
|
||||
chance: ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE,
|
||||
action: { kind: "custom" as const, id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID },
|
||||
}],
|
||||
};
|
||||
|
||||
/** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */
|
||||
export function romPriestChampionCooldownOverride(
|
||||
@@ -11,3 +34,34 @@ export function romPriestChampionCooldownOverride(
|
||||
|| !learnedPassiveSourceIds.includes(498610)) return null;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Righteous Explosion removes every modeled harmful effect from the party. */
|
||||
export function romPriestChampionCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
learnedPassiveSpellIds: readonly number[] = [],
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source === "runewaker"
|
||||
&& ability.classId === "rom-priest"
|
||||
&& ability.dbcSpellId === 490269
|
||||
&& learnedPassiveSpellIds.includes(498613)) {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
aura: PROTECTION_RUNE_SEAL_REGENERATE_PROC_AURA,
|
||||
recipient: "caster",
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 498613,
|
||||
}, ...effects];
|
||||
}
|
||||
|
||||
if (ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-champion"
|
||||
|| ability.secondaryClassId !== "rom-priest"
|
||||
|| ability.dbcSpellId !== 499836) return effects;
|
||||
|
||||
return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse"
|
||||
? { ...effect, maxCount: 99, scope: "party" }
|
||||
: effect);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID, ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE } from "./romPriestChampionOverrides";
|
||||
|
||||
describe("RoM Priest/Champion Protection Rune Seal", () => {
|
||||
beforeEach(() => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-human", level: 30, role: "healer",
|
||||
});
|
||||
});
|
||||
|
||||
it("resets Heart Rune Energy on an 8% Regenerate tick proc", () => {
|
||||
const regenerate = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490269)!;
|
||||
const heartRuneEnergy = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498612)!;
|
||||
const initial = useCombatStore.getState();
|
||||
useCombatStore.setState({ cooldowns: { ...initial.cooldowns, [heartRuneEnergy.id]: 60_000 } });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(regenerate.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const activeProcAura = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID
|
||||
));
|
||||
expect(activeProcAura?.definition.procs?.[0]).toMatchObject({
|
||||
chance: ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE,
|
||||
abilityIds: [regenerate.id],
|
||||
triggers: "periodic",
|
||||
});
|
||||
|
||||
// The deterministic 8% roll succeeds on Regenerate's first periodic tick.
|
||||
useCombatStore.getState().tick(0, 3_000);
|
||||
expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(0);
|
||||
|
||||
// Include the final 20-second HoT tick when the exact expiry time is processed.
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [heartRuneEnergy.id]: 60_000 },
|
||||
}));
|
||||
useCombatStore.getState().tick(0, 21_000);
|
||||
expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(0);
|
||||
});
|
||||
|
||||
it("does not activate the reset proc when Protection Rune Seal is not unlocked", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-human", level: 24, role: "healer",
|
||||
});
|
||||
const regenerate = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490269)!;
|
||||
expect(useCombatStore.getState().castAbility(regenerate.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID
|
||||
))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
|
||||
const grandDispel = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 492928)!;
|
||||
const purify = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 491588)!;
|
||||
const grandDispelTag = "rom-priest-mage-grand-dispel";
|
||||
const mechanics = {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 1_000,
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
|
||||
function cleanseCategories(effects: ReturnType<typeof romEffectsForCast>): readonly string[] | undefined {
|
||||
const dispel = effects.find((effect) => effect.kind === "dispel");
|
||||
return dispel?.kind === "dispel" ? dispel.categories : undefined;
|
||||
}
|
||||
|
||||
describe("RoM Mage/Priest Grand Dispel", () => {
|
||||
it("exposes its source passive marker and keeps Purify curse-only without it", () => {
|
||||
expect(grandDispel.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({ utilityTags: [grandDispelTag] }),
|
||||
}),
|
||||
]));
|
||||
expect(purify).toMatchObject({ classId: "rom-mage", secondaryClassId: "rom-priest" });
|
||||
expect(cleanseCategories(romEffectsForCast(purify, mechanics))).toEqual(["curse"]);
|
||||
});
|
||||
|
||||
it("adds only modeled harmful cleanse categories for the matching pair with Grand Dispel", () => {
|
||||
const withPassive = romEffectsForCast(purify, {
|
||||
...mechanics,
|
||||
passiveTags: [grandDispelTag],
|
||||
});
|
||||
expect(withPassive).toContainEqual(expect.objectContaining({
|
||||
kind: "dispel",
|
||||
mode: "cleanse",
|
||||
relationship: "friendly",
|
||||
categories: ["magic", "curse", "poison", "disease"],
|
||||
maxCount: 1,
|
||||
}));
|
||||
|
||||
const wrongPair = romEffectsForCast({ ...purify, secondaryClassId: "rom-knight" }, {
|
||||
...mechanics,
|
||||
passiveTags: [grandDispelTag],
|
||||
});
|
||||
expect(cleanseCategories(wrongPair)).toEqual(["curse"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,31 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
import { romPriestChampionCastEffects } from "./romPriestChampionOverrides";
|
||||
|
||||
export const ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG = "rom-priest-warrior-battle-monk-stance";
|
||||
|
||||
const BATTLE_MONK_STANCE_GATED_SKILLS = new Set([491362, 491363, 491364, 492635, 493038]);
|
||||
export const ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG = "rom-priest-knight-holy-power-heal-uses";
|
||||
|
||||
export function romPriestWarriorStanceRequired(
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "secondaryClassId" | "dbcSpellId">,
|
||||
): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& ability.classId === "rom-priest"
|
||||
&& ability.secondaryClassId === "rom-warrior"
|
||||
&& BATTLE_MONK_STANCE_GATED_SKILLS.has(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
/** Player and companion cast gates share the source-specific Priest/Warrior condition. */
|
||||
export function romPriestWarriorStanceUseAllowed(
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "secondaryClassId" | "dbcSpellId">,
|
||||
activeAuraTags: readonly string[] | ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!romPriestWarriorStanceRequired(ability)) return true;
|
||||
return Array.isArray(activeAuraTags)
|
||||
? activeAuraTags.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG)
|
||||
: (activeAuraTags as ReadonlySet<string>).has(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG);
|
||||
}
|
||||
|
||||
function passiveState(sourceId: number, name: string, tag: string): readonly AbilityEffect[] {
|
||||
return [{
|
||||
@@ -24,6 +50,33 @@ export function overrideRomPriestKnightEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
// These RuneWaker party buffs are self-targeted in their source rows, and
|
||||
// their tooltip wording does not match the generic party-scope detector.
|
||||
// Preserve the locally translated components and apply their auras to the
|
||||
// party named by the source descriptions.
|
||||
const partyBuff = (row.classId === "rom-priest" && row.secondaryClassId === undefined && row.sourceSkillId === 490298)
|
||||
|| (row.classId === "rom-priest" && row.secondaryClassId === "rom-scout" && row.sourceSkillId === 491323)
|
||||
|| (row.classId === "rom-priest" && row.secondaryClassId === "rom-rogue" && row.sourceSkillId === 492932);
|
||||
if (partyBuff) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0
|
||||
? { ...effect, scope: "party" as const }
|
||||
: effect);
|
||||
}
|
||||
|
||||
if (row.classId === "rom-priest" && row.secondaryClassId === "rom-warrior" && row.sourceSkillId === 491600) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
|
||||
? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
// All native Battle Monk stance components have a 900-second duration.
|
||||
durationMs: 900_000,
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG],
|
||||
},
|
||||
}
|
||||
: effect);
|
||||
}
|
||||
|
||||
switch (row.sourceSkillId) {
|
||||
case 492355: // Enhanced Grace of Life: Grace of Life also raises maximum MP.
|
||||
return passiveState(492355, row.name, "rom-enhanced-grace-of-life");
|
||||
@@ -35,6 +88,8 @@ export function overrideRomPriestKnightEffects(
|
||||
return passiveState(499957, row.name, "rom-light-guidance");
|
||||
case 492638: // Lightning Rod: Lightning shocks targets already afflicted by Bone Chill.
|
||||
return passiveState(492638, row.name, "rom-priest-mage-lightning-rod");
|
||||
case 492928: // Grand Dispel: Purify also removes harmful effects, beyond curses.
|
||||
return passiveState(492928, row.name, "rom-priest-mage-grand-dispel");
|
||||
case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage.
|
||||
return passiveState(491610, row.name, "rom-enhanced-holy-strike");
|
||||
case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage.
|
||||
@@ -157,6 +212,18 @@ export function romPriestKnightCastEffects(
|
||||
learnedPassiveSpellIds: readonly number[] = [],
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
|
||||
const priestChampionEffects = romPriestChampionCastEffects(ability, effects, learnedPassiveSpellIds);
|
||||
if (priestChampionEffects !== effects) return priestChampionEffects;
|
||||
if (ability.dbcSpellId === 491588
|
||||
&& ability.classId === "rom-mage"
|
||||
&& ability.secondaryClassId === "rom-priest"
|
||||
&& hasTag(passiveTags, "rom-priest-mage-grand-dispel")) {
|
||||
// Purify's base tooltip/source row allows curse removal. Grand Dispel
|
||||
// broadens the same one-effect friendly cleanse to harmful status types.
|
||||
return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse"
|
||||
? { ...effect, categories: ["magic", "curse", "poison", "disease"] }
|
||||
: effect);
|
||||
}
|
||||
if (ability.dbcSpellId === 490261
|
||||
&& learnedPassiveSpellIds.includes(493039)
|
||||
&& friendlyTargetHealthPercent < 0.3) {
|
||||
@@ -184,6 +251,19 @@ export function romPriestKnightCastEffects(
|
||||
}, ...effects];
|
||||
}
|
||||
switch (ability.dbcSpellId) {
|
||||
case 493044: // Holy Power expires after three successful casts of Heal.
|
||||
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"
|
||||
? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
maxStacks: 3,
|
||||
initialStacks: 3,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG],
|
||||
},
|
||||
}
|
||||
: effect);
|
||||
case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP.
|
||||
if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects;
|
||||
return [...effects, {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
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"],
|
||||
@@ -500,4 +502,132 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => {
|
||||
&& 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);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,46 @@ describe("RoM Knight/Priest conditional combat payloads", () => {
|
||||
&& event.abilityId === "rom-knight-rom-priest-elite-20")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("expires Priest/Knight Holy Power after the third successful Heal cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 45, role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"priest-knight-holy-power", 45, useCombatStore.getState().health, "healer", 2, "rom",
|
||||
);
|
||||
const ally = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.getState().damageMember(ally.id, Math.floor(ally.maxHealth * 0.5));
|
||||
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(ally.id);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-45", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const taggedHolyPowerAuras = () => useCombatStore.getState().auras.filter((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes("rom-priest-knight-holy-power-heal-uses")
|
||||
));
|
||||
expect(taggedHolyPowerAuras().length).toBeGreaterThan(0);
|
||||
expect(taggedHolyPowerAuras().every((aura) => aura.stacks === 3)).toBe(true);
|
||||
const holyPowerModifiers = taggedHolyPowerAuras().flatMap((aura) => aura.definition.modifiers ?? []);
|
||||
expect(holyPowerModifiers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "stat", stat: "wisdom", operation: "flat", value: 60 }),
|
||||
expect.objectContaining({ kind: "stat", stat: "wisdom", operation: "percent", value: 0.01 }),
|
||||
]));
|
||||
|
||||
let now = 2_600;
|
||||
for (const expectedUsesRemaining of [2, 1, 0]) {
|
||||
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, now)).toMatchObject({ ok: true });
|
||||
const cast = useCombatStore.getState().activeCast;
|
||||
if (cast) {
|
||||
useCombatStore.getState().tick(Math.max(0.01, (cast.completesAt - now) / 1_000), cast.completesAt);
|
||||
now = cast.completesAt + 100;
|
||||
} else {
|
||||
now += 1_600;
|
||||
}
|
||||
expect(taggedHolyPowerAuras().every((aura) => aura.stacks === expectedUsesRemaining)).toBe(true);
|
||||
if (expectedUsesRemaining === 0) expect(taggedHolyPowerAuras()).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("casts only the self or ally Holy Protection threat payload", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_PRIEST_MAGE_ENLIGHTENMENT } from "./romPriestMageRuntime";
|
||||
|
||||
const ACTIVE_TAG = "rom-priest-mage-enlightenment-active";
|
||||
|
||||
function initializePriestMage(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-mage",
|
||||
raceId: "rom-human",
|
||||
level: 45,
|
||||
role: "healer",
|
||||
});
|
||||
}
|
||||
|
||||
function aura(actorId: string, tag: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId
|
||||
&& (entry.expiresAt === null || entry.expiresAt > now)
|
||||
&& entry.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
describe("RoM Priest/Mage Enlightenment integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
initializePriestMage();
|
||||
usePartyStore.getState().initializeParty(
|
||||
"priest-mage-enlightenment-test", 45, useCombatStore.getState().health, "healer", 2, "rom",
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers only on completed Heal and respects the caster's 20-second cooldown", () => {
|
||||
const heal = abilityById("rom-priest-primary-2");
|
||||
const wrongSpell = abilityById("rom-priest-primary-4");
|
||||
const passive = abilityById("rom-priest-rom-mage-elite-40");
|
||||
if (!heal || !wrongSpell || !passive) throw new Error("Missing local Priest/Mage Enlightenment rows");
|
||||
expect([heal.dbcSpellId, wrongSpell.dbcSpellId, passive.dbcSpellId]).toEqual([490_261, 491_165, 492_933]);
|
||||
expect(passive.effects).toEqual([]);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 1_000)).toBeUndefined();
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 1_000)).toBeUndefined();
|
||||
|
||||
useCombatStore.getState().damagePlayer(100);
|
||||
expect(useCombatStore.getState().castAbility(heal.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 3_100)?.expiresAt).toBe(11_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 3_100)?.expiresAt)
|
||||
.toBe(23_100);
|
||||
|
||||
useCombatStore.getState().registerMob("enlightenment-wrong-spell-target", {
|
||||
name: "Enlightenment wrong-spell target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("enlightenment-wrong-spell-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("enlightenment-wrong-spell-target");
|
||||
expect(useCombatStore.getState().castAbility(wrongSpell.id, undefined, 5_000)).toMatchObject({ ok: true });
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 5_000)?.expiresAt).toBe(11_100);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(heal.id, undefined, 7_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 9_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 9_100)?.expiresAt).toBe(11_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 9_100)?.expiresAt)
|
||||
.toBe(23_100);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(heal.id, undefined, 24_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 26_100);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 26_100)?.expiresAt).toBe(34_100);
|
||||
});
|
||||
|
||||
it("triggers after a party Priest/Mage finishes Heal and keeps the aura on that caster", () => {
|
||||
const seed = "priest-mage-enlightenment-party";
|
||||
const healer = createConfiguredPartyMember({
|
||||
name: "Enlightenment Priest",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-mage",
|
||||
specialization: "Primary",
|
||||
role: "healer",
|
||||
}, 45, 0, seed);
|
||||
const heal = abilityById("rom-priest-primary-2");
|
||||
const passive = abilityById("rom-priest-rom-mage-elite-40");
|
||||
if (!heal || !passive) throw new Error("Missing local Priest/Mage Heal or Enlightenment rows");
|
||||
usePartyStore.getState().initializeParty(seed, 45, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
usePartyStore.setState({ members: [{
|
||||
...healer,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...healer.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: heal.id,
|
||||
school: "holy",
|
||||
mode: "cast",
|
||||
startedAt: 500,
|
||||
completesAt: 2_500,
|
||||
originalDurationMs: 2_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: healer.id,
|
||||
},
|
||||
activeCastTargetId: healer.id,
|
||||
status: "healing",
|
||||
}] });
|
||||
registerPartyRuntimePosition(healer.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
expect(passive.effects).toEqual([]);
|
||||
expect(aura(healer.id, ACTIVE_TAG, 1_000)).toBeUndefined();
|
||||
|
||||
advanceDungeonPartyCombat(2_500);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === healer.id
|
||||
&& event.abilityId === heal.id && event.kind === "healing")).toBe(true);
|
||||
expect(aura(healer.id, ACTIVE_TAG, 2_500)?.expiresAt).toBe(10_500);
|
||||
expect(aura(healer.id, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 2_500)?.expiresAt).toBe(22_500);
|
||||
expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 2_500)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_PRIEST_MAGE_ENLIGHTENMENT,
|
||||
ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT,
|
||||
romPriestMageEnlightenmentProc,
|
||||
romPriestMageEnlightenmentReadyAt,
|
||||
} from "./romPriestMageRuntime";
|
||||
|
||||
const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490_261)!;
|
||||
const learnedPassive = [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId];
|
||||
|
||||
describe("RoM Priest/Mage Enlightenment runtime", () => {
|
||||
it("grants the source-defined Wisdom aura after completing Heal", () => {
|
||||
expect(ROM_PRIEST_MAGE_ENLIGHTENMENT).toMatchObject({
|
||||
passiveSpellId: 492_933,
|
||||
triggerSpellId: 490_261,
|
||||
wisdomComponentId: 503_197,
|
||||
cooldownComponentId: 506_532,
|
||||
wisdomPercent: 1,
|
||||
durationMs: 8_000,
|
||||
cooldownMs: 20_000,
|
||||
});
|
||||
const result = romPriestMageEnlightenmentProc(
|
||||
"rom-priest",
|
||||
"rom-mage",
|
||||
heal,
|
||||
learnedPassive,
|
||||
true,
|
||||
2_000,
|
||||
2_000,
|
||||
);
|
||||
|
||||
expect(result.nextReadyAt).toBe(22_000);
|
||||
expect(result.effect).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
triggerSpellId: 492_933,
|
||||
aura: {
|
||||
id: "rom-priest-mage-enlightenment",
|
||||
durationMs: 8_000,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "wisdom",
|
||||
operation: "percent",
|
||||
value: 0.01,
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT).toMatchObject({
|
||||
recipient: "caster",
|
||||
triggerSpellId: 492_933,
|
||||
aura: {
|
||||
durationMs: 20_000,
|
||||
hideFromAuraStrip: true,
|
||||
utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the per-caster cooldown independent and ready at expiration", () => {
|
||||
const activeAuras = [
|
||||
{
|
||||
targetId: "priest-a",
|
||||
expiresAt: 12_000,
|
||||
definition: {
|
||||
id: "enlightenment-cooldown-test",
|
||||
name: "Enlightenment cooldown test",
|
||||
disposition: "buff" as const,
|
||||
durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs,
|
||||
utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-a", 5_000)).toBe(12_000);
|
||||
expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-b", 5_000)).toBe(5_000);
|
||||
expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-a", 12_000)).toBe(12_000);
|
||||
});
|
||||
|
||||
it("rejects other builds, spells, sources, unlearned passives, incomplete casts, and cooldown casts", () => {
|
||||
const rejected = [
|
||||
romPriestMageEnlightenmentProc("rom-mage", "rom-priest", heal, learnedPassive, true, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", undefined, heal, learnedPassive, true, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", "rom-mage", { ...heal, dbcSpellId: 490_204 }, learnedPassive, true, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", "rom-mage", { ...heal, source: "core" }, learnedPassive, true, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, [], true, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, learnedPassive, false, 0, 1),
|
||||
romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, learnedPassive, true, 5_000, 4_999),
|
||||
];
|
||||
for (const result of rejected) {
|
||||
expect(result.effect).toBeNull();
|
||||
expect(result.nextReadyAt).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses the generated passive aura while keeping Heal available", () => {
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-mage-elite-40");
|
||||
expect(passive?.effects).toEqual([]);
|
||||
expect(passive?.ranks?.[0]?.effects).toEqual([]);
|
||||
expect(heal).toMatchObject({ classId: "rom-priest", dbcSpellId: 490_261, source: "runewaker" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import type { ActiveAura } from "./combatAuras";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
export const ROM_PRIEST_MAGE_ENLIGHTENMENT = Object.freeze({
|
||||
passiveSpellId: 492_933,
|
||||
triggerSpellId: 490_261,
|
||||
wisdomComponentId: 503_197,
|
||||
cooldownComponentId: 506_532,
|
||||
wisdomPercent: 1,
|
||||
durationMs: 8_000,
|
||||
cooldownMs: 20_000,
|
||||
cooldownAuraTag: "rom-priest-mage-enlightenment-cooldown",
|
||||
});
|
||||
|
||||
export const ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag,
|
||||
name: "Enlightenment cooldown",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs,
|
||||
hideFromAuraStrip: true,
|
||||
utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId,
|
||||
};
|
||||
|
||||
/** Enlightenment is triggered by the Priest/Mage Heal cast, not a permanent passive aura. */
|
||||
export function overrideRomPriestMageEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
_translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
return row.classId === "rom-priest"
|
||||
&& row.secondaryClassId === "rom-mage"
|
||||
&& row.sourceSkillId === ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId
|
||||
? []
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Find the active internal cooldown marker for one caster. */
|
||||
export function romPriestMageEnlightenmentReadyAt(
|
||||
activeAuras: readonly Pick<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
casterId: string,
|
||||
now: number,
|
||||
): number {
|
||||
const cooldown = activeAuras.find((aura) => aura.targetId === casterId
|
||||
&& aura.definition.utilityTags?.includes(ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now));
|
||||
return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now;
|
||||
}
|
||||
|
||||
export interface RomPriestMageEnlightenmentResult {
|
||||
readonly effect: AbilityEffect | null;
|
||||
readonly nextReadyAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RuneWaker Enlightenment (492933): completing Heal (490261) while the
|
||||
* Priest/Mage passive is learned grants +1% Wisdom for 8 seconds, with a
|
||||
* 20-second internal cooldown. The source says "when you use Heal," so a
|
||||
* completed cast is the trigger; hit or target health is not required.
|
||||
*/
|
||||
export function romPriestMageEnlightenmentProc(
|
||||
classId: string | null,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "classId" | "secondaryClassId" | "source" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
castCompleted: boolean,
|
||||
readyAt: number,
|
||||
now: number,
|
||||
): RomPriestMageEnlightenmentResult {
|
||||
const eligible = classId === "rom-priest"
|
||||
&& secondaryClassId === "rom-mage"
|
||||
&& ability.classId === "rom-priest"
|
||||
&& ability.source === "runewaker"
|
||||
&& ability.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.triggerSpellId
|
||||
&& learnedPassiveSpellIds.includes(ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId)
|
||||
&& castCompleted
|
||||
&& now >= readyAt;
|
||||
if (!eligible) return { effect: null, nextReadyAt: readyAt };
|
||||
|
||||
return {
|
||||
effect: {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-priest-mage-enlightenment",
|
||||
name: "Enlightenment",
|
||||
description: "Completing Heal grants +1% Wisdom for 8 seconds. This effect has a 20-second internal cooldown.",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: ["rom-priest-mage-enlightenment-active"],
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "wisdom",
|
||||
operation: "percent",
|
||||
value: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent / 100,
|
||||
}],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 49,
|
||||
miscValue: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent,
|
||||
triggerSpellId: ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId,
|
||||
},
|
||||
nextReadyAt: now + ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog";
|
||||
|
||||
function ability(
|
||||
primary: "rom-priest",
|
||||
secondary: "rom-scout" | "rom-rogue" | null,
|
||||
sourceId: number,
|
||||
): AbilityDefinition {
|
||||
const match = abilitiesForCharacter(primary, secondary).find((candidate) => candidate.dbcSpellId === sourceId);
|
||||
if (!match) throw new Error(`Missing Priest ability ${sourceId}`);
|
||||
return match;
|
||||
}
|
||||
|
||||
describe("RoM Priest party buff scopes", () => {
|
||||
it.each([
|
||||
[null, 490298, "Blessed Spring Water"],
|
||||
["rom-scout", 491323, "Embrace of the Water Spirit"],
|
||||
["rom-rogue", 492932, "Shadow Fury"],
|
||||
] as const)("applies %s to its source-described party", (secondary, sourceId, name) => {
|
||||
const buff = ability("rom-priest", secondary, sourceId);
|
||||
expect(buff.name).toBe(name);
|
||||
|
||||
const auras = buff.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
const buffs = auras.filter((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0);
|
||||
expect(buffs.length).toBeGreaterThan(0);
|
||||
expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
|
||||
expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.recipient === "caster")).toBe(true);
|
||||
expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.aura.durationMs === 900_000)).toBe(true);
|
||||
// The Scout pairing also has an independent periodic trigger state; it remains caster-only.
|
||||
expect(auras.filter((effect) => effect.kind === "apply-aura" && effect.aura.procs?.length).every(
|
||||
(effect) => effect.kind === "apply-aura" && effect.scope !== "party",
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
|
||||
491366: "Increases your healing and reduces mana costs while lowering magical attack for 15 minutes.",
|
||||
492639: "Protects a friendly target from Fear for 10 seconds.",
|
||||
492934: "Sacrifices some of your health to heal a friendly party member.",
|
||||
493044: "Increases your Wisdom for 20 seconds or until you cast Heal three times.",
|
||||
493044: "Increases your Wisdom by (current Wisdom + 60) × 1% + 60 for 20 seconds or until you cast Heal three times.",
|
||||
499968: "Passive: Splendor Ripple makes your Devotion Halo heal its target for additional health.",
|
||||
499957: "Passive: while Devotion Halo is active, increases your healing done.",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
const cleanse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491175
|
||||
&& ability.classId === "rom-priest")!;
|
||||
const soulCleansing = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 498771)!;
|
||||
const soulCleansingTag = "rom-priest-warlock-soul-cleansing-ceremony";
|
||||
const mechanics = {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 1_000,
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
|
||||
describe("RoM Priest/Warlock Soul Cleansing Ceremony", () => {
|
||||
beforeEach(() => {
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("keeps Cleanse curse-free without the passive and adds curse removal for Priest/Warlock", () => {
|
||||
expect(soulCleansing.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({ utilityTags: [soulCleansingTag] }),
|
||||
}),
|
||||
]));
|
||||
expect(cleanse.effects).toContainEqual(expect.objectContaining({
|
||||
kind: "dispel",
|
||||
mode: "cleanse",
|
||||
categories: ["magic", "poison", "disease"],
|
||||
maxCount: 1,
|
||||
}));
|
||||
|
||||
const withPassive = romEffectsForCast(cleanse, {
|
||||
...mechanics,
|
||||
passiveTags: [soulCleansingTag],
|
||||
});
|
||||
expect(withPassive).toContainEqual(expect.objectContaining({
|
||||
kind: "dispel",
|
||||
mode: "cleanse",
|
||||
relationship: "friendly",
|
||||
categories: ["magic", "poison", "disease", "curse"],
|
||||
maxCount: 1,
|
||||
}));
|
||||
|
||||
const wrongClass = romEffectsForCast({ ...cleanse, classId: "rom-mage" }, {
|
||||
...mechanics,
|
||||
passiveTags: [soulCleansingTag],
|
||||
});
|
||||
expect(wrongClass).toContainEqual(expect.objectContaining({
|
||||
kind: "dispel",
|
||||
categories: ["magic", "poison", "disease"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("removes a cursed status from the selected ally when the passive is active", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-warlock",
|
||||
raceId: "rom-human",
|
||||
level: 35,
|
||||
});
|
||||
expect(useCombatStore.getState().abilities).toContainEqual(expect.objectContaining({
|
||||
dbcSpellId: 498771,
|
||||
passive: true,
|
||||
unlockLevel: 30,
|
||||
}));
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-priest-warlock-soul-cleansing",
|
||||
35,
|
||||
useCombatStore.getState().health,
|
||||
"healer",
|
||||
2,
|
||||
"rom",
|
||||
);
|
||||
const ally = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
|
||||
usePartyStore.getState().selectMember(ally.id);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes(soulCleansingTag)
|
||||
))).toBe(true);
|
||||
|
||||
const curse = {
|
||||
id: "test:rom-priest-warlock-curse",
|
||||
name: "Test Curse",
|
||||
disposition: "debuff" as const,
|
||||
dispelCategory: "curse" as const,
|
||||
durationMs: 60_000,
|
||||
};
|
||||
const applied = applyAura(useCombatStore.getState().auras, {
|
||||
definition: curse,
|
||||
sourceId: "test:enemy",
|
||||
targetId: ally.id,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: [...applied.auras] });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(cleanse.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === curse.id
|
||||
&& aura.targetId === ally.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,61 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
const SOUL_CLEANSING_TAG = "rom-priest-warlock-soul-cleansing-ceremony";
|
||||
|
||||
/** Source-specific Priest/Warlock passive and cleanse mechanics. */
|
||||
export function overrideRomPriestWarlockEffects(
|
||||
row: { readonly sourceSkillId: number; readonly name: string; readonly classId?: string; readonly secondaryClassId?: string },
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.sourceSkillId === 498771
|
||||
&& row.classId === "rom-priest"
|
||||
&& row.secondaryClassId === "rom-warlock") {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-priest-warlock-soul-cleansing-ceremony",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [SOUL_CLEANSING_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 498771,
|
||||
}];
|
||||
}
|
||||
|
||||
if (row.sourceSkillId === 491175 && row.classId === "rom-priest") {
|
||||
// Soul Cleansing Ceremony explicitly adds cursed states to Cleanse. Keep
|
||||
// the base cleanse on the other modeled harmful categories so the
|
||||
// passive has a real, pair-specific effect.
|
||||
return translated.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse"
|
||||
? { ...effect, categories: ["magic", "poison", "disease"] }
|
||||
: effect);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Select local Prayer of Tribulation buff stacks from its tooltip HP gates. */
|
||||
export function romPriestWarlockCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
friendlyTargetHealthPercent: number,
|
||||
passiveTags: readonly string[] | ReadonlySet<string> = [],
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-priest"
|
||||
|| ability.secondaryClassId !== "rom-warlock"
|
||||
|| ability.dbcSpellId !== 498770) return effects;
|
||||
if (ability.source !== "runewaker" || ability.classId !== "rom-priest") return effects;
|
||||
const hasSoulCleansing = Array.isArray(passiveTags)
|
||||
? passiveTags.includes(SOUL_CLEANSING_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).has(SOUL_CLEANSING_TAG);
|
||||
if (ability.dbcSpellId === 491175 && hasSoulCleansing) {
|
||||
return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse"
|
||||
? { ...effect, categories: [...(effect.categories ?? []), "curse"] }
|
||||
: effect);
|
||||
}
|
||||
if (ability.secondaryClassId !== "rom-warlock" || ability.dbcSpellId !== 498770) return effects;
|
||||
|
||||
const buff = effects.find((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
|
||||
effect.kind === "apply-aura"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
overrideRomPriestKnightEffects,
|
||||
ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG,
|
||||
romPriestWarriorStanceUseAllowed,
|
||||
} from "./romPriestKnightOverrides";
|
||||
|
||||
function ability(sourceId: number): AbilityDefinition {
|
||||
const found = ROM_ALL_ABILITIES.find((row) => row.dbcSpellId === sourceId
|
||||
&& row.classId === "rom-priest" && row.secondaryClassId === "rom-warrior");
|
||||
if (!found) throw new Error(`Missing Priest/Warrior source skill ${sourceId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("RoM Priest/Warrior Battle Monk stance requirements", () => {
|
||||
it("tags the generated stance aura so cast gates can detect it", () => {
|
||||
const stance = ability(491600);
|
||||
const tagged = overrideRomPriestKnightEffects({
|
||||
classId: stance.classId,
|
||||
secondaryClassId: stance.secondaryClassId,
|
||||
sourceSkillId: stance.dbcSpellId,
|
||||
name: stance.name,
|
||||
description: stance.description,
|
||||
}, stance.effects);
|
||||
|
||||
expect(tagged).not.toBeNull();
|
||||
expect(tagged!.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.recipient === "caster"
|
||||
&& effect.aura.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([491362, 491363, 491364, 492635, 493038])(
|
||||
"requires Battle Monk Stance for generated skill %i",
|
||||
(sourceId) => {
|
||||
const skill = ability(sourceId);
|
||||
expect(romPriestWarriorStanceUseAllowed(skill, [])).toBe(false);
|
||||
expect(romPriestWarriorStanceUseAllowed(skill, [ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG])).toBe(true);
|
||||
expect(romPriestWarriorStanceUseAllowed(skill, new Set([ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG]))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not apply the source-specific gate to other abilities", () => {
|
||||
const skill = ability(491362);
|
||||
expect(romPriestWarriorStanceUseAllowed({ ...skill, dbcSpellId: 490290 }, [])).toBe(true);
|
||||
expect(romPriestWarriorStanceUseAllowed({ ...skill, secondaryClassId: "rom-knight" }, [])).toBe(true);
|
||||
expect(romPriestWarriorStanceUseAllowed({ ...skill, source: "core" }, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter } from "./abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { partyAbilityIsReady } from "./partyAbilityAi";
|
||||
import {
|
||||
partyAbilityTargetIsValid,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG,
|
||||
} from "./romPriestKnightOverrides";
|
||||
|
||||
const TARGET_ID = "rom-priest-warrior-stance-target";
|
||||
const STANCE_SPELL_ID = 491600;
|
||||
const STANCE_GATED_SPELL_IDS = [491362, 491363, 491364, 492635, 493038] as const;
|
||||
|
||||
function sourceAbility(spellId: number) {
|
||||
const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId
|
||||
&& candidate.classId === "rom-priest"
|
||||
&& candidate.secondaryClassId === "rom-warrior");
|
||||
if (!found) throw new Error(`Missing Priest/Warrior ability ${spellId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function preparePlayer(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000 },
|
||||
}));
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function registerTarget(): void {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 500);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function activeStanceFor(actorId: string, now: number): boolean {
|
||||
const stance = sourceAbility(STANCE_SPELL_ID);
|
||||
const effect = stance.effects.find((candidate) => candidate.kind === "apply-aura"
|
||||
&& candidate.recipient === "caster");
|
||||
if (!effect || effect.kind !== "apply-aura") throw new Error("Battle Monk Stance has no caster aura");
|
||||
return useCombatStore.getState().applyActorAura(actorId, actorId, stance, effect, now);
|
||||
}
|
||||
|
||||
function companionFor(abilityId: string) {
|
||||
const seed = `rom-priest-warrior-stance:${abilityId}`;
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Battle Monk",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const pairAbilities = abilitiesForCharacter("rom-priest", "rom-warrior");
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
const configuredMember = {
|
||||
...member,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...member.resourcePools, mana: 10_000, rage: 10_000 },
|
||||
cooldowns: Object.fromEntries(pairAbilities.map((ability) => [ability.id, ability.id === abilityId ? 0 : 60_000])),
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "attacking" as const,
|
||||
};
|
||||
usePartyStore.setState({
|
||||
members: [configuredMember],
|
||||
command: "attack",
|
||||
activeMobId: TARGET_ID,
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
return configuredMember;
|
||||
}
|
||||
|
||||
describe("RoM Priest/Warrior Battle Monk stance runtime gate", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
usePartyStore.setState({ members: [], activeMobId: null, objectiveBossId: null, memberTargetIds: {} });
|
||||
preparePlayer();
|
||||
});
|
||||
|
||||
it.each(STANCE_GATED_SPELL_IDS)("rejects player cast %i without Battle Monk Stance", (spellId) => {
|
||||
registerTarget();
|
||||
const ability = sourceAbility(spellId);
|
||||
const result = useCombatStore.getState().castAbility(ability.id, {
|
||||
...(ability.target === "hostile" ? { targetActorId: TARGET_ID } : {}),
|
||||
now: 1_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
});
|
||||
|
||||
it.each(STANCE_GATED_SPELL_IDS)("accepts player cast %i while Battle Monk Stance is active", (spellId) => {
|
||||
registerTarget();
|
||||
const stance = sourceAbility(STANCE_SPELL_ID);
|
||||
const ability = sourceAbility(spellId);
|
||||
expect(useCombatStore.getState().castAbility(stance.id, { now: 1_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG))).toBe(true);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(ability.id, {
|
||||
...(ability.target === "hostile" ? { targetActorId: TARGET_ID } : {}),
|
||||
now: 2_600,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a player cast after the timed stance aura expires", () => {
|
||||
registerTarget();
|
||||
const stance = sourceAbility(STANCE_SPELL_ID);
|
||||
const ability = sourceAbility(491362);
|
||||
expect(useCombatStore.getState().castAbility(stance.id, { now: 1_000 }).ok).toBe(true);
|
||||
const stanceAura = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG));
|
||||
expect(stanceAura?.expiresAt).not.toBeNull();
|
||||
const afterExpiry = (stanceAura?.expiresAt ?? 1_000) + 1;
|
||||
|
||||
expect(useCombatStore.getState().castAbility(ability.id, {
|
||||
targetActorId: TARGET_ID,
|
||||
now: afterExpiry,
|
||||
})).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
});
|
||||
|
||||
it.each(STANCE_GATED_SPELL_IDS)("gates companion eligibility for %i by the active timed stance", (spellId) => {
|
||||
registerTarget();
|
||||
const ability = sourceAbility(spellId);
|
||||
const member = companionFor(ability.id);
|
||||
const targetId = ability.target === "hostile" ? TARGET_ID : member.id;
|
||||
|
||||
expect(partyAbilityIsReady(member, ability, 1_000)).toBe(false);
|
||||
expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, 1_000)).toBe(false);
|
||||
expect(activeStanceFor(member.id, 500)).toBe(true);
|
||||
expect(partyAbilityIsReady(member, ability, 1_000)).toBe(true);
|
||||
expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, 1_000)).toBe(true);
|
||||
|
||||
const stanceAura = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG));
|
||||
expect(stanceAura?.expiresAt).not.toBeNull();
|
||||
expect(partyAbilityIsReady(member, ability, (stanceAura?.expiresAt ?? 1_000) + 1)).toBe(false);
|
||||
expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, (stanceAura?.expiresAt ?? 1_000) + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not apply the runtime stance gate to Priest/Knight", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-knight",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
registerTarget();
|
||||
const warriorAbility = sourceAbility(491362);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(warriorAbility.id, {
|
||||
targetActorId: TARGET_ID,
|
||||
now: 1_000,
|
||||
})).toMatchObject({ ok: false, reason: "wrong-class" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID,
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
|
||||
} from "./romRogueChampionNerveGearRuntime";
|
||||
|
||||
const ELITE_ABILITY_ID = "rom-rogue-rom-champion-elite-30";
|
||||
|
||||
function nerveGearAura(actorId: string) {
|
||||
return useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === actorId
|
||||
&& aura.targetId === actorId
|
||||
&& aura.definition.id === ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID
|
||||
));
|
||||
}
|
||||
|
||||
describe("RoM Rogue/Champion Nerve Gear cast integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50, xp: 0 });
|
||||
usePartyStore.getState().initializeParty("rom-rogue-champion-nerve-gear-tests", 50,
|
||||
useCombatStore.getState().health);
|
||||
});
|
||||
|
||||
it("grants one stack to a learned player Rogue/Champion after one elite cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-champion", level: 50, xp: 0,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, energy: 100, rage: 100 },
|
||||
maxResourcePools: { ...state.maxResourcePools, energy: 100, rage: 100 },
|
||||
}));
|
||||
const ability = abilityById(ELITE_ABILITY_ID)!;
|
||||
const marker = abilityById("rom-rogue-rom-champion-elite-50")!.effects.find((effect) => (
|
||||
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes(ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG)
|
||||
));
|
||||
expect(marker).toBeDefined();
|
||||
expect(nerveGearAura(PLAYER_AGGRO_ID)).toBeUndefined();
|
||||
|
||||
const result = useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(nerveGearAura(PLAYER_AGGRO_ID)).toMatchObject({
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 6_000,
|
||||
stacks: 1,
|
||||
definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it("grants one stack to the party Rogue/Champion at cast scope for an area skill", () => {
|
||||
const seed = "rom-rogue-champion-nerve-gear-party";
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Rogue companion",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-champion",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 50, 0, seed);
|
||||
const selectedAbility = abilityById(ELITE_ABILITY_ID)!;
|
||||
const lockedCooldowns = Object.fromEntries(abilitiesForCharacter("rom-rogue", "rom-champion")
|
||||
.filter((ability) => ability.id !== selectedAbility.id)
|
||||
.map((ability) => [ability.id, 50_000]));
|
||||
usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
nextActionAt: 100,
|
||||
status: "attacking",
|
||||
resource: 100,
|
||||
resourcePools: { ...member.resourcePools, energy: 100, rage: 100 },
|
||||
cooldowns: lockedCooldowns,
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const primaryTargetId = "nerve-gear-party-target-a";
|
||||
const secondaryTargetId = "nerve-gear-party-target-b";
|
||||
for (const targetId of [primaryTargetId, secondaryTargetId]) {
|
||||
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 10_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
}
|
||||
useCombatStore.getState().selectMob(primaryTargetId);
|
||||
usePartyStore.setState((party) => ({
|
||||
command: "attack",
|
||||
activeMobId: primaryTargetId,
|
||||
members: party.members.map((entry) => (
|
||||
entry.id === member.id ? { ...entry, globalCooldownEndsAt: 0, nextActionAt: 0 } : entry
|
||||
)),
|
||||
}));
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(nerveGearAura(member.id)).toMatchObject({
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 6_000,
|
||||
stacks: 1,
|
||||
definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { applyAura as applyCombatAura, type ActiveAura } from "./combatAuras";
|
||||
import {
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID,
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS,
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS,
|
||||
romRogueChampionNerveGearEffect,
|
||||
romRogueChampionNerveGearShouldTrigger,
|
||||
} from "./romRogueChampionNerveGearRuntime";
|
||||
|
||||
describe("RoM Rogue/Champion Nerve Gear", () => {
|
||||
const passive = abilityById("rom-rogue-rom-champion-elite-50")!;
|
||||
const elite = abilityById("rom-rogue-rom-champion-elite-25")!;
|
||||
const primaryAbility = abilityById("rom-rogue-primary-1")!;
|
||||
|
||||
it("requires the learned Rogue/Champion passive and a non-passive RuneWaker elite skill", () => {
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", elite, true)).toBe(true);
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", elite, false)).toBe(false);
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-warrior", elite, true)).toBe(false);
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", primaryAbility, true)).toBe(false);
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", passive, true)).toBe(false);
|
||||
expect(romRogueChampionNerveGearShouldTrigger("rom-champion", "rom-rogue", elite, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the learned passive as a marker instead of an unconditional stat aura", () => {
|
||||
const marker = passive.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(marker?.kind).toBe("apply-aura");
|
||||
if (marker?.kind !== "apply-aura") throw new Error("Missing Nerve Gear passive marker.");
|
||||
expect(marker.aura.utilityTags).toContain(ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG);
|
||||
expect(marker.aura.durationMs).toBeNull();
|
||||
expect(marker.aura.modifiers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the local 5-second, five-stack attack-speed and dodge payload", () => {
|
||||
const effect = romRogueChampionNerveGearEffect();
|
||||
expect(effect).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID,
|
||||
durationMs: ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS,
|
||||
maxStacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS,
|
||||
stackBehavior: "add",
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "attack-speed", operation: "flat", value: 5 },
|
||||
{ kind: "stat", stat: "dodge", operation: "flat", value: 2 },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds up to five stacks and refreshes the five-second duration", () => {
|
||||
const effect = romRogueChampionNerveGearEffect();
|
||||
if (effect.kind !== "apply-aura") throw new Error("Missing Nerve Gear aura.");
|
||||
let auras: readonly ActiveAura[] = [];
|
||||
for (let stack = 1; stack <= ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS + 2; stack += 1) {
|
||||
auras = applyCombatAura(auras, {
|
||||
definition: effect.aura,
|
||||
sourceId: "rogue-actor",
|
||||
targetId: "rogue-actor",
|
||||
now: stack * 1_000,
|
||||
}).auras;
|
||||
}
|
||||
expect(auras).toHaveLength(1);
|
||||
expect(auras[0]).toMatchObject({
|
||||
stacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS,
|
||||
appliedAt: 7_000,
|
||||
expiresAt: 7_000 + ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS,
|
||||
definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export const ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID = 498839;
|
||||
export const ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG = "rom-rogue-champion-nerve-gear";
|
||||
export const ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID = "rom-rogue-champion-nerve-gear-active";
|
||||
export const ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS = 5_000;
|
||||
export const ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS = 5;
|
||||
|
||||
export function romRogueChampionNerveGearShouldTrigger(
|
||||
classId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "classId" | "secondaryClassId" | "id" | "source" | "romSkillGroup" | "passive">,
|
||||
passiveLearned: boolean,
|
||||
): boolean {
|
||||
return classId === "rom-rogue"
|
||||
&& secondaryClassId === "rom-champion"
|
||||
&& passiveLearned
|
||||
&& ability.classId === "rom-rogue"
|
||||
&& ability.secondaryClassId === "rom-champion"
|
||||
&& ability.source === "runewaker"
|
||||
&& ability.romSkillGroup === "elite"
|
||||
&& ability.passive !== true;
|
||||
}
|
||||
|
||||
/** RuneWaker child 621652: +5 attack speed and +2 dodge for 5s, stacking to five. */
|
||||
export function romRogueChampionNerveGearEffect(): Extract<AbilityEffect, { kind: "apply-aura" }> {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID,
|
||||
aura: {
|
||||
id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID,
|
||||
name: "Nerve Gear",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS,
|
||||
maxStacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS,
|
||||
stackBehavior: "add",
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "attack-speed", operation: "flat", value: 5 },
|
||||
{ kind: "stat", stat: "dodge", operation: "flat", value: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,16 +7,75 @@ import { abilityRequiresOutOfCombat } from "./abilityRules";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import { combatAttackTable } from "./combatResults";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_ROGUE_DAGGER_MASTERY_TAG,
|
||||
ROM_ROGUE_INSTANT_SHADOW_READY_TAG,
|
||||
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
|
||||
ROM_ROGUE_TREASURE_HUNTER_TAG,
|
||||
romRogueDaggerDamageMultiplier,
|
||||
romRogueEnchantedThrowShouldProc,
|
||||
romRogueShadowWalkerAdjustment,
|
||||
romRogueTreasureHunterLootChance,
|
||||
} from "./romRogueMechanics";
|
||||
import {
|
||||
ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG,
|
||||
ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY,
|
||||
ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG,
|
||||
romRogueMageDayOfRainBaseMissPercent,
|
||||
romScoutRogueProcActionSpec,
|
||||
} from "./romScoutRogueOverrides";
|
||||
|
||||
const SINISTER_METHODS_BUFF_ID = "rom-pair-sinister-methods-critical";
|
||||
|
||||
function prepareSinisterMethodsTarget(
|
||||
abilityId: "rom-rogue-primary-1" | "rom-rogue-primary-4" | "rom-rogue-primary-2",
|
||||
mobLevel = 80,
|
||||
criticalStrikeRating = 0,
|
||||
) {
|
||||
const behindTarget = abilityId === "rom-rogue-primary-4";
|
||||
const dagger = behindTarget ? testWeapon("dagger", "rogue-sinister-methods-dagger") : null;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-priest",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
...(dagger ? { inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } } } : {}),
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
playerPosition: behindTarget ? [0, 0, -2] : [0, 0, 0],
|
||||
gearStats: {
|
||||
...state.gearStats,
|
||||
hitRating: mobLevel >= 200 ? 0 : 1_000_000,
|
||||
criticalStrikeRating,
|
||||
},
|
||||
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
||||
}));
|
||||
const targetId = "rogue-sinister-methods-target";
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: "Sinister Methods target",
|
||||
maxHealth: 100_000,
|
||||
level: mobLevel,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(targetId, behindTarget ? [0, 0, 0] : [0, 0, 2]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function sinisterMethodsBuff() {
|
||||
return useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID && aura.definition.id === SINISTER_METHODS_BUFF_ID
|
||||
));
|
||||
}
|
||||
|
||||
function castRogueAbilityAtTarget(abilityId: string, targetActorId: string, now: number) {
|
||||
return useCombatStore.getState().castAbility(abilityId, { targetActorId, now });
|
||||
}
|
||||
|
||||
function effectAuras(abilityId: string) {
|
||||
return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
@@ -71,6 +130,160 @@ describe("RoM Rogue source mechanics", () => {
|
||||
expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("learns Sinister Methods as a proc marker without an always-on critical aura", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
||||
});
|
||||
const passive = abilityById("rom-rogue-rom-priest-elite-45")!;
|
||||
const [marker] = effectAuras(passive.id);
|
||||
expect(marker?.kind === "apply-aura" && marker.aura.utilityTags)
|
||||
.toContain(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG);
|
||||
expect(marker?.kind === "apply-aura" && marker.aura.procs?.[0]?.triggers).toEqual(["hit", "crit"]);
|
||||
expect(marker?.kind === "apply-aura" && marker.aura.modifiers).toBeUndefined();
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false);
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG)
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Low Blow", "rom-rogue-primary-1"],
|
||||
["Blind Spot", "rom-rogue-primary-4"],
|
||||
] as const)("procs Sinister Methods after a landed %s", (_name, abilityId) => {
|
||||
const targetId = prepareSinisterMethodsTarget(abilityId);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false);
|
||||
const castResult = castRogueAbilityAtTarget(abilityId, targetId, 1_000);
|
||||
expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true });
|
||||
const hit = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === abilityId
|
||||
));
|
||||
expect(hit?.outcome).not.toBe("miss");
|
||||
expect(sinisterMethodsBuff()).toMatchObject({
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 11_000,
|
||||
definition: {
|
||||
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }],
|
||||
},
|
||||
});
|
||||
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000);
|
||||
});
|
||||
|
||||
it("also procs Sinister Methods on a landed critical hit", () => {
|
||||
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 80, 1_000_000);
|
||||
const castResult = castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000);
|
||||
expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === "rom-rogue-primary-1"
|
||||
))?.outcome).toBe("crit");
|
||||
expect(sinisterMethodsBuff()?.appliedAt).toBe(1_000);
|
||||
});
|
||||
|
||||
it("does not proc or start its cooldown on a miss or another ability", () => {
|
||||
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 200);
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === "rom-rogue-primary-1"
|
||||
))?.outcome).toBe("miss");
|
||||
expect(sinisterMethodsBuff()).toBeUndefined();
|
||||
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined();
|
||||
|
||||
const otherTargetId = prepareSinisterMethodsTarget("rom-rogue-primary-2");
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-2", otherTargetId, 3_000).ok).toBe(true);
|
||||
expect(sinisterMethodsBuff()).toBeUndefined();
|
||||
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined();
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, hitRating: 1_000_000 },
|
||||
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
||||
}));
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", otherTargetId, 5_000).ok).toBe(true);
|
||||
expect(sinisterMethodsBuff()?.appliedAt).toBe(5_000);
|
||||
});
|
||||
|
||||
it("honors Sinister Methods duration and 20-second cooldown expiry", () => {
|
||||
const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1");
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true);
|
||||
expect(sinisterMethodsBuff()?.expiresAt).toBe(11_000);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
||||
}));
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 12_000).ok).toBe(true);
|
||||
expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 1_000, expiresAt: 11_000 });
|
||||
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
|
||||
}));
|
||||
expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 21_000).ok).toBe(true);
|
||||
expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 21_000, expiresAt: 31_000 });
|
||||
expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(41_000);
|
||||
});
|
||||
|
||||
it("arms Instant Shadow on a successful Rogue/Mage dodge and consumes it for its three-second concealment", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
|
||||
});
|
||||
const ability = abilityById("rom-rogue-rom-mage-elite-60")!;
|
||||
useCombatStore.getState().resetSettings();
|
||||
usePartyStore.getState().initializeParty("rom-instant-shadow-tests", 1, useCombatStore.getState().health);
|
||||
resetCombatSpatialProvider();
|
||||
const beforeDodge = useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
|
||||
expect(beforeDodge).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
|
||||
useCombatStore.getState().registerMob("rogue-instant-shadow-attacker", {
|
||||
name: "Instant Shadow attacker", maxHealth: 100_000, xpReward: 0, attacks: [DEFAULT_ENEMY_ATTACK],
|
||||
});
|
||||
setMobPosition("rogue-instant-shadow-attacker", [0, 0, 1]);
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, dodgeRating: 1_000_000 },
|
||||
resource: state.maxResource,
|
||||
resourcePools: {
|
||||
...state.resourcePools,
|
||||
energy: state.maxResourcePools.energy,
|
||||
mana: state.maxResourcePools.mana,
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().damageMob("rogue-instant-shadow-attacker", 1, 1_100);
|
||||
|
||||
let dodgedAt: number | null = null;
|
||||
for (let now = 1_450; now < 20_000; now += 100) {
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
if (useCombatStore.getState().lastCombatResult?.outcome === "dodge") {
|
||||
dodgedAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(dodgedAt, JSON.stringify({ health: useCombatStore.getState().health,
|
||||
lastCombatResult: useCombatStore.getState().lastCombatResult,
|
||||
mob: useCombatStore.getState().mobs["rogue-instant-shadow-attacker"] })).not.toBeNull();
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG)
|
||||
))).toBe(true);
|
||||
|
||||
const beforeCast = useCombatStore.getState();
|
||||
const manaCost = Math.round((beforeCast.maxResourcePools.mana ?? 0) * 0.06);
|
||||
const castAt = dodgedAt! + 100;
|
||||
expect(useCombatStore.getState().castAbility(ability.id, undefined, castAt))
|
||||
.toMatchObject({ ok: true, abilityId: ability.id });
|
||||
const afterCast = useCombatStore.getState();
|
||||
expect(afterCast.resourcePools.energy).toBe((beforeCast.resourcePools.energy ?? 0) - 15);
|
||||
expect(afterCast.resourcePools.mana).toBe((beforeCast.resourcePools.mana ?? 0) - manaCost);
|
||||
expect(afterCast.cooldowns[ability.id]).toBe(castAt + ability.cooldownMs);
|
||||
expect(afterCast.auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG)
|
||||
))).toBe(false);
|
||||
expect(afterCast.auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes("stealth")
|
||||
&& aura.expiresAt === castAt + 3_000
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => {
|
||||
const context = {
|
||||
classId: "rom-rogue",
|
||||
@@ -88,6 +301,56 @@ describe("RoM Rogue source mechanics", () => {
|
||||
expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
|
||||
});
|
||||
|
||||
it("adds the referenced Throw payload to landed Rogue/Mage normal attacks while Enchanted Throw is active", () => {
|
||||
const simulateAutoAttacks = (enchanted: boolean) => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
|
||||
});
|
||||
useCombatStore.getState().registerMob("enchanted-throw-target", { name: "Target", maxHealth: 1_000_000 });
|
||||
setMobPosition("enchanted-throw-target", [0, 0, 3]);
|
||||
if (enchanted) {
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true);
|
||||
}
|
||||
useCombatStore.setState({ autoAttackTargetId: "enchanted-throw-target", nextAutoAttackAt: 1_500 });
|
||||
for (let now = 1_500; now <= 20_000; now += 250) {
|
||||
useCombatStore.getState().tick(0.25, now);
|
||||
if (useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.abilityId === "rom-rogue-general-1"
|
||||
))) break;
|
||||
}
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.targetActorId === "enchanted-throw-target"
|
||||
));
|
||||
};
|
||||
|
||||
const enchantedHits = simulateAutoAttacks(true);
|
||||
expect(enchantedHits.some((event) => event.abilityId === "rom-rogue-general-1"
|
||||
&& event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
|
||||
|
||||
const ordinaryHits = simulateAutoAttacks(false);
|
||||
expect(ordinaryHits.some((event) => event.abilityId === undefined)).toBe(true);
|
||||
expect(ordinaryHits.some((event) => event.abilityId === "rom-rogue-general-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("only qualifies landed Rogue/Mage player normal attacks for Enchanted Throw", () => {
|
||||
const context = {
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-mage",
|
||||
sourceActorId: "player",
|
||||
playerActorId: "player",
|
||||
attackKind: "melee",
|
||||
isAutoAttack: true,
|
||||
attackLanded: true,
|
||||
enchantedThrowActive: true,
|
||||
};
|
||||
expect(romRogueEnchantedThrowShouldProc(context)).toBe(true);
|
||||
expect(romRogueEnchantedThrowShouldProc({ ...context, attackLanded: false })).toBe(false);
|
||||
expect(romRogueEnchantedThrowShouldProc({ ...context, enchantedThrowActive: false })).toBe(false);
|
||||
expect(romRogueEnchantedThrowShouldProc({ ...context, isAutoAttack: false })).toBe(false);
|
||||
expect(romRogueEnchantedThrowShouldProc({ ...context, secondaryClassId: "rom-warrior" })).toBe(false);
|
||||
});
|
||||
|
||||
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
|
||||
expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2);
|
||||
expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25);
|
||||
@@ -114,6 +377,87 @@ describe("RoM Rogue source mechanics", () => {
|
||||
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
|
||||
});
|
||||
|
||||
it("gives Rogue/Mage Throw its source-backed one-second Day of Rain cooldown reduction", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
|
||||
});
|
||||
useCombatStore.getState().registerMob("rogue-day-of-rain-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-day-of-rain-target", [0, 0, 1]);
|
||||
useCombatStore.getState().selectMob("rogue-day-of-rain-target");
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(26_000);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-general-1", undefined, 2_600).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(25_000);
|
||||
});
|
||||
|
||||
it("applies Day of Rain's exact 50% miss rate only while Enchanted Throw is active", () => {
|
||||
const dayOfRain = abilityById("rom-rogue-rom-mage-elite-30")!;
|
||||
const beforeBuff = romRogueMageDayOfRainBaseMissPercent(
|
||||
"rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [], 6,
|
||||
);
|
||||
const withBuff = romRogueMageDayOfRainBaseMissPercent(
|
||||
"rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], 6,
|
||||
);
|
||||
expect(beforeBuff).toBeNull();
|
||||
expect(withBuff).toBe(56);
|
||||
expect(combatAttackTable({
|
||||
amount: 100,
|
||||
attackKind: "ranged",
|
||||
damageSchool: "physical",
|
||||
attacker: { level: 80, hitPercent: 6, criticalChancePercent: 0 },
|
||||
defender: { level: 80, dodgePercent: 0, parryPercent: 0, blockPercent: 0, criticalAvoidancePercent: 0 },
|
||||
rules: { baseMissChancePercent: withBuff! },
|
||||
}).missPercent).toBe(50);
|
||||
expect(romRogueMageDayOfRainBaseMissPercent(
|
||||
"rom-rogue", "rom-warrior", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], 6,
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the Enchanted Throw hit penalty in Day of Rain's resolved combat result", () => {
|
||||
const outcomeFor = (now: number, enchantedThrow: boolean): string | undefined => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
|
||||
});
|
||||
useCombatStore.getState().registerMob("rogue-day-rain-roll-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("rogue-day-rain-roll-target", [0, 0, 1]);
|
||||
useCombatStore.getState().selectMob("rogue-day-rain-roll-target");
|
||||
if (enchantedThrow) {
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true);
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, now).ok).toBe(true);
|
||||
return useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === "rom-rogue-rom-mage-elite-30"
|
||||
))?.outcome;
|
||||
};
|
||||
|
||||
let foundEnchantedOnlyMiss = false;
|
||||
for (let now = 3_000; now < 3_100; now += 1) {
|
||||
const ordinaryOutcome = outcomeFor(now, false);
|
||||
const enchantedOutcome = outcomeFor(now, true);
|
||||
if (ordinaryOutcome !== "miss" && ordinaryOutcome !== undefined && enchantedOutcome === "miss") {
|
||||
foundEnchantedOnlyMiss = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(foundEnchantedOnlyMiss).toBe(true);
|
||||
});
|
||||
|
||||
it("marks the active Rogue/Mage Enchanted Throw buff for its Day of Rain hit-rate rider", () => {
|
||||
const enchantedThrow = abilityById("rom-rogue-rom-mage-elite-15")!;
|
||||
const enchantedAuras = enchantedThrow.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
expect(enchantedAuras.length).toBeGreaterThan(0);
|
||||
expect(enchantedAuras.every((effect) => !effect.aura.procs?.length)).toBe(true);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 15,
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("adds one bonus boss loot roll only while Treasure Hunter is active", () => {
|
||||
const bossKey = LOOT_CATALOG.bosses[0]!.bossKey;
|
||||
const lootCount = (passiveActive: boolean) => {
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
export const ROM_ROGUE_DAGGER_MASTERY_TAG = "rom-dagger-mastery";
|
||||
export const ROM_ROGUE_TREASURE_HUNTER_TAG = "rom-treasure-hunter";
|
||||
export const ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID = 491537;
|
||||
export const ROM_ROGUE_INSTANT_SHADOW_READY_TAG = "rom-rogue-instant-shadow-ready";
|
||||
|
||||
export interface RomRogueEnchantedThrowContext {
|
||||
readonly classId: string;
|
||||
readonly secondaryClassId: string | null | undefined;
|
||||
readonly sourceActorId: string;
|
||||
readonly playerActorId: string;
|
||||
readonly attackKind: string;
|
||||
readonly isAutoAttack: boolean;
|
||||
readonly attackLanded: boolean;
|
||||
readonly enchantedThrowActive: boolean;
|
||||
}
|
||||
|
||||
/** Enchanted Throw only follows a landed Rogue/Mage normal attack while its buff is active. */
|
||||
export function romRogueEnchantedThrowShouldProc(context: RomRogueEnchantedThrowContext): boolean {
|
||||
return context.classId === "rom-rogue"
|
||||
&& context.secondaryClassId === "rom-mage"
|
||||
&& context.sourceActorId === context.playerActorId
|
||||
&& context.attackKind === "melee"
|
||||
&& context.isAutoAttack
|
||||
&& context.attackLanded
|
||||
&& context.enchantedThrowActive;
|
||||
}
|
||||
|
||||
export interface RomRogueAbilityAdjustment {
|
||||
readonly allowsCombatUse: boolean;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const PLAYER_DEFENDER = {
|
||||
level: 50,
|
||||
armor: 0,
|
||||
dodgePercent: 0,
|
||||
parryPercent: 0,
|
||||
blockPercent: 0,
|
||||
blockValue: 0,
|
||||
} as const;
|
||||
|
||||
describe("RoM Rogue Numbing Dagger status durations", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
level: 50,
|
||||
});
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
function registerLivingTarget(id: string, characterDefender = false) {
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
serverEntry: 103946,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
if (characterDefender) {
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[id]: { ...state.mobs[id]!, characterDefender: PLAYER_DEFENDER },
|
||||
},
|
||||
}));
|
||||
}
|
||||
setMobPosition(id, [3, 0, 0]);
|
||||
}
|
||||
|
||||
function applyPlayerPoisonousAndSleep(targetId: string, completionAt: number): void {
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(1.1, completionAt);
|
||||
}
|
||||
|
||||
it("keeps the local ten-second sleep for player Rogue casts on NPCs", () => {
|
||||
registerLivingTarget("numbing-npc");
|
||||
applyPlayerPoisonousAndSleep("numbing-npc", 3_700);
|
||||
|
||||
expect(useCombatStore.getState().mobs["numbing-npc"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt: 13_700 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the local three-second sleep for player Rogue casts on character defenders", () => {
|
||||
registerLivingTarget("numbing-player", true);
|
||||
applyPlayerPoisonousAndSleep("numbing-player", 3_700);
|
||||
|
||||
expect(useCombatStore.getState().mobs["numbing-player"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt: 6_700 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the same target-specific duration for a party Rogue cast", () => {
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Rogue companion",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 50, 0, "numbing-dagger-party");
|
||||
const numbingDagger = abilityById("rom-rogue-primary-5");
|
||||
const poisonous = abilityById("rom-rogue-primary-6");
|
||||
const poisonousEffect = poisonous?.effects.find((effect) => effect.kind === "apply-aura");
|
||||
if (!numbingDagger || !poisonous || poisonousEffect?.kind !== "apply-aura") {
|
||||
throw new Error("Missing RuneWaker Rogue Numbing Dagger or Poisonous source effect.");
|
||||
}
|
||||
|
||||
usePartyStore.getState().initializeParty(
|
||||
"numbing-dagger-party", 50, useCombatStore.getState().health, "damage", 2, "rom",
|
||||
);
|
||||
const targetId = "numbing-party-player";
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
activeCast: {
|
||||
abilityId: numbingDagger.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 2_000,
|
||||
originalDurationMs: 1_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
expect(useCombatStore.getState().applyActorAura(
|
||||
member.id, member.id, poisonous, poisonousEffect, 1_000,
|
||||
)).toBe(true);
|
||||
|
||||
registerLivingTarget(targetId, true);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 1_000);
|
||||
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
|
||||
advanceDungeonPartyCombat(2_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "sleep",
|
||||
sourceAbilityId: numbingDagger.id,
|
||||
sourceActorId: member.id,
|
||||
endsAt: 5_000,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["low-level NPC status branch", "numbing-control-npc", false, 15_000],
|
||||
["low-level player status branch", "numbing-control-player", true, 8_000],
|
||||
] as const)("keeps Numbing Dagger target durations in the %s", (_label, targetId, characterDefender, endsAt) => {
|
||||
registerLivingTarget(targetId, characterDefender);
|
||||
|
||||
expect(useCombatStore.getState().controlMob(
|
||||
targetId,
|
||||
"rom-rogue-primary-5",
|
||||
"sleep",
|
||||
9_000,
|
||||
5_000,
|
||||
true,
|
||||
)).toBe(true);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import generated from "./romPlayerCatalog.generated.json";
|
||||
import {
|
||||
ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID,
|
||||
ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID,
|
||||
ROM_ROGUE_NUMBING_DAGGER_SKILL_ID,
|
||||
romRogueNumbingDaggerStatusForTarget,
|
||||
} from "./romRogueNumbingDaggerRuntime";
|
||||
|
||||
describe("RoM Rogue Numbing Dagger duration", () => {
|
||||
it("keeps RuneWaker's separate creature and player control durations", () => {
|
||||
const skill = generated.skills.find((row) => row.sourceSkillId === ROM_ROGUE_NUMBING_DAGGER_SKILL_ID
|
||||
&& row.classId === "rom-rogue" && row.secondaryClassId === undefined);
|
||||
expect(skill?.source?.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID, effectTime: 10 }),
|
||||
expect.objectContaining({ id: ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID, effectTime: 3 }),
|
||||
]));
|
||||
expect(romRogueNumbingDaggerStatusForTarget("npc")).toEqual({
|
||||
kind: "status",
|
||||
status: "sleep",
|
||||
durationMs: 10_000,
|
||||
maxTargets: 1,
|
||||
breakOnDamage: true,
|
||||
});
|
||||
expect(romRogueNumbingDaggerStatusForTarget("player")).toEqual({
|
||||
kind: "status",
|
||||
status: "sleep",
|
||||
durationMs: 3_000,
|
||||
maxTargets: 1,
|
||||
breakOnDamage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export const ROM_ROGUE_NUMBING_DAGGER_SKILL_ID = 490347;
|
||||
export const ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID = 500737;
|
||||
export const ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID = 503046;
|
||||
|
||||
export type RomRogueNumbingDaggerTargetKind = "npc" | "player";
|
||||
|
||||
/** RuneWaker gives Numbing Dagger separate sleep payloads for NPCs and players. */
|
||||
export function romRogueNumbingDaggerStatusForTarget(targetKind: RomRogueNumbingDaggerTargetKind) {
|
||||
return {
|
||||
kind: "status" as const,
|
||||
status: "sleep" as const,
|
||||
durationMs: targetKind === "player" ? 3_000 : 10_000,
|
||||
maxTargets: 1,
|
||||
breakOnDamage: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_ROGUE_POISON_AURA_TAG,
|
||||
ROM_ROGUE_POISON_TRIGGER_SPELL_ID,
|
||||
} from "./romRoguePoisonRuntime";
|
||||
|
||||
function stableUnit(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (const character of seed) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function attackTime(seedForTime: (now: number) => string, shouldProc: boolean, startAt = 3_000): number {
|
||||
for (let now = startAt; now < 100_000; now += 1) {
|
||||
if ((stableUnit(seedForTime(now)) < 0.5) === shouldProc) return now;
|
||||
}
|
||||
throw new Error("Could not find a seeded Poison proc time.");
|
||||
}
|
||||
|
||||
function poisonedAuraFor(sourceId: string, targetId: string) {
|
||||
return useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === sourceId
|
||||
&& aura.targetId === targetId
|
||||
&& aura.definition.id === `rom-rogue-poisoned:${ROM_ROGUE_POISON_TRIGGER_SPELL_ID}`
|
||||
));
|
||||
}
|
||||
|
||||
describe("RoM Rogue Poison attack integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50, xp: 0 });
|
||||
usePartyStore.getState().initializeParty("rom-rogue-poison-tests", 50, useCombatStore.getState().health);
|
||||
});
|
||||
|
||||
function preparePlayerRogue(targetId: string): void {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", level: 50, xp: 0 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
}
|
||||
|
||||
it.each([true, false])("player landed normal autos use the 50%% trigger roll (proc=%s)", (shouldProc) => {
|
||||
const targetId = `poison-player-${shouldProc}`;
|
||||
preparePlayerRogue(targetId);
|
||||
const now = attackTime(
|
||||
(attackAt) => `${PLAYER_AGGRO_ID}:auto:${targetId}:${attackAt}:rom-rogue-poison`,
|
||||
shouldProc,
|
||||
);
|
||||
useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: now });
|
||||
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(1_000_000);
|
||||
expect(poisonedAuraFor(PLAYER_AGGRO_ID, targetId)?.stacks ?? 0).toBe(shouldProc ? 1 : 0);
|
||||
});
|
||||
|
||||
it.each([true, false])("party landed normal autos use the 50%% trigger roll (proc=%s)", (shouldProc) => {
|
||||
const targetId = `poison-party-${shouldProc}`;
|
||||
const seed = `rom-rogue-poison-party-${shouldProc}`;
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Rogue companion",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 50, 0, seed);
|
||||
const poisonous = abilityById("rom-rogue-primary-6")!;
|
||||
const poisonousEffect = poisonous.effects.find((effect) => effect.kind === "apply-aura");
|
||||
if (poisonousEffect?.kind !== "apply-aura") throw new Error("Missing RuneWaker Poisonous aura effect.");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000, status: "attacking" }] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
expect(useCombatStore.getState().applyActorAura(
|
||||
member.id, member.id, poisonous, poisonousEffect, 100,
|
||||
)).toBe(true);
|
||||
const now = attackTime(
|
||||
(attackAt) => `${member.id}:${targetId}:party-auto-attack:${attackAt}:rom-rogue-poison`,
|
||||
shouldProc,
|
||||
);
|
||||
|
||||
advanceDungeonPartyCombat(now);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(1_000_000);
|
||||
expect(poisonedAuraFor(member.id, targetId)?.stacks ?? 0).toBe(shouldProc ? 1 : 0);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("adds stacks independently for player and party Rogues on the same mob", () => {
|
||||
const targetId = "poison-two-rogues";
|
||||
preparePlayerRogue(targetId);
|
||||
const playerTime = attackTime(
|
||||
(now) => `${PLAYER_AGGRO_ID}:auto:${targetId}:${now}:rom-rogue-poison`,
|
||||
true,
|
||||
);
|
||||
useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: playerTime });
|
||||
useCombatStore.getState().tick(0.1, playerTime);
|
||||
|
||||
const seed = "rom-rogue-poison-two-rogues";
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Rogue companion",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 50, 0, seed);
|
||||
const poisonous = abilityById("rom-rogue-primary-6")!;
|
||||
const poisonousEffect = poisonous.effects.find((effect) => effect.kind === "apply-aura");
|
||||
if (poisonousEffect?.kind !== "apply-aura") throw new Error("Missing RuneWaker Poisonous aura effect.");
|
||||
usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000, status: "attacking" }] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
expect(useCombatStore.getState().applyActorAura(member.id, member.id, poisonous, poisonousEffect, playerTime))
|
||||
.toBe(true);
|
||||
const partyTime = attackTime(
|
||||
(now) => `${member.id}:${targetId}:party-auto-attack:${now}:rom-rogue-poison`,
|
||||
true,
|
||||
playerTime + 1,
|
||||
);
|
||||
|
||||
advanceDungeonPartyCombat(partyTime);
|
||||
|
||||
expect(poisonedAuraFor(PLAYER_AGGRO_ID, targetId)?.stacks).toBe(1);
|
||||
expect(poisonedAuraFor(member.id, targetId)?.stacks).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAura, resolveDamageValue, type AuraDefinition } from "./combatAuras";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import generated from "./romPlayerCatalog.generated.json";
|
||||
import {
|
||||
ROM_ROGUE_POISON_AURA_TAG,
|
||||
ROM_ROGUE_POISON_COMPONENT_ID,
|
||||
ROM_ROGUE_POISON_DEBUFF_DURATION_MS,
|
||||
ROM_ROGUE_POISON_MAX_STACKS,
|
||||
ROM_ROGUE_POISON_PROC_CHANCE,
|
||||
ROM_ROGUE_POISON_SKILL_ID,
|
||||
ROM_ROGUE_POISON_TRIGGER_SPELL_ID,
|
||||
romRoguePoisonApplicationForAttack,
|
||||
} from "./romRoguePoisonRuntime";
|
||||
|
||||
const baseContext = {
|
||||
classId: "rom-rogue",
|
||||
sourceActorId: "party-rogue-1",
|
||||
targetActorId: "mob-1",
|
||||
attackKind: "melee" as const,
|
||||
isNormalAttack: true,
|
||||
attackLanded: true,
|
||||
poisonActive: true,
|
||||
roll: 0.4999,
|
||||
};
|
||||
|
||||
describe("RoM Rogue Poison", () => {
|
||||
it("keeps the RuneWaker-triggered attack poison values and only borrows the missing duration", () => {
|
||||
const sourceRow = generated.skills.find((row) => row.sourceSkillId === ROM_ROGUE_POISON_SKILL_ID
|
||||
&& row.classId === "rom-rogue");
|
||||
expect(sourceRow?.source?.components?.[0]).toMatchObject({
|
||||
id: ROM_ROGUE_POISON_COMPONENT_ID,
|
||||
effectTime: 900,
|
||||
onAttackTrigger: { rate: 50, rank: 0, id: ROM_ROGUE_POISON_TRIGGER_SPELL_ID },
|
||||
});
|
||||
const skill = abilityById("rom-rogue-primary-6");
|
||||
expect(skill?.dbcSpellId).toBe(ROM_ROGUE_POISON_SKILL_ID);
|
||||
expect(skill?.description).toContain("normal attacks have a 50% chance");
|
||||
expect(skill?.description).toContain("damage is lowered by 2%");
|
||||
expect(skill?.description).toContain("stacked four times");
|
||||
const selfBuff = skill?.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(selfBuff?.kind === "apply-aura" && selfBuff.aura.utilityTags).toContain(ROM_ROGUE_POISON_AURA_TAG);
|
||||
expect(selfBuff?.kind === "apply-aura" && selfBuff.aura.durationMs).toBe(900_000);
|
||||
expect(ROM_ROGUE_POISON_TRIGGER_SPELL_ID).toBe(490357);
|
||||
expect(ROM_ROGUE_POISON_PROC_CHANCE).toBe(0.5);
|
||||
expect(ROM_ROGUE_POISON_DEBUFF_DURATION_MS).toBe(900_000);
|
||||
});
|
||||
|
||||
it("applies one two-percent source-owned poison stack on a qualifying normal hit", () => {
|
||||
const application = romRoguePoisonApplicationForAttack(baseContext);
|
||||
expect(application).toMatchObject({
|
||||
sourceId: "party-rogue-1",
|
||||
targetId: "mob-1",
|
||||
stacks: 1,
|
||||
definition: {
|
||||
durationMs: 900_000,
|
||||
maxStacks: ROM_ROGUE_POISON_MAX_STACKS,
|
||||
stackBehavior: "add",
|
||||
uniqueBySource: true,
|
||||
dispelCategory: "poison",
|
||||
modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: -0.02, perStack: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["player", "party-rogue-1"])("supports the learned Rogue owner %s", (sourceActorId) => {
|
||||
expect(romRoguePoisonApplicationForAttack({ ...baseContext, sourceActorId })?.sourceId).toBe(sourceActorId);
|
||||
});
|
||||
|
||||
it("allows a landed ranged normal attack to use the same source trigger", () => {
|
||||
expect(romRoguePoisonApplicationForAttack({ ...baseContext, attackKind: "ranged" })).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["miss", { attackLanded: false }],
|
||||
["spell", { attackKind: "spell" as const, isNormalAttack: false }],
|
||||
["ability hit", { isNormalAttack: false }],
|
||||
["inactive Poison", { poisonActive: false }],
|
||||
["another primary class", { classId: "rom-warrior" }],
|
||||
["threshold roll", { roll: ROM_ROGUE_POISON_PROC_CHANCE }],
|
||||
["invalid roll", { roll: Number.NaN }],
|
||||
])("does not apply for a %s", (_reason, overrides) => {
|
||||
expect(romRoguePoisonApplicationForAttack({ ...baseContext, ...overrides })).toBeNull();
|
||||
});
|
||||
|
||||
it("caps this Rogue's target debuff at four stacks and keeps a second Rogue independent", () => {
|
||||
const first = romRoguePoisonApplicationForAttack(baseContext)!;
|
||||
let auras = [] as ReturnType<typeof applyAura>["auras"];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
auras = applyAura(auras, {
|
||||
definition: first.definition,
|
||||
sourceId: first.sourceId,
|
||||
targetId: first.targetId,
|
||||
now: index * 1_000,
|
||||
}).auras;
|
||||
}
|
||||
expect(auras.find((aura) => aura.sourceId === "party-rogue-1")?.stacks).toBe(4);
|
||||
|
||||
const second = romRoguePoisonApplicationForAttack({
|
||||
...baseContext,
|
||||
sourceActorId: "party-rogue-2",
|
||||
roll: 0.1,
|
||||
})!;
|
||||
auras = applyAura(auras, {
|
||||
definition: second.definition,
|
||||
sourceId: second.sourceId,
|
||||
targetId: second.targetId,
|
||||
now: 7_000,
|
||||
}).auras;
|
||||
expect(auras.filter((aura) => aura.targetId === "mob-1").map((aura) => [aura.sourceId, aura.stacks]))
|
||||
.toEqual([["party-rogue-1", 4], ["party-rogue-2", 1]]);
|
||||
const debuff = auras.find((aura) => aura.sourceId === "party-rogue-2")?.definition as AuraDefinition;
|
||||
expect(debuff.modifiers?.[0]).toMatchObject({ value: -0.02, perStack: true });
|
||||
});
|
||||
|
||||
it("reduces target damage by two percent per stack for melee, ranged, and spell attacks", () => {
|
||||
const application = romRoguePoisonApplicationForAttack(baseContext)!;
|
||||
let auras = [] as ReturnType<typeof applyAura>["auras"];
|
||||
for (let index = 0; index < ROM_ROGUE_POISON_MAX_STACKS; index += 1) {
|
||||
auras = applyAura(auras, {
|
||||
definition: application.definition,
|
||||
sourceId: application.sourceId,
|
||||
targetId: application.targetId,
|
||||
now: index * 1_000,
|
||||
}).auras;
|
||||
}
|
||||
|
||||
for (const context of [
|
||||
{ attackKind: "melee", school: "physical" },
|
||||
{ attackKind: "ranged", school: "physical" },
|
||||
{ attackKind: "spell", school: "nature" },
|
||||
{ attackKind: "spell", school: "shadow" },
|
||||
] as const) {
|
||||
expect(resolveDamageValue(100, auras, { direction: "dealt", ...context })).toBe(92);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AuraDefinition, AttackKind } from "./combatAuras";
|
||||
|
||||
export const ROM_ROGUE_POISON_SKILL_ID = 490329;
|
||||
export const ROM_ROGUE_POISON_COMPONENT_ID = 500715;
|
||||
export const ROM_ROGUE_POISON_TRIGGER_SPELL_ID = 490357;
|
||||
export const ROM_ROGUE_POISON_AURA_TAG = "rom-poisonous";
|
||||
export const ROM_ROGUE_POISON_PROC_CHANCE = 0.5;
|
||||
export const ROM_ROGUE_POISON_MAX_STACKS = 4;
|
||||
export const ROM_ROGUE_POISON_DEBUFF_DURATION_MS = 900_000;
|
||||
|
||||
export interface RomRoguePoisonAttackContext {
|
||||
readonly classId: string;
|
||||
readonly sourceActorId: string;
|
||||
readonly targetActorId: string;
|
||||
readonly attackKind: AttackKind;
|
||||
readonly isNormalAttack: boolean;
|
||||
readonly attackLanded: boolean;
|
||||
readonly poisonActive: boolean;
|
||||
readonly roll: number;
|
||||
}
|
||||
|
||||
export interface RomRoguePoisonApplication {
|
||||
readonly sourceId: string;
|
||||
readonly targetId: string;
|
||||
readonly definition: AuraDefinition;
|
||||
readonly stacks: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* RuneWaker Poison (490329/500715) has a 50% on-normal-attack trigger for
|
||||
* hidden child 490357. Its local tooltip supplies the 2% damage reduction and
|
||||
* four-stack cap; the child duration is absent locally, so 900 seconds uses
|
||||
* only the current CoA entry's duration detail.
|
||||
*/
|
||||
export function romRoguePoisonApplicationForAttack(
|
||||
context: RomRoguePoisonAttackContext,
|
||||
): RomRoguePoisonApplication | null {
|
||||
if (context.classId !== "rom-rogue"
|
||||
|| !context.sourceActorId
|
||||
|| !context.targetActorId
|
||||
|| context.sourceActorId === context.targetActorId
|
||||
|| context.attackKind === "spell"
|
||||
|| !context.isNormalAttack
|
||||
|| !context.attackLanded
|
||||
|| !context.poisonActive
|
||||
|| !Number.isFinite(context.roll)
|
||||
|| context.roll < 0
|
||||
|| context.roll >= ROM_ROGUE_POISON_PROC_CHANCE) return null;
|
||||
|
||||
return {
|
||||
sourceId: context.sourceActorId,
|
||||
targetId: context.targetActorId,
|
||||
stacks: 1,
|
||||
definition: {
|
||||
id: `rom-rogue-poisoned:${ROM_ROGUE_POISON_TRIGGER_SPELL_ID}`,
|
||||
name: "Poisoned",
|
||||
disposition: "debuff",
|
||||
durationMs: ROM_ROGUE_POISON_DEBUFF_DURATION_MS,
|
||||
maxStacks: ROM_ROGUE_POISON_MAX_STACKS,
|
||||
stackBehavior: "add",
|
||||
uniqueBySource: true,
|
||||
dispelCategory: "poison",
|
||||
modifiers: [{
|
||||
kind: "damage",
|
||||
direction: "dealt",
|
||||
operation: "percent",
|
||||
value: -0.02,
|
||||
perStack: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { resolveDamageValue, resolveStatValue } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { romAbilityNeedsRangedWeapon } from "./romCombatMechanics";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides";
|
||||
|
||||
describe("RoM Scout ranged weapon masteries", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
it.each(["bow", "crossbow"] as const)(
|
||||
@@ -104,4 +113,287 @@ describe("RoM Scout ranged weapon masteries", () => {
|
||||
expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["rom-scout", "rom-warrior", 25, "rom-enhanced-throat-attack"],
|
||||
["rom-rogue", "rom-scout", 20, "rom-slit-throat"],
|
||||
] as const)("applies the local four-second Throat Attack silence for %s/%s", (
|
||||
classId, secondaryClassId, level, passiveTag,
|
||||
) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId, secondaryClassId, raceId: "rom-human", level,
|
||||
});
|
||||
useCombatStore.getState().registerMob("throat-silence-target", { name: "Target", level: 1, maxHealth: 100_000 });
|
||||
setMobPosition("throat-silence-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("throat-silence-target");
|
||||
|
||||
const throatAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490428);
|
||||
expect(throatAttack).toBeDefined();
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(passiveTag))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(throatAttack!.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["throat-silence-target"]?.statuses).toContainEqual(
|
||||
expect.objectContaining({ kind: "silence", endsAt: 5_000, sourceAbilityId: throatAttack!.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("limits the paired Throat Attack silence proc to Throat Attack hits", () => {
|
||||
expect(romScoutRogueProcActionSpec("rom-enhanced-throat-attack", 490428, false))
|
||||
.toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" });
|
||||
expect(romScoutRogueProcActionSpec("rom-slit-throat", 490428, false))
|
||||
.toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" });
|
||||
expect(romScoutRogueProcActionSpec("rom-slit-throat", 490423, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("reduces Snipe's cast by one second only with Scout/Priest Sniper", () => {
|
||||
const bow = {
|
||||
instanceId: "scout-sniper-bow",
|
||||
itemId: 1,
|
||||
itemKey: "scout-sniper-bow",
|
||||
name: "Test bow",
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: "ranged",
|
||||
armorType: null,
|
||||
weaponType: "bow" as const,
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: EMPTY_ITEM_STATS,
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
const prepareSnipe = (secondaryClassId?: "rom-priest", targetId = "sniper-target") => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout",
|
||||
...(secondaryClassId ? { secondaryClassId } : {}),
|
||||
raceId: "rom-human",
|
||||
level: 25,
|
||||
inventory: [bow],
|
||||
equipment: { player: { ranged: bow.instanceId } },
|
||||
});
|
||||
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000 });
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
};
|
||||
|
||||
prepareSnipe("rom-priest");
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const withSniper = useCombatStore.getState().activeCast?.originalDurationMs;
|
||||
expect(withSniper).toBeGreaterThan(0);
|
||||
|
||||
prepareSnipe(undefined, "sniper-base-target");
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBe(withSniper! + 1_000);
|
||||
});
|
||||
|
||||
it("marks all sourced Scout projectile abilities as requiring a ranged weapon", () => {
|
||||
for (const [abilityId, sourceSpellId] of [
|
||||
["rom-scout-general-0", 490423], // Shot
|
||||
["rom-scout-general-2", 491292], // Vampire Arrows
|
||||
["rom-scout-general-3", 490420], // Joint Blow
|
||||
["rom-scout-primary-1", 491128], // Wind Arrows
|
||||
["rom-scout-primary-57", 492589], // Autoshot
|
||||
] as const) {
|
||||
const ability = abilityById(abilityId);
|
||||
expect(ability?.dbcSpellId, abilityId).toBe(sourceSpellId);
|
||||
expect(romAbilityNeedsRangedWeapon(ability!), abilityId).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves Blood Arrow and Accurate Targeting outside the ranged weapon requirement", () => {
|
||||
const bloodArrow = abilitiesForCharacter("rom-scout", null).find((candidate) => candidate.dbcSpellId === 490434);
|
||||
const accurateTargeting = abilitiesForCharacter("rom-scout", "rom-warrior").find((candidate) => candidate.dbcSpellId === 493006);
|
||||
expect(bloodArrow).toBeDefined();
|
||||
expect(accurateTargeting).toBeDefined();
|
||||
expect(romAbilityNeedsRangedWeapon(bloodArrow!)).toBe(false);
|
||||
expect(romAbilityNeedsRangedWeapon(accurateTargeting!)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Scout secondary projectile skills without a ranged weapon for the player", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-warrior",
|
||||
secondaryClassId: "rom-scout",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
inventory: [],
|
||||
equipment: { player: {} },
|
||||
});
|
||||
useCombatStore.getState().registerMob("ranged-weapon-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("ranged-weapon-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("ranged-weapon-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, focus: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, focus: 1_000 },
|
||||
}));
|
||||
const player = useCombatStore.getState();
|
||||
expect(player.abilities.map((ability) => ability.id)).toEqual(expect.arrayContaining([
|
||||
"rom-scout-general-0", "rom-scout-general-2", "rom-scout-general-3",
|
||||
]));
|
||||
expect(resolvedEquipmentLoadout(player.equipment, player.inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: player.classId,
|
||||
secondaryClassId: player.secondaryClassId,
|
||||
specializationId: player.specializationId,
|
||||
role: player.gearRole,
|
||||
level: player.level,
|
||||
}).ranged).toBeUndefined();
|
||||
|
||||
for (const abilityId of [
|
||||
"rom-scout-general-0", // Shot (490423)
|
||||
"rom-scout-general-2", // Vampire Arrows (491292)
|
||||
"rom-scout-general-3", // Joint Blow (490420)
|
||||
]) {
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000), abilityId)
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a Scout secondary companion cast Shot without a ranged weapon", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 80 });
|
||||
const player = useCombatStore.getState();
|
||||
usePartyStore.getState().initializeParty("scout-companion-ranged-guard", 80, player.health, "damage");
|
||||
const scout = createConfiguredPartyMember({
|
||||
name: "Warrior Scout companion",
|
||||
classId: "rom-warrior",
|
||||
secondaryClassId: "rom-scout",
|
||||
raceId: "rom-human",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, "scout-companion-ranged-guard");
|
||||
const shot = abilitiesForCharacter("rom-warrior", "rom-scout").find((ability) => ability.dbcSpellId === 490423);
|
||||
if (!shot) throw new Error("Missing Scout Shot");
|
||||
const cooldowns = Object.fromEntries(abilitiesForCharacter("rom-warrior", "rom-scout").map((ability) => [ability.id, 60_000]));
|
||||
usePartyStore.setState({ members: [{
|
||||
...scout,
|
||||
resource: scout.maxResource,
|
||||
resourcePools: { ...scout.resourcePools, focus: scout.maxResource },
|
||||
cooldowns: { ...cooldowns, [shot.id]: 0 },
|
||||
}] });
|
||||
const combat = useCombatStore.getState();
|
||||
expect(resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
|
||||
ownerId: scout.id,
|
||||
ownerKind: "party",
|
||||
classId: scout.classId,
|
||||
secondaryClassId: scout.secondaryClassId,
|
||||
specializationId: scout.specializationId,
|
||||
role: scout.role,
|
||||
level: scout.level,
|
||||
}).ranged).toBeUndefined();
|
||||
registerPartyRuntimePosition(scout.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("scout-companion-target", { name: "Target", maxHealth: 100_000 });
|
||||
setMobPosition("scout-companion-target", [3, 0, 0]);
|
||||
useCombatStore.getState().damageMob("scout-companion-target", 1, 100);
|
||||
useCombatStore.getState().selectMob("scout-companion-target");
|
||||
updatePartyRuntimeTargetVisibility(scout.id, "scout-companion-target", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === scout.id
|
||||
&& event.abilityId === shot.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoM Warrior/Scout Calm", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
function prepareWarriorScoutTarget(targetId: string, targetLevel: number) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-warrior",
|
||||
secondaryClassId: "rom-scout",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, focus: 1_000, rage: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, focus: 1_000, rage: 1_000 },
|
||||
}));
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: targetId,
|
||||
level: targetLevel,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(targetId, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
const skullBreaker = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491328);
|
||||
if (!skullBreaker) throw new Error("Missing Warrior/Scout Skull Breaker");
|
||||
return skullBreaker;
|
||||
}
|
||||
|
||||
function calmAura() {
|
||||
return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-warrior-scout-calm"));
|
||||
}
|
||||
|
||||
it("grants 20 physical hit for 15 seconds when Skull Breaker misses", () => {
|
||||
const skullBreaker = prepareWarriorScoutTarget("calm-miss-target", 255);
|
||||
const player = useCombatStore.getState();
|
||||
const basePhysicalHit = resolveStatValue(0, player.auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit");
|
||||
expect(player.abilities.some((ability) => ability.dbcSpellId === 498032 && ability.passive)).toBe(true);
|
||||
|
||||
expect(player.castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false);
|
||||
const aura = calmAura();
|
||||
expect(aura?.expiresAt).toBe(16_000);
|
||||
expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit"))
|
||||
.toBe(basePhysicalHit + 20);
|
||||
|
||||
useCombatStore.getState().tick(0, 16_001);
|
||||
expect(calmAura()).toBeUndefined();
|
||||
expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit"))
|
||||
.toBe(basePhysicalHit);
|
||||
});
|
||||
|
||||
it("does not grant Calm when Skull Breaker lands", () => {
|
||||
const skullBreaker = prepareWarriorScoutTarget("calm-landed-target", 1);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
"calm-landed-target": {
|
||||
...state.mobs["calm-landed-target"]!,
|
||||
characterDefender: {
|
||||
level: 1,
|
||||
armor: 0,
|
||||
dodgePercent: 0,
|
||||
parryPercent: 0,
|
||||
blockPercent: 0,
|
||||
blockValue: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
|
||||
expect(calmAura()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not grant Calm for another failed Warrior attack or without the Warrior/Scout pair", () => {
|
||||
const skullBreaker = prepareWarriorScoutTarget("calm-other-ability-target", 255);
|
||||
const probingAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491133);
|
||||
if (!probingAttack) throw new Error("Missing Probing Attack");
|
||||
expect(useCombatStore.getState().castAbility(probingAttack.id, undefined, 3_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false);
|
||||
expect(calmAura()).toBeUndefined();
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 80 });
|
||||
useCombatStore.getState().registerMob("calm-wrong-pair-target", { name: "Target", maxHealth: 1_000_000 });
|
||||
setMobPosition("calm-wrong-pair-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("calm-wrong-pair-target");
|
||||
expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000))
|
||||
.toMatchObject({ ok: false, reason: "wrong-class" });
|
||||
expect(calmAura()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_SCOUT_PRIEST_DRUMS_OF_WAR,
|
||||
ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA,
|
||||
} from "./romScoutPriestOverrides";
|
||||
|
||||
const TARGET_ID = "drums-of-war-target";
|
||||
|
||||
function initializeScoutPriest(): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout",
|
||||
secondaryClassId: "rom-priest",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
}
|
||||
|
||||
function registerTarget(): void {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: TARGET_ID,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function shot() {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => (
|
||||
candidate.source === "runewaker" && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId
|
||||
));
|
||||
if (!ability) throw new Error("RuneWaker Shot (490423) is missing from Scout/Priest abilities.");
|
||||
return ability;
|
||||
}
|
||||
|
||||
function landPlayerShot(now: number, seed: string) {
|
||||
const ability = shot();
|
||||
return useCombatStore.getState().damageMob(
|
||||
TARGET_ID,
|
||||
100,
|
||||
now,
|
||||
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: ability.id },
|
||||
"physical",
|
||||
{
|
||||
attackKind: "ranged",
|
||||
abilityId: ability.id,
|
||||
seed,
|
||||
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function activeDrumsAura(actorId: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === actorId
|
||||
&& aura.sourceId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
));
|
||||
}
|
||||
|
||||
describe("RoM Scout/Priest Drums of War integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
initializeScoutPriest();
|
||||
});
|
||||
|
||||
it("applies the local critical-stat aura after a landed Shot and respects its per-caster ICD", () => {
|
||||
registerTarget();
|
||||
const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 493010);
|
||||
expect(passive).toMatchObject({ classId: "rom-scout", secondaryClassId: "rom-priest", passive: true });
|
||||
|
||||
const first = landPlayerShot(1_000, "drums-player-first");
|
||||
expect(first.combatResult?.landed).toBe(true);
|
||||
expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toMatchObject({
|
||||
expiresAt: 9_000,
|
||||
definition: {
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 },
|
||||
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${PLAYER_AGGRO_ID}`])
|
||||
.toBe(16_000);
|
||||
|
||||
landPlayerShot(5_000, "drums-player-on-icd");
|
||||
expect(activeDrumsAura(PLAYER_AGGRO_ID, 5_000)?.expiresAt).toBe(9_000);
|
||||
|
||||
landPlayerShot(16_000, "drums-player-ready-again");
|
||||
expect(activeDrumsAura(PLAYER_AGGRO_ID, 16_000)?.expiresAt).toBe(24_000);
|
||||
});
|
||||
|
||||
it("applies only to living party members within 50 yards of the caster", () => {
|
||||
const near = createConfiguredPartyMember({
|
||||
name: "Near Ally",
|
||||
classId: "priest",
|
||||
specialization: "Discipline",
|
||||
role: "healer",
|
||||
}, 80, 0, "drums-near-party-member");
|
||||
const far = createConfiguredPartyMember({
|
||||
name: "Far Ally",
|
||||
classId: "warrior",
|
||||
specialization: "Arms",
|
||||
role: "damage",
|
||||
}, 80, 1, "drums-far-party-member");
|
||||
usePartyStore.setState({ members: [near, far] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(near.id, [40, 0, 0]);
|
||||
registerPartyRuntimePosition(far.id, [51, 0, 0]);
|
||||
registerTarget();
|
||||
|
||||
landPlayerShot(1_000, "drums-player-party-radius");
|
||||
|
||||
expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === near.id
|
||||
&& aura.sourceId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === far.id
|
||||
&& aura.sourceId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes party Shot through the ranged hit-resolution path before proc application", () => {
|
||||
registerTarget();
|
||||
const playerHit = landPlayerShot(1_000, "drums-player-independent-icd");
|
||||
expect(playerHit.combatResult?.landed).toBe(true);
|
||||
|
||||
const seed = "drums-party-scout";
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Scout Priest",
|
||||
classId: "rom-scout",
|
||||
secondaryClassId: "rom-priest",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const partyShot = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId);
|
||||
if (!partyShot) throw new Error("RuneWaker Shot (490423) is missing from party Scout/Priest abilities.");
|
||||
const passive = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId);
|
||||
if (!passive) throw new Error("Drums of War (493010) is missing from party Scout/Priest abilities.");
|
||||
expect(abilityById(partyShot.id)?.dbcSpellId).toBe(490_423);
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...member.resourcePools, "nature-power": 10_000, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: partyShot.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: 500,
|
||||
completesAt: 3_500,
|
||||
originalDurationMs: 3_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(3_500);
|
||||
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === member.id
|
||||
&& aura.targetId === member.id
|
||||
&& aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id
|
||||
&& aura.expiresAt === 11_500)).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${member.id}`])
|
||||
.toBe(18_500);
|
||||
expect(activeDrumsAura(PLAYER_AGGRO_ID, 3_500)?.expiresAt).toBe(9_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_SCOUT_PRIEST_DRUMS_OF_WAR,
|
||||
romScoutPriestDrumsOfWarProc,
|
||||
} from "./romScoutPriestOverrides";
|
||||
|
||||
describe("RoM Scout/Priest Drums of War", () => {
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493010)!;
|
||||
const shot = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490423)!;
|
||||
|
||||
it("uses the local 8-second +20 physical/magic critical stats and 15-second cooldown on a landed Shot", () => {
|
||||
expect(passive).toMatchObject({
|
||||
classId: "rom-scout",
|
||||
secondaryClassId: "rom-priest",
|
||||
passive: true,
|
||||
});
|
||||
expect(shot).toMatchObject({ classId: "rom-scout", source: "runewaker" });
|
||||
expect(ROM_SCOUT_PRIEST_DRUMS_OF_WAR).toMatchObject({
|
||||
passiveSpellId: 493010,
|
||||
triggerSpellId: 490423,
|
||||
internalCooldownMs: 15_000,
|
||||
durationMs: 8_000,
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 },
|
||||
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(romScoutPriestDrumsOfWarProc(
|
||||
"rom-scout",
|
||||
"rom-priest",
|
||||
shot,
|
||||
[493010],
|
||||
true,
|
||||
15_000,
|
||||
15_000,
|
||||
)).toEqual({
|
||||
internalCooldownMs: 15_000,
|
||||
durationMs: 8_000,
|
||||
radius: 50,
|
||||
scope: "party",
|
||||
modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects other class pairs, other shots, missed hits, unlearned passives, and active cooldown", () => {
|
||||
const blocked = [
|
||||
romScoutPriestDrumsOfWarProc("rom-priest", "rom-scout", shot, [493010], true, 20_000, 0),
|
||||
romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [], true, 20_000, 0),
|
||||
romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", {
|
||||
...shot,
|
||||
dbcSpellId: 490424,
|
||||
}, [493010], true, 20_000, 0),
|
||||
romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [493010], false, 20_000, 0),
|
||||
romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [493010], true, 14_999, 15_000),
|
||||
romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", {
|
||||
...shot,
|
||||
source: "core",
|
||||
}, [493010], true, 20_000, 0),
|
||||
];
|
||||
|
||||
expect(blocked).toEqual(Array.from({ length: 6 }, () => null));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import type { AuraDefinition, AuraModifier } from "./combatAuras";
|
||||
|
||||
/** RuneWaker's local payload for Scout/Priest passive 493010. */
|
||||
export const ROM_SCOUT_PRIEST_DRUMS_OF_WAR = Object.freeze({
|
||||
passiveSpellId: 493010,
|
||||
triggerSpellId: 490423,
|
||||
internalCooldownMs: 15_000,
|
||||
durationMs: 8_000,
|
||||
radius: 50,
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 },
|
||||
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 },
|
||||
] satisfies readonly AuraModifier[],
|
||||
cooldownKey: "rom-scout-priest:drums-of-war",
|
||||
});
|
||||
|
||||
export const ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA: AuraDefinition = Object.freeze({
|
||||
id: "rom-scout-priest:drums-of-war",
|
||||
name: "Drums of War",
|
||||
description: "Increases physical and magical critical strike by 20 for 8 seconds.",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.durationMs,
|
||||
stackBehavior: "replace",
|
||||
uniqueBySource: true,
|
||||
modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers,
|
||||
});
|
||||
|
||||
export interface RomScoutPriestDrumsOfWarProc {
|
||||
readonly internalCooldownMs: number;
|
||||
readonly durationMs: number;
|
||||
/** The RuneWaker tooltip says nearby party; 50 is from the CoA range field. */
|
||||
readonly radius: number;
|
||||
readonly scope: "party";
|
||||
readonly modifiers: readonly AuraModifier[];
|
||||
}
|
||||
|
||||
/** Select Drums of War's exact sourced payload after a landed Shot when its ICD is ready. */
|
||||
export function romScoutPriestDrumsOfWarProc(
|
||||
characterClassId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
landed: boolean,
|
||||
now: number,
|
||||
readyAt: number,
|
||||
attackKind: "melee" | "ranged" | "spell" = "ranged",
|
||||
): RomScoutPriestDrumsOfWarProc | null {
|
||||
if (characterClassId !== "rom-scout"
|
||||
|| secondaryClassId !== "rom-priest"
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-scout"
|
||||
|| ability.dbcSpellId !== ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId
|
||||
|| attackKind !== "ranged"
|
||||
|| !learnedPassiveSpellIds.includes(ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId)
|
||||
|| !landed
|
||||
|| now < readyAt) return null;
|
||||
|
||||
return {
|
||||
internalCooldownMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.internalCooldownMs,
|
||||
durationMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.durationMs,
|
||||
radius: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.radius,
|
||||
scope: "party",
|
||||
modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
ROM_ROGUE_DAGGER_MASTERY_TAG,
|
||||
ROM_ROGUE_TREASURE_HUNTER_TAG,
|
||||
} from "./romRogueMechanics";
|
||||
import {
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID,
|
||||
} from "./romRogueChampionNerveGearRuntime";
|
||||
|
||||
export const ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG = "rom-491178-enchanted-throw";
|
||||
export const ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG = "rom-rogue-priest-sinister-methods";
|
||||
export const ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY = "rom-rogue-priest-sinister-methods";
|
||||
export const ROM_SCOUT_PRIEST_SNIPER_TAG = "rom-scout-priest-sniper";
|
||||
|
||||
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
|
||||
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
|
||||
@@ -56,14 +65,18 @@ function customProc(
|
||||
sourceId: number,
|
||||
trigger: "hit" | "crit",
|
||||
actionId: string,
|
||||
options: { readonly chance?: number; readonly internalCooldownMs?: number } = {},
|
||||
options: {
|
||||
readonly chance?: number;
|
||||
readonly internalCooldownMs?: number;
|
||||
readonly data?: Readonly<Record<string, string | number | boolean>>;
|
||||
} = {},
|
||||
): AuraProcDefinition {
|
||||
return {
|
||||
id: `rom-pair-proc-${sourceId}`,
|
||||
triggers: trigger === "hit" ? ["hit", "crit"] : trigger,
|
||||
chance: options.chance ?? 1,
|
||||
...(options.internalCooldownMs ? { internalCooldownMs: options.internalCooldownMs } : {}),
|
||||
action: { kind: "custom", id: actionId },
|
||||
action: { kind: "custom", id: actionId, ...(options.data ? { data: options.data } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,8 +104,8 @@ function timedCasterAura(
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-specific translations for the accepted Scout/Rogue and Rogue/Warrior
|
||||
* pairings. Passive tags and custom proc IDs are consumed by combat-store
|
||||
* Source-specific translations for the accepted Scout paired-class
|
||||
* mechanics. Passive tags and custom proc IDs are consumed by combat-store
|
||||
* transitions; they intentionally do not apply unrelated generic stat buffs.
|
||||
*/
|
||||
export function overrideRomScoutRogueEffects(
|
||||
@@ -100,6 +113,28 @@ export function overrideRomScoutRogueEffects(
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
switch (row.sourceSkillId) {
|
||||
case 497206: { // Dodge arms the usable spell; its child applies the three-second concealment.
|
||||
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null;
|
||||
const concealment = row.source?.components?.find((component) => component.id === 502597);
|
||||
const durationMs = Math.max(1_000, Math.round((concealment?.effectTime ?? 3) * 1_000));
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-rogue:instant-shadow",
|
||||
name: "Instant Shadow",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
utilityTags: ["stealth"],
|
||||
},
|
||||
sourceEffectType: row.source?.sourceEffectType ?? -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 502597,
|
||||
}];
|
||||
}
|
||||
case 490504:
|
||||
return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG);
|
||||
case 490316:
|
||||
@@ -109,6 +144,46 @@ export function overrideRomScoutRogueEffects(
|
||||
// is no distinct RoM off-hand hit yet, so do not turn either value into a
|
||||
// generic player melee modifier.
|
||||
return [];
|
||||
case 493020: { // Successful Low Blow/Blind Spot hits trigger the local 10s crit buff.
|
||||
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-priest") return null;
|
||||
const buff = row.source?.components?.find((component) => component.id === 503265);
|
||||
const criticalModifier = buff?.abilityModifiers?.find((modifier) => modifier.type === 18);
|
||||
const cooldown = row.source?.components?.find((component) => component.id === 503300);
|
||||
const durationMs = Math.max(0, Math.round((buff?.effectTime ?? 0) * 1_000));
|
||||
const cooldownMs = Math.max(0, Math.round((cooldown?.effectTime ?? 0) * 1_000));
|
||||
const proc = criticalModifier && durationMs > 0 && cooldownMs > 0
|
||||
? customProc(493020, "hit", "rom-sinister-methods", {
|
||||
data: { criticalStrike: criticalModifier.value, durationMs, cooldownMs },
|
||||
})
|
||||
: undefined;
|
||||
return taggedPassive(493020, "Sinister Methods", ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG, proc);
|
||||
}
|
||||
case ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID:
|
||||
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-champion") return null;
|
||||
// The timed child only triggers after an elite skill use. Keep a learned
|
||||
// marker, not its generic aura, so party passive loading cannot grant the
|
||||
// attack-speed/dodge payload on an unrelated cast.
|
||||
return taggedPassive(
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID,
|
||||
"Nerve Gear",
|
||||
ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG,
|
||||
);
|
||||
case 491178:
|
||||
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null;
|
||||
// Hidden child 491185 has no generated payload. The auto-attack runtime
|
||||
// applies its referenced Throw (490309) damage explicitly, so keep the
|
||||
// tag and avoid also registering the untranslated generic child proc.
|
||||
if (translated.some((effect) => effect.kind === "apply-aura")) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
procs: [],
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG],
|
||||
},
|
||||
} : effect);
|
||||
}
|
||||
return [...translated, ...taggedRoguePassive(491178, "Enchanted Throw", ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG)];
|
||||
case 491552:
|
||||
if (row.classId !== "rom-rogue") return null;
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
@@ -164,6 +239,17 @@ export function overrideRomScoutRogueEffects(
|
||||
case 493006:
|
||||
return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting",
|
||||
customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 }));
|
||||
case 491489:
|
||||
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-warrior") return null;
|
||||
return taggedPassive(491489, "Enhanced Throat Attack", "rom-enhanced-throat-attack",
|
||||
customProc(491489, "hit", "rom-enhanced-throat-attack"));
|
||||
case 491532:
|
||||
if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-scout") return null;
|
||||
return taggedPassive(491532, "Slit Throat", "rom-slit-throat",
|
||||
customProc(491532, "hit", "rom-slit-throat"));
|
||||
case 492344:
|
||||
if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-priest") return null;
|
||||
return taggedPassive(492344, "Sniper", ROM_SCOUT_PRIEST_SNIPER_TAG);
|
||||
case 493007:
|
||||
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
|
||||
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
|
||||
@@ -301,8 +387,14 @@ export function romScoutRogueCastEffects(
|
||||
breakOnDamage: true,
|
||||
}];
|
||||
}
|
||||
if (ability.dbcSpellId === 490309 && passives.has("rom-throwing-mastery")) {
|
||||
return [...effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.4 }];
|
||||
if (ability.dbcSpellId === 490309) {
|
||||
return [
|
||||
...effects,
|
||||
...(passives.has("rom-throwing-mastery")
|
||||
? [{ kind: "status" as const, status: "slow" as const, durationMs: 5_000, magnitude: 0.4 }]
|
||||
: []),
|
||||
{ kind: "cooldown-reset", mode: "reduce", abilityName: "Day of Rain", amountMs: 1_000 },
|
||||
];
|
||||
}
|
||||
if (ability.dbcSpellId === 492621) {
|
||||
const hasSourcePoison = target.has("rom-deadly-poison") || target.has("rom-501690");
|
||||
@@ -316,6 +408,26 @@ export function romScoutRogueCastEffects(
|
||||
return effects;
|
||||
}
|
||||
|
||||
/** Day of Rain has an exact 50% miss chance while Enchanted Throw is active. */
|
||||
export function romRogueMageDayOfRainBaseMissPercent(
|
||||
classId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
abilitySpellId: number | undefined,
|
||||
activeAuraTags: readonly string[],
|
||||
attackerHitPercent: number,
|
||||
): number | null {
|
||||
if (classId !== "rom-rogue"
|
||||
|| secondaryClassId !== "rom-mage"
|
||||
|| abilitySpellId !== 491183
|
||||
|| !activeAuraTags.includes(ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG)) return null;
|
||||
const hitPercent = Number.isFinite(attackerHitPercent)
|
||||
? Math.max(0, Math.min(100, attackerHitPercent))
|
||||
: 0;
|
||||
// combatAttackTable subtracts attacker hit rating from base miss chance.
|
||||
// Offset it here so the tooltip's 50% rate remains exact at any hit rating.
|
||||
return 50 + hitPercent;
|
||||
}
|
||||
|
||||
/** 499481/622993 adds a hit only to targets already carrying 622992's poison. */
|
||||
export function romScoutRogueWhirlwindBonusEffect(
|
||||
ability: AbilityDefinition,
|
||||
@@ -333,6 +445,7 @@ export type RomScoutRogueProcActionSpec =
|
||||
readonly recipient: "caster" | "target";
|
||||
readonly aura: AuraDefinition;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
readonly cooldownMs?: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "dot";
|
||||
@@ -352,6 +465,13 @@ export type RomScoutRogueProcActionSpec =
|
||||
readonly recipient: "target";
|
||||
readonly effect: Extract<AbilityEffect, { kind: "damage" }>;
|
||||
readonly provenance: "source" | "hybrid-estimate";
|
||||
}
|
||||
| {
|
||||
readonly kind: "status";
|
||||
readonly recipient: "target";
|
||||
readonly status: "silence";
|
||||
readonly durationMs: number;
|
||||
readonly provenance: "source";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -364,8 +484,37 @@ export function romScoutRogueProcActionSpec(
|
||||
actionId: string,
|
||||
triggeringSourceSkillId: number,
|
||||
casterPoisonous: boolean,
|
||||
data: Readonly<Record<string, string | number | boolean>> = {},
|
||||
): RomScoutRogueProcActionSpec | null {
|
||||
switch (actionId) {
|
||||
case "rom-enhanced-throat-attack":
|
||||
case "rom-slit-throat":
|
||||
if (triggeringSourceSkillId !== 490428) return null;
|
||||
return { kind: "status", recipient: "target", status: "silence", durationMs: 4_000, provenance: "source" };
|
||||
case "rom-sinister-methods": {
|
||||
if (![490323, 490311].includes(triggeringSourceSkillId)) return null;
|
||||
const criticalStrike = data.criticalStrike;
|
||||
const durationMs = data.durationMs;
|
||||
const cooldownMs = data.cooldownMs;
|
||||
if (typeof criticalStrike !== "number" || !Number.isFinite(criticalStrike)
|
||||
|| typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0
|
||||
|| typeof cooldownMs !== "number" || !Number.isFinite(cooldownMs) || cooldownMs <= 0) return null;
|
||||
return {
|
||||
kind: "aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-pair-sinister-methods-critical",
|
||||
name: "Sinister Methods",
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: criticalStrike }],
|
||||
},
|
||||
cooldownMs,
|
||||
provenance: "source",
|
||||
};
|
||||
}
|
||||
case "rom-accurate-targeting": {
|
||||
if (triggeringSourceSkillId !== 490423) return null;
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
||||
import { weaponDps, type WeaponStats } from "./itemStats";
|
||||
import { runewakerOrgPower } from "./runewakerCombatFormula";
|
||||
|
||||
export const ROM_WARDEN_BRIAR_SHIELD_TAG = "rom-briar-shield";
|
||||
export const ROM_WARDEN_WILL_ATTACK_TAG = "rom-will-attack";
|
||||
export const ROM_WARDEN_DAMAGE_TRANSFER_TAG = "rom-warden-damage-transfer";
|
||||
export const ROM_WARDEN_TOUGH_OAK_TAG = "rom-warden-tough-oak";
|
||||
export const ROM_WARDEN_OAK_BLESSING_TAG = "rom-warden-oak-blessing";
|
||||
export const ROM_WARDEN_DAMAGE_TRANSFER_FRACTION = 0.3;
|
||||
|
||||
/** RuneWaker component 503959's level-scaled physical rebound coefficient. */
|
||||
export function romWardenBriarShieldReflectFraction(characterLevel: number): number {
|
||||
return Math.abs(runewakerOrgPower(-0.01, characterLevel, 8));
|
||||
}
|
||||
|
||||
/** The local tooltip defines the rebound basis as main-hand weapon DPS. */
|
||||
export function romWardenBriarShieldReflectDamage(
|
||||
characterLevel: number,
|
||||
mainHandWeapon: WeaponStats | null,
|
||||
): number {
|
||||
return mainHandWeapon
|
||||
? Math.max(0, Math.round(weaponDps(mainHandWeapon) * romWardenBriarShieldReflectFraction(characterLevel)))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function romWardenBriarShieldShouldReflect(input: {
|
||||
readonly auraActive: boolean;
|
||||
readonly landed: boolean;
|
||||
readonly school: string;
|
||||
readonly attackKind: "melee" | "ranged" | "spell";
|
||||
readonly withinRange: boolean;
|
||||
}): boolean {
|
||||
return input.auraActive
|
||||
&& input.landed
|
||||
&& input.school === "physical"
|
||||
&& input.attackKind !== "spell"
|
||||
&& input.withinRange;
|
||||
}
|
||||
|
||||
const ROM_WARDEN_TOUGH_OAK_COOLDOWN_SPELLS = new Set([493382, 493385]);
|
||||
|
||||
const classPair = (ability: AbilityDefinition, primary: string, secondary: string): boolean => (
|
||||
ability.classId === primary && ability.secondaryClassId === secondary
|
||||
);
|
||||
@@ -41,6 +77,51 @@ export function romWardenCastEffects(
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker") return effects;
|
||||
|
||||
if (ability.classId === "rom-warden"
|
||||
&& ability.dbcSpellId !== undefined
|
||||
&& ROM_WARDEN_TOUGH_OAK_COOLDOWN_SPELLS.has(ability.dbcSpellId)
|
||||
&& passiveTags.includes(ROM_WARDEN_TOUGH_OAK_TAG)) {
|
||||
return [...effects, {
|
||||
kind: "cooldown-reset",
|
||||
mode: "reduce",
|
||||
abilityName: ability.name,
|
||||
amountMs: 1_000,
|
||||
}];
|
||||
}
|
||||
|
||||
if (ability.classId === "rom-warden"
|
||||
&& ability.dbcSpellId === 491668
|
||||
&& passiveTags.includes(ROM_WARDEN_OAK_BLESSING_TAG)) {
|
||||
// Warden/Mage's local Oak Blessing component 620679 is conditional on
|
||||
// Heart of the Oak: +5% physical attack and 1% maximum HP every 3s for
|
||||
// 60s. The generated passive itself is only a marker so it cannot apply
|
||||
// this rider outside the parent buff's lifetime.
|
||||
return [...effects,
|
||||
{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-warden-oak-blessing-attack",
|
||||
name: "Oak Blessing",
|
||||
disposition: "buff",
|
||||
durationMs: 60_000,
|
||||
modifiers: [{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.05 }],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 134,
|
||||
miscValue: 5,
|
||||
triggerSpellId: 491668,
|
||||
},
|
||||
{
|
||||
kind: "hot",
|
||||
coefficient: 0,
|
||||
percentMaxHealth: 0.01,
|
||||
ticks: 20,
|
||||
intervalMs: 3_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (ability.dbcSpellId === 493391 && ability.classId === "rom-warden") {
|
||||
// The source row includes Will Attack's component, but it belongs on the
|
||||
// target only when the Warden has learned that passive.
|
||||
@@ -108,7 +189,7 @@ export function romWardenCastEffects(
|
||||
if (naturePower < 1) return [heals[0]!];
|
||||
return [
|
||||
...heals.slice(0, 2),
|
||||
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
|
||||
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -128,7 +209,7 @@ export function romWardenCastEffects(
|
||||
coefficientPerLevel: 0,
|
||||
bonusPowerCoefficient: 0,
|
||||
},
|
||||
{ kind: "resource-drain", amount: points, burn: false, resource: "nature-power" },
|
||||
{ kind: "resource-drain", amount: points, burn: false, resource: "nature-power", recipient: "caster" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,15 @@ import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
|
||||
import {
|
||||
ROM_WARDEN_BRIAR_SHIELD_TAG,
|
||||
ROM_WARDEN_DAMAGE_TRANSFER_FRACTION,
|
||||
ROM_WARDEN_DAMAGE_TRANSFER_TAG,
|
||||
ROM_WARDEN_OAK_BLESSING_TAG,
|
||||
ROM_WARDEN_TOUGH_OAK_TAG,
|
||||
ROM_WARDEN_WILL_ATTACK_TAG,
|
||||
romWardenBriarShieldReflectDamage,
|
||||
romWardenBriarShieldReflectFraction,
|
||||
romWardenBriarShieldShouldReflect,
|
||||
} from "./romWardenOverrides";
|
||||
|
||||
const emptyContext: RomCastMechanics = {
|
||||
@@ -30,7 +36,7 @@ function effectsFor(spellId: number, context: Partial<RomCastMechanics> = {}) {
|
||||
|
||||
function initializeRomCharacter(
|
||||
classId: "rom-warden" | "rom-warrior" | "rom-druid",
|
||||
secondaryClassId?: "rom-warden" | "rom-warrior",
|
||||
secondaryClassId?: "rom-warden" | "rom-warrior" | "rom-mage",
|
||||
) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId,
|
||||
@@ -53,6 +59,140 @@ describe("RoM Warden and Warden-linked source behaviors", () => {
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("casts Briar Shield as its source-backed defense aura without an immediate damage hit", () => {
|
||||
initializeRomCharacter("rom-warden");
|
||||
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 493642)!;
|
||||
expect(ability.effects).toHaveLength(1);
|
||||
expect(ability.effects[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
durationMs: 900_000,
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }],
|
||||
utilityTags: expect.arrayContaining([ROM_WARDEN_BRIAR_SHIELD_TAG]),
|
||||
},
|
||||
});
|
||||
expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(false);
|
||||
|
||||
const mobId = "briar-shield-cast-target";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Briar Shield target", maxHealth: 100_000 });
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
const now = 10_000;
|
||||
expect(useCombatStore.getState().castAbility(ability.id, undefined, now)).toMatchObject({ ok: true });
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.mobs[mobId]?.health).toBe(100_000);
|
||||
expect(state.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARDEN_BRIAR_SHIELD_TAG))).toMatchObject({
|
||||
appliedAt: now,
|
||||
expiresAt: now + 900_000,
|
||||
definition: {
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Briar Shield's reflected component to source-level main-hand DPS", () => {
|
||||
const weapon = { damageMin: 100, damageMax: 120, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
expect(romWardenBriarShieldReflectFraction(80)).toBeCloseTo(0.074);
|
||||
expect(romWardenBriarShieldReflectFraction(105)).toBeCloseTo(0.094);
|
||||
expect(romWardenBriarShieldReflectDamage(80, weapon)).toBe(4);
|
||||
expect(romWardenBriarShieldReflectDamage(105, weapon)).toBe(5);
|
||||
expect(romWardenBriarShieldReflectDamage(80, null)).toBe(0);
|
||||
|
||||
const validPhysicalHit = {
|
||||
auraActive: true,
|
||||
landed: true,
|
||||
school: "physical",
|
||||
attackKind: "melee" as const,
|
||||
withinRange: true,
|
||||
};
|
||||
expect(romWardenBriarShieldShouldReflect(validPhysicalHit)).toBe(true);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, attackKind: "ranged" })).toBe(true);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, school: "fire" })).toBe(false);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, attackKind: "spell" })).toBe(false);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, landed: false })).toBe(false);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, auraActive: false })).toBe(false);
|
||||
expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, withinRange: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects landed physical projectiles, but not magic or attacks beyond 50 range", () => {
|
||||
const reflectedFrom = (school: "physical" | "fire", attackerDistance: number) => {
|
||||
clearMobRuntimeRegistry();
|
||||
initializeRomCharacter("rom-warden");
|
||||
const weapon = { damageMin: 100, damageMax: 120, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, meleeWeapon: weapon },
|
||||
}));
|
||||
const mobId = `briar-reflect-${school}-${attackerDistance}`;
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Briar Shield attacker",
|
||||
maxHealth: 100_000,
|
||||
level: 100,
|
||||
attacks: [{
|
||||
...DEFAULT_ENEMY_ATTACK,
|
||||
id: `briar-reflect-${school}`,
|
||||
delivery: "projectile",
|
||||
school,
|
||||
range: 100,
|
||||
initialCooldownMs: [0, 0],
|
||||
}],
|
||||
});
|
||||
setMobPosition(mobId, [attackerDistance, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 3_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().damageMob(mobId, 1, 3_000);
|
||||
const healthBeforeIncomingHit = useCombatStore.getState().mobs[mobId]!.health;
|
||||
const incomingDamage = useCombatStore.getState().advanceMobCombat(3_450, [0, 0, 0]);
|
||||
const healthAfterIncomingHit = useCombatStore.getState().mobs[mobId]!.health;
|
||||
return { incomingDamage, reflectedDamage: healthBeforeIncomingHit - healthAfterIncomingHit };
|
||||
};
|
||||
|
||||
const physicalHit = reflectedFrom("physical", 2);
|
||||
expect(physicalHit.incomingDamage).toBeGreaterThan(0);
|
||||
expect(physicalHit.reflectedDamage).toBe(4);
|
||||
const magicHit = reflectedFrom("fire", 2);
|
||||
expect(magicHit.incomingDamage).toBeGreaterThan(0);
|
||||
expect(magicHit.reflectedDamage).toBe(0);
|
||||
const outOfRangePhysicalHit = reflectedFrom("physical", 60);
|
||||
expect(outOfRangePhysicalHit.incomingDamage).toBeGreaterThan(0);
|
||||
expect(outOfRangePhysicalHit.reflectedDamage).toBe(0);
|
||||
});
|
||||
|
||||
it("uses Briar Shield's defense aura against physical enemy attacks", () => {
|
||||
const incomingDamage = (withBriarShield: boolean) => {
|
||||
clearMobRuntimeRegistry();
|
||||
initializeRomCharacter("rom-warden", "rom-warrior");
|
||||
const mobId = "briar-shield-defense-attacker";
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Briar Shield attacker",
|
||||
maxHealth: 100_000,
|
||||
level: 100,
|
||||
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "briar-shield-physical-swing", delivery: "projectile", initialCooldownMs: [0, 0] }],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
if (withBriarShield) {
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 3_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().damageMob(mobId, 1, 3_000);
|
||||
return useCombatStore.getState().advanceMobCombat(3_450, [0, 0, 0]);
|
||||
};
|
||||
|
||||
const withoutBriarShield = incomingDamage(false);
|
||||
const withBriarShield = incomingDamage(true);
|
||||
expect(withoutBriarShield).toBeGreaterThan(0);
|
||||
expect(withBriarShield).toBeGreaterThan(0);
|
||||
expect(withBriarShield).toBeLessThan(withoutBriarShield);
|
||||
});
|
||||
|
||||
it("gates Will Attack's 10% damage reduction on the passive and places it on Frantic Briar's target", () => {
|
||||
const passive = effectsFor(494610).find((effect) => effect.kind === "apply-aura");
|
||||
expect(passive?.kind).toBe("apply-aura");
|
||||
@@ -122,6 +262,66 @@ describe("RoM Warden and Warden-linked source behaviors", () => {
|
||||
expect(reducedDamage).toBeCloseTo(normalDamage * 0.9, 0);
|
||||
});
|
||||
|
||||
it("reduces Warden Punch and Sepal Stab cooldowns by one second with Tough Oak", () => {
|
||||
expect(effectsFor(493382).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
|
||||
initializeRomCharacter("rom-warden", "rom-mage");
|
||||
const state = useCombatStore.getState();
|
||||
const toughOak = state.abilities.find((ability) => ability.dbcSpellId === 494207);
|
||||
expect(toughOak).toMatchObject({ passive: true, secondaryClassId: "rom-mage" });
|
||||
expect(state.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARDEN_TOUGH_OAK_TAG))).toBe(true);
|
||||
|
||||
const mobId = "tough-oak-target";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Tough Oak target", maxHealth: 100_000 });
|
||||
setMobPosition(mobId, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-primary-2", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns["rom-warden-primary-2"]).toBe(5_000);
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-primary-14", undefined, 2_500).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns["rom-warden-primary-14"]).toBe(6_500);
|
||||
});
|
||||
|
||||
it("activates Oak Blessing's attack and periodic-healing riders only with Heart of the Oak", () => {
|
||||
initializeRomCharacter("rom-warden", "rom-mage");
|
||||
const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494011)!;
|
||||
expect(passive).toMatchObject({ passive: true, secondaryClassId: "rom-mage" });
|
||||
expect(passive.effects).toHaveLength(1);
|
||||
expect(passive.effects[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: { utilityTags: [ROM_WARDEN_OAK_BLESSING_TAG] },
|
||||
});
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARDEN_OAK_BLESSING_TAG))).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "attack-power"))).toBe(false);
|
||||
|
||||
const now = 10_000;
|
||||
expect(useCombatStore.getState().castAbility("rom-warden-primary-10", undefined, now)).toMatchObject({ ok: true });
|
||||
const stateAfterCast = useCombatStore.getState();
|
||||
expect(stateAfterCast.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "attack-power"))).toMatchObject({
|
||||
expiresAt: now + 60_000,
|
||||
definition: {
|
||||
modifiers: [{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.05 }],
|
||||
},
|
||||
});
|
||||
expect(stateAfterCast.effects.find((effect) => effect.kind === "hot"
|
||||
&& effect.sourceAbilityId === "rom-warden-primary-10")).toMatchObject({
|
||||
remainingTicks: 20,
|
||||
intervalMs: 3_000,
|
||||
nextTickAt: now + 3_000,
|
||||
percentMaxHealth: 0.01,
|
||||
});
|
||||
|
||||
const healthBefore = stateAfterCast.maxHealth - 200;
|
||||
useCombatStore.setState({ health: healthBefore });
|
||||
useCombatStore.getState().tick(3, now + 3_000);
|
||||
expect(useCombatStore.getState().health).toBe(healthBefore + Math.round(stateAfterCast.maxHealth * 0.01));
|
||||
});
|
||||
|
||||
it("keeps Nature Crystal active and makes its owner immune while blocking attack skills", () => {
|
||||
initializeRomCharacter("rom-warden");
|
||||
const now = Date.now();
|
||||
@@ -271,7 +471,7 @@ describe("RoM Warden and Warden-linked source behaviors", () => {
|
||||
expect(baseline.filter((effect) => effect.kind === "heal")).toHaveLength(1);
|
||||
expect(baseline.some((effect) => effect.kind === "resource-drain")).toBe(false);
|
||||
expect(empowered.filter((effect) => effect.kind === "heal")).toHaveLength(2);
|
||||
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
|
||||
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" });
|
||||
});
|
||||
|
||||
it("scales Gift Pulse by spent Nature Power and targets nearby party members", () => {
|
||||
@@ -281,7 +481,7 @@ describe("RoM Warden and Warden-linked source behaviors", () => {
|
||||
expect(empoweredHeal?.kind).toBe("heal");
|
||||
if (empoweredHeal?.kind === "heal") expect(empoweredHeal.percentMaxHealth).toBeCloseTo(0.3);
|
||||
expect(empowered.find((effect) => effect.kind === "resource-drain"))
|
||||
.toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power" });
|
||||
.toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power", recipient: "caster" });
|
||||
expect(effectsFor(499936, { naturePower: 20 }).find((effect) => effect.kind === "heal"))
|
||||
.toMatchObject({ percentMaxHealth: 1 });
|
||||
expect(ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499936))
|
||||
|
||||
@@ -147,9 +147,24 @@ export function romWarlockCastEffects(
|
||||
effects: readonly AbilityEffect[],
|
||||
targetTags: readonly string[],
|
||||
learnedPassiveSpellIds: readonly number[] = [],
|
||||
targetHealthPercent = 1,
|
||||
): readonly AbilityEffect[] {
|
||||
if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return effects;
|
||||
|
||||
if (ability.dbcSpellId === 498544) {
|
||||
return effects.flatMap((effect): AbilityEffect[] => {
|
||||
if (effect.kind !== "apply-aura" || effect.sourceAuraType !== 192 || effect.miscValue !== 5) {
|
||||
return [effect];
|
||||
}
|
||||
if (targetHealthPercent >= 0.2) return [];
|
||||
return [{
|
||||
...effect,
|
||||
recipient: "caster",
|
||||
aura: { ...effect.aura, disposition: "buff" },
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (ability.dbcSpellId === 498549
|
||||
&& learnedPassiveSpellIds.includes(498648)) {
|
||||
// Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second.
|
||||
|
||||
@@ -3,9 +3,14 @@ import type { AbilityEffect } from "./abilityCatalog";
|
||||
import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { cooldownResetCandidates } from "./cooldownReset";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
import {
|
||||
@@ -65,9 +70,130 @@ describe("RuneWaker Warlock runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("gives the caster Ruthless Judgment's source bonus only below 20% target health", () => {
|
||||
const ruthlessJudgment = ability(498544);
|
||||
const effectsFor = (targetHealthPercent: number) => romEffectsForCast(ruthlessJudgment, {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 10_000,
|
||||
targetHealthPercent,
|
||||
});
|
||||
const bonusAuras = (targetHealthPercent: number) => effectsFor(targetHealthPercent).filter((effect) => (
|
||||
effect.kind === "apply-aura" && effect.sourceAuraType === 192 && effect.miscValue === 5
|
||||
));
|
||||
const below = bonusAuras(0.199);
|
||||
expect(below).toHaveLength(1);
|
||||
expect(below[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
disposition: "buff",
|
||||
durationMs: 20_000,
|
||||
modifiers: [expect.objectContaining({
|
||||
kind: "damage",
|
||||
direction: "dealt",
|
||||
attackKind: "spell",
|
||||
operation: "percent",
|
||||
value: 0.05,
|
||||
})],
|
||||
},
|
||||
});
|
||||
expect(bonusAuras(0.2)).toHaveLength(0);
|
||||
expect(bonusAuras(0.8)).toHaveLength(0);
|
||||
|
||||
const sourceDamage = ruthlessJudgment.effects.find((effect) => effect.kind === "damage");
|
||||
expect(effectsFor(0.199).find((effect) => effect.kind === "damage")).toEqual(sourceDamage);
|
||||
expect(effectsFor(0.2).find((effect) => effect.kind === "damage")).toEqual(sourceDamage);
|
||||
});
|
||||
|
||||
it("applies Ruthless Judgment's low-health bonus to the player caster", () => {
|
||||
prepareWarlock("rom-priest");
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900);
|
||||
|
||||
const ruthlessJudgment = ability(498544);
|
||||
expect(useCombatStore.getState().castAbility(ruthlessJudgment.id, {
|
||||
targetActorId: TARGET_ID,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
const completesAt = useCombatStore.getState().activeCast?.completesAt;
|
||||
expect(completesAt).toBeDefined();
|
||||
finishCast(1_000);
|
||||
|
||||
const bonusAura = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.name.startsWith("Ruthless Judgment")
|
||||
));
|
||||
expect(bonusAura).toMatchObject({
|
||||
definition: {
|
||||
disposition: "buff",
|
||||
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })],
|
||||
},
|
||||
});
|
||||
expect(bonusAura?.expiresAt).toBe(completesAt! + 20_000);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment")
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("applies Ruthless Judgment's low-health bonus to a companion Warlock caster", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
||||
usePartyStore.getState().initializeParty("rom-warlock-ruthless-companion", 80,
|
||||
useCombatStore.getState().health, "damage", 2, "rom");
|
||||
const warlock = createConfiguredPartyMember({
|
||||
name: "Ruthless Companion",
|
||||
classId: "rom-warlock",
|
||||
secondaryClassId: "rom-priest",
|
||||
raceId: "rom-dwarf",
|
||||
specialization: "Warlock",
|
||||
role: "damage",
|
||||
}, 80, 0, "rom-warlock-ruthless-companion");
|
||||
const ruthlessJudgment = ability(498544);
|
||||
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest");
|
||||
usePartyStore.setState({
|
||||
command: "attack",
|
||||
members: [{
|
||||
...warlock,
|
||||
cooldowns: Object.fromEntries(pairAbilities
|
||||
.filter((candidate) => candidate.id !== ruthlessJudgment.id)
|
||||
.map((candidate) => [candidate.id, 100_000])),
|
||||
resourcePools: { ...warlock.resourcePools, focus: 100 },
|
||||
resource: 100,
|
||||
}],
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(warlock.id, [0, 0, 0]);
|
||||
registerTarget();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900);
|
||||
updatePartyRuntimeTargetVisibility(warlock.id, TARGET_ID, true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
advanceDungeonPartyCombat(3_100);
|
||||
|
||||
const bonusAura = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === warlock.id
|
||||
&& aura.targetId === warlock.id
|
||||
&& aura.definition.name.startsWith("Ruthless Judgment")
|
||||
));
|
||||
expect(bonusAura).toMatchObject({
|
||||
expiresAt: 23_100,
|
||||
definition: {
|
||||
disposition: "buff",
|
||||
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })],
|
||||
},
|
||||
});
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment")
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => {
|
||||
prepareWarlock("rom-warrior");
|
||||
useCombatStore.setState((state) => ({
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_WARRIOR_BRIAR_TECHNIQUE_AURA,
|
||||
ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG,
|
||||
ROM_WARRIOR_BRIAR_TECHNIQUE_TAG,
|
||||
} from "./romWarriorOverrides";
|
||||
|
||||
describe("RoM Warrior/Priest Defender's Roar party scope", () => {
|
||||
beforeEach(() => {
|
||||
@@ -112,3 +117,68 @@ describe("RoM Warrior/Priest Defender's Roar party scope", () => {
|
||||
)))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoM Druid/Warrior Briar Technique", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("stacks the local bonuses only after landed Natural Attack or Heart Piercing hits", () => {
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499955)!;
|
||||
const naturalAttack = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494016)!;
|
||||
const heartPiercing = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494216)!;
|
||||
expect(passive).toMatchObject({ passive: true, classId: "rom-druid", secondaryClassId: "rom-warrior" });
|
||||
expect(passive.effects[0]).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
aura: { utilityTags: [ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG] },
|
||||
});
|
||||
for (const ability of [naturalAttack, heartPiercing]) {
|
||||
expect(ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id.includes("-623080-"))).toBe(false);
|
||||
}
|
||||
expect(ROM_WARRIOR_BRIAR_TECHNIQUE_AURA).toMatchObject({
|
||||
durationMs: 15_000,
|
||||
maxStacks: 5,
|
||||
stackBehavior: "add",
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.03, perStack: true },
|
||||
{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.03, perStack: true },
|
||||
],
|
||||
});
|
||||
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-elf",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000 },
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000 },
|
||||
}));
|
||||
const mobId = "briar-technique-target";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Briar Technique target", level: 1, maxHealth: 1_000_000 });
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG))).toBe(true);
|
||||
|
||||
const casts = [
|
||||
[naturalAttack.id, 1_000, 1],
|
||||
[heartPiercing.id, 2_500, 2],
|
||||
[naturalAttack.id, 5_000, 3],
|
||||
[naturalAttack.id, 10_000, 4],
|
||||
[naturalAttack.id, 14_000, 5],
|
||||
[heartPiercing.id, 15_500, 5],
|
||||
] as const;
|
||||
for (const [abilityId, now, expectedStacks] of casts) {
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, now)).toMatchObject({ ok: true });
|
||||
const aura = useCombatStore.getState().auras.find((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& entry.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_TAG));
|
||||
expect(aura?.stacks).toBe(expectedStacks);
|
||||
expect(aura?.expiresAt).toBe(now + 15_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { AuraDefinition } from "./combatAuras";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
|
||||
export const ROM_WARRIOR_BRIAR_TECHNIQUE_TAG = "rom-druid-warrior-briar-technique";
|
||||
export const ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG = "rom-druid-warrior-briar-technique-learned";
|
||||
export const ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS = new Set([494016, 494216]);
|
||||
export const ROM_WARRIOR_BRIAR_TECHNIQUE_AURA: AuraDefinition = {
|
||||
id: "rom-druid-warrior-briar-technique",
|
||||
name: "Briar Technique",
|
||||
disposition: "buff",
|
||||
durationMs: 15_000,
|
||||
maxStacks: 5,
|
||||
stackBehavior: "add",
|
||||
utilityTags: [ROM_WARRIOR_BRIAR_TECHNIQUE_TAG],
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.03, perStack: true },
|
||||
{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.03, perStack: true },
|
||||
],
|
||||
};
|
||||
|
||||
type RomWarriorGeneratedRow = RomGeneratedAbilityLike & {
|
||||
readonly classId?: string;
|
||||
readonly secondaryClassId?: string;
|
||||
|
||||
@@ -27,6 +27,35 @@ describe("talent runtime modifiers", () => {
|
||||
expect(applyTalentModifierValue(100, maxRank, "damage-percent", { tags: ["cast-time", "holy"] })).toBe(105);
|
||||
});
|
||||
|
||||
it("keeps Arcane Potency's next-spell crit bonus out of passive modifiers", () => {
|
||||
for (const rank of [1, 2]) {
|
||||
const modifiers = talentModifiersForRanks({ "talent-1725": rank }, "mage");
|
||||
expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Molten Fury's target-health bonus out of unconditional scalar modifiers", () => {
|
||||
for (const rank of [1, 2]) {
|
||||
const modifiers = talentModifiersForRanks({ "talent-1732": rank }, "mage");
|
||||
expect(modifiers.filter((modifier) => modifier.kind === "damage-percent")).toEqual([]);
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
||||
abilityName: "Fireball",
|
||||
tags: ["damage", "fire"],
|
||||
})).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Master of Elements' passive crit modifier single-counted", () => {
|
||||
const modifiers = talentModifiersForRanks({ "talent-1639": 3 }, "mage");
|
||||
expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([
|
||||
expect.objectContaining({
|
||||
amount: 3,
|
||||
sourceTalentId: "talent-1639",
|
||||
sourceTalentName: "Master of Elements",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses common passive stat, cost, cast-time, cooldown, and mitigation categories", () => {
|
||||
expect(parseTalentModifiers("Increases your maximum health by 10%.")).toContainEqual(
|
||||
expect.objectContaining({ kind: "max-health-percent", amount: 10 }),
|
||||
@@ -67,6 +96,40 @@ describe("talent runtime modifiers", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("applies Spell Warding to direct and periodic magic damage, but not physical damage", () => {
|
||||
const spellWarding = parseTalentModifiers(
|
||||
"Reduces all direct and periodic magic damage taken by 3%.",
|
||||
).map((modifier) => ({
|
||||
...modifier,
|
||||
sourceTalentId: "test:spell-warding",
|
||||
sourceTalentName: "Spell Warding",
|
||||
rank: 3,
|
||||
description: "Reduces all direct and periodic magic damage taken by 3%.",
|
||||
}));
|
||||
const generatedMaxRank = talentModifiersForRanks({ "talent-411": 3 }, "priest");
|
||||
|
||||
expect(spellWarding).toContainEqual(expect.objectContaining({
|
||||
kind: "damage-taken-percent",
|
||||
amount: -3,
|
||||
scope: "all direct and periodic magic damage taken",
|
||||
}));
|
||||
expect(generatedMaxRank).toContainEqual(expect.objectContaining({
|
||||
kind: "damage-taken-percent",
|
||||
amount: -3,
|
||||
sourceTalentId: "talent-411",
|
||||
rank: 3,
|
||||
}));
|
||||
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
||||
tags: ["magic", "shadow"],
|
||||
})).toBe(97);
|
||||
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
||||
tags: ["magic", "fire", "periodic"],
|
||||
})).toBe(97);
|
||||
expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", {
|
||||
tags: ["physical"],
|
||||
})).toBe(100);
|
||||
});
|
||||
|
||||
it("scopes Improved Rejuvenation's effect bonus to Rejuvenation healing", () => {
|
||||
const improvedRejuvenation = TALENT_NODES.find((node) => node.id === "talent-830")!;
|
||||
const modifiers = talentModifiersForRanks({ [improvedRejuvenation.id]: 3 }, "druid");
|
||||
@@ -110,6 +173,32 @@ describe("talent runtime modifiers", () => {
|
||||
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
|
||||
});
|
||||
|
||||
it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => {
|
||||
const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
|
||||
|
||||
for (const rank of [1, 2, 3]) {
|
||||
const modifiers = talentModifiersForRanks({ [impact.id]: rank }, "mage");
|
||||
const expectedBonus = rank * 3;
|
||||
expect(modifiers).toContainEqual(expect.objectContaining({
|
||||
kind: "damage-percent",
|
||||
amount: expectedBonus,
|
||||
scope: "damage of your Fire Blast and Meteor abilities",
|
||||
}));
|
||||
for (const abilityName of ["Fire Blast", "Meteor"]) {
|
||||
expect(
|
||||
applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName, tags: ["fire"] }),
|
||||
`${abilityName} at Impact rank ${rank}`,
|
||||
).toBeCloseTo(100 + expectedBonus);
|
||||
}
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
||||
abilityName: "Fireball", tags: ["fire"],
|
||||
})).toBe(100);
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
||||
abilityName: "Frostbolt", tags: ["frost"],
|
||||
})).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps critical damage bonuses out of ordinary hit damage", () => {
|
||||
const modifiers = parseTalentModifiers("Increases the critical damage of your spells by 150%.")
|
||||
.map((modifier) => ({ ...modifier, sourceTalentId: "test", sourceTalentName: "Thunderous", rank: 1, description: "" }));
|
||||
@@ -166,4 +255,23 @@ describe("talent runtime modifiers", () => {
|
||||
tags: ["damage", "physical"],
|
||||
})).toBe(100);
|
||||
});
|
||||
|
||||
it("applies Mage Flame Throwing's generated flat range only to damaging spells", () => {
|
||||
for (const [rank, bonus] of [[1, 2], [2, 4], [3, 6]] as const) {
|
||||
const modifiers = talentModifiersForRanks({ "talent-28": rank }, "mage");
|
||||
expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", {
|
||||
abilityName: "Fireball",
|
||||
tags: ["cast-time", "damage", "fire"],
|
||||
})).toBe(30 + bonus);
|
||||
expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", {
|
||||
abilityName: "Flash Heal",
|
||||
tags: ["cast-time", "healing", "holy"],
|
||||
})).toBe(30);
|
||||
expect(applyTalentModifierValue(30, talentModifiersForRanks({ "talent-28": rank }, "priest"),
|
||||
"range-flat-yards", {
|
||||
abilityName: "Smite",
|
||||
tags: ["cast-time", "damage", "holy"],
|
||||
})).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
+119
-3
@@ -4,6 +4,9 @@ import {
|
||||
type RuntimeTalentRanks as TalentRanks,
|
||||
} from "./talentRuntimeCatalog";
|
||||
import { talentBalanceOverride } from "./balanceTuning";
|
||||
import { wow335MageFlameThrowingProfile } from "./wow335MageFlameThrowingRuntime";
|
||||
import { wow335DruidTalentModifierOverrides } from "./wow335DruidTalentRuntime";
|
||||
import { wow335ShamanConvectionModifiersForRank } from "./wow335ShamanTalentRuntime";
|
||||
|
||||
export type TalentModifierKind =
|
||||
| "damage-percent"
|
||||
@@ -21,6 +24,7 @@ export type TalentModifierKind =
|
||||
| "cooldown-percent"
|
||||
| "cooldown-flat-ms"
|
||||
| "armor-percent"
|
||||
| "range-flat-yards"
|
||||
| "attribute-percent"
|
||||
| "range-percent"
|
||||
| "duration-percent"
|
||||
@@ -30,7 +34,7 @@ export interface TalentModifier {
|
||||
readonly kind: TalentModifierKind;
|
||||
/** Signed amount: increases are positive and reductions are negative. */
|
||||
readonly amount: number;
|
||||
readonly unit: "percent" | "milliseconds";
|
||||
readonly unit: "percent" | "milliseconds" | "yards";
|
||||
readonly scope: string;
|
||||
readonly attribute?: "agility" | "intellect" | "spirit" | "stamina" | "strength";
|
||||
readonly sourceTalentId: string;
|
||||
@@ -43,6 +47,9 @@ interface ParsedModifier extends Omit<TalentModifier, "sourceTalentId" | "source
|
||||
|
||||
const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
"Inner Focus",
|
||||
// Arcane Potency exists only after Presence of Mind or Arcane Concentration
|
||||
// Clearcasting. The generated next-spell crit text is applied by its proc aura.
|
||||
"Arcane Potency",
|
||||
// These effects only exist after a proc or charge is earned; their tooltip
|
||||
// percentages must not be applied as passive stats while inactive.
|
||||
"Holy Concentration",
|
||||
@@ -55,10 +62,19 @@ const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
// Backlash has passive crit chance plus a conditional next-cast proc. Its
|
||||
// proc damage and cost clauses must not become always-on bonuses.
|
||||
"Backlash",
|
||||
// Backdraft's cast-time/GCD reduction applies only to its temporary three-cast proc.
|
||||
"Backdraft",
|
||||
// Brain Freeze mixes a conditional next Fireball/Frostfire Bolt proc with
|
||||
// separate school and target riders; the proc bonus is applied at cast time.
|
||||
"Brain Freeze",
|
||||
// Aura Mastery and Divine Favor carry scoped next-cast/aura effects. Their
|
||||
// activation tooltips must not be parsed as persistent global modifiers.
|
||||
"Aura Mastery",
|
||||
"Divine Favor",
|
||||
// Redoubt's Strength and damage-reduction clauses only exist during its
|
||||
// block-triggered ten-second proc; the shield block chance is handled by
|
||||
// the Paladin runtime with an equipped-shield check.
|
||||
"Redoubt",
|
||||
// The Beast Within activates from a pet Bestial Wrath proc; its tooltip
|
||||
// percentages must not become always-on modifiers.
|
||||
"The Beast Within",
|
||||
@@ -67,6 +83,19 @@ const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
"Elemental Focus",
|
||||
"Reverberation",
|
||||
"Maelstrom Weapon",
|
||||
// Tidal Waves only affects casts while its earned charge aura is active.
|
||||
"Tidal Waves",
|
||||
// Convection's comma-delimited named-spell list is represented by explicit
|
||||
// per-ability scopes so the generic parser cannot truncate it at Shock.
|
||||
"Convection",
|
||||
// Riptide's 25% rider only applies to the next Chain Heal on its HoT target.
|
||||
"Riptide",
|
||||
// Torment the Weak applies only to named damage spells and only against a
|
||||
// target that is currently slowed; its conditional part is target-aware.
|
||||
"Torment the Weak",
|
||||
// Feral Instinct has one named damage bonus plus two cast-state clauses;
|
||||
// only the named Swipe/Ravage clause is a persistent scalar modifier.
|
||||
"Feral Instinct",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -174,6 +203,42 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
|
||||
|
||||
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
|
||||
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
|
||||
if (classId === "druid" && active.talentId === "talent-799") {
|
||||
// Do not parse Tiger's Fury and Enrage's conditional bonuses as global
|
||||
// damage dealt/taken modifiers. Their temporary aura states are applied
|
||||
// by the Druid cast runtime.
|
||||
return wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description);
|
||||
}
|
||||
// Elemental Oath's generated rank text repeats one crit bonus for the
|
||||
// owner and the party. CombatStore applies the rank once to each spell
|
||||
// attack/heal source, so the broad scalar parser must not apply it again.
|
||||
if (classId === "shaman" && active.talentId === "talent-2049") return [];
|
||||
if (classId === "shaman" && active.talentId === "talent-564") {
|
||||
return wow335ShamanConvectionModifiersForRank(active.rank).map((modifier) => ({
|
||||
...modifier,
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}));
|
||||
}
|
||||
if (classId === "mage" && active.talentId === "talent-28") {
|
||||
const amount = wow335MageFlameThrowingProfile(active.rank).damagingSpellRangeBonusYards;
|
||||
return amount > 0 ? [{
|
||||
kind: "range-flat-yards" as const,
|
||||
amount,
|
||||
unit: "yards" as const,
|
||||
scope: "your damaging spells",
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}] : [];
|
||||
}
|
||||
// Molten Fury is active only while the individual target is below 35%
|
||||
// health. The target-health predicate is applied by the Mage damage path,
|
||||
// so keep its generated rank amount out of always-on scalar modifiers.
|
||||
if (classId === "mage" && active.talentId === "talent-1732") return [];
|
||||
if (active.talentName === "Backlash") {
|
||||
return [{
|
||||
kind: "critical-chance-percent" as const,
|
||||
@@ -186,6 +251,20 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
|
||||
description: active.description,
|
||||
}];
|
||||
}
|
||||
if (active.talentName === "Backdraft") {
|
||||
const shadowburnDamage = active.description.match(/increases the damage of your Shadowburn by (\d+(?:\.\d+)?)%/i);
|
||||
if (!shadowburnDamage) return [];
|
||||
return [{
|
||||
kind: "damage-percent" as const,
|
||||
amount: Number(shadowburnDamage[1]),
|
||||
unit: "percent" as const,
|
||||
scope: "damage of your Shadowburn",
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}];
|
||||
}
|
||||
if (talentUsesExplicitActivation(active.talentName)) return [];
|
||||
// Its healing and damage bonuses only apply below a target-health threshold;
|
||||
// the scalar parser cannot encode that condition. CombatStore applies the
|
||||
@@ -193,7 +272,28 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
|
||||
if (active.talentName === "Test of Faith") return [];
|
||||
const override = talentBalanceOverride(active.talentId);
|
||||
const scale = (override?.effectScalePercent ?? 100) / 100;
|
||||
const druidOverrides = classId === "druid"
|
||||
? wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description)
|
||||
: [];
|
||||
const overriddenKinds = new Set(druidOverrides.map((modifier) => modifier.kind));
|
||||
const parsed = parseTalentModifiers(active.description)
|
||||
.filter((modifier) => !overriddenKinds.has(modifier.kind))
|
||||
// Empowered Fire's generated tooltip names the three affected spells,
|
||||
// but this effect belongs to the caster. Combat applies it after
|
||||
// resolving the source actor so party Mage ranks cannot leak from the
|
||||
// player's scalar modifier list.
|
||||
.filter((modifier) => !(classId === "mage" && active.talentId === "talent-1734"
|
||||
&& modifier.kind === "damage-percent"))
|
||||
// Focused Will's healing-received rider only exists after taking a
|
||||
// critical hit. Preserve the rank's passive spell-crit modifier above,
|
||||
// while the conditional +3% healing stacks come from its runtime aura.
|
||||
.filter((modifier) => !(classId === "priest" && active.talentId === "talent-1858"
|
||||
&& modifier.kind === "healing-percent"))
|
||||
// Impact's passive bonus is restricted to Fire Blast and Meteor. The
|
||||
// generated tooltip names those abilities without saying "your", so the
|
||||
// general scope parser would otherwise treat the damage bonus as global.
|
||||
.filter((modifier) => !(classId === "mage" && active.talentId === "talent-30"
|
||||
&& modifier.kind === "damage-percent"))
|
||||
// Improved Shadow Bolt's 5% spell crit applies to the debuffed target;
|
||||
// only its named Shadow Bolt damage bonus is a persistent caster modifier.
|
||||
.filter((modifier) => active.talentName !== "Improved Shadow Bolt" || modifier.kind !== "critical-chance-percent")
|
||||
@@ -232,6 +332,19 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
|
||||
const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault"
|
||||
? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i)
|
||||
: null;
|
||||
const mageImpactDamage = classId === "mage" && active.talentId === "talent-30"
|
||||
? active.description.match(/increases the damage of Fire Blast and Meteor by (\d+(?:\.\d+)?)%/i)
|
||||
: null;
|
||||
const explicitMageImpact = mageImpactDamage ? [{
|
||||
kind: "damage-percent" as const,
|
||||
amount: Number(mageImpactDamage[1]) * scale,
|
||||
unit: "percent" as const,
|
||||
scope: "damage of your Fire Blast and Meteor abilities",
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
}] : [];
|
||||
const explicitScoped = unrelentingAssaultDamage ? [{
|
||||
kind: "damage-percent" as const,
|
||||
amount: Number(unrelentingAssaultDamage[1]) * scale,
|
||||
@@ -255,7 +368,7 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
|
||||
description: active.description,
|
||||
}];
|
||||
});
|
||||
return [...parsed, ...explicitScoped, ...supplemental];
|
||||
return [...parsed, ...explicitMageImpact, ...explicitScoped, ...druidOverrides, ...supplemental];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,8 +380,11 @@ export interface TalentModifierContext {
|
||||
function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean {
|
||||
const scope = modifier.scope.toLowerCase();
|
||||
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
|
||||
if (scope.includes("damaging spell") && !tags.has("damage")) return false;
|
||||
if (scope.includes("instant") && !tags.has("instant")) return false;
|
||||
if (scope.includes("periodic") && !tags.has("periodic")) return false;
|
||||
const coversDirectAndPeriodic = /\bdirect and periodic\b/.test(scope);
|
||||
if (scope.includes("periodic") && !coversDirectAndPeriodic && !tags.has("periodic")) return false;
|
||||
if (scope.includes("magic damage") && !tags.has("magic")) return false;
|
||||
const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const)
|
||||
.filter((school) => scope.includes(school));
|
||||
if (schools.length > 1) {
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
} from "./roguePoison";
|
||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import {
|
||||
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
|
||||
wow335WarlockMetamorphosisAbility,
|
||||
} from "./wow335WarlockMetamorphosisRuntime";
|
||||
import { WOW335_WARLOCK_SOUL_LINK_TAG } from "./wow335WarlockSoulLinkRuntime";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
||||
|
||||
@@ -542,6 +548,8 @@ function namedAuraForEffect(
|
||||
id: `${auraId}:holy-shield`,
|
||||
triggers: "block",
|
||||
chance: 1,
|
||||
// Holy Shield ranks 20925/20927/20928 carry procCharges=8 in local Spell.dbc.
|
||||
charges: 8,
|
||||
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
|
||||
});
|
||||
}
|
||||
@@ -646,8 +654,10 @@ function namedAuraForEffect(
|
||||
...(abilityName === "Divine Favor" ? ["wow335-divine-favor-next-holy-heal"] : []),
|
||||
...(abilityName === "Aura Mastery" ? ["wow335-aura-mastery-silence-interrupt", "wow335-aura-mastery-aura-effectiveness"] : []),
|
||||
];
|
||||
const controlImmunities = classId === "paladin" && abilityName === "Aura Mastery" && effect.miscValue === 3
|
||||
? ["silence"] as const
|
||||
const controlImmunities = classId === "paladin" && abilityName === "Aura Mastery"
|
||||
? effect.miscValue === 3 ? ["silence"] as const
|
||||
: effect.miscValue === 12 ? ["interrupt"] as const
|
||||
: undefined
|
||||
: controlImmunitiesForAura(effect.auraType, effect.miscValue);
|
||||
return {
|
||||
id: auraId,
|
||||
@@ -878,7 +888,10 @@ function classScriptEffects(
|
||||
maxScalingLevel: rank.maxScalingLevel,
|
||||
}] : [];
|
||||
}
|
||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||
case "warrior:Charge": return [
|
||||
{ kind: "movement", movement: "charge", toward: "target" },
|
||||
{ kind: "status", status: "stun", durationMs: 1_500 },
|
||||
];
|
||||
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||
case "warrior:Devastate": return [
|
||||
{
|
||||
@@ -969,15 +982,14 @@ function classScriptEffects(
|
||||
aura: {
|
||||
id: `wow335:${rank.spellId}:kill-command`,
|
||||
name: abilityName,
|
||||
description: "Increases your pet's special attack damage by 1% per remaining charge for 30 seconds. Each successful special attack reduces the bonus by 1%.",
|
||||
description: rank.description,
|
||||
disposition: "buff",
|
||||
durationMs: rank.durationMs,
|
||||
maxStacks: 60,
|
||||
initialStacks: 60,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: ["wow335-hunter-kill-command"],
|
||||
// The source applies this only to pet special attacks. The current
|
||||
// summon scheduler emits generic melee swings, so do not boost those.
|
||||
// The generated row omits the starting damage bonus; leave the pet
|
||||
// special-attack rider inert until that value and a special-hit event
|
||||
// are both represented by the runtime.
|
||||
},
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 4,
|
||||
@@ -993,6 +1005,43 @@ function classScriptEffects(
|
||||
triggerSpellId: 60202,
|
||||
durationMs: 30_000,
|
||||
}];
|
||||
case "hunter:Freezing Trap":
|
||||
case "hunter:Frost Trap": return [{
|
||||
kind: "utility",
|
||||
action: "class-script",
|
||||
effectType: 104,
|
||||
auraType: 0,
|
||||
miscValue: rank.effects.find((effect) => effect.effectType === 104)?.miscValue ?? 0,
|
||||
triggerSpellId: 0,
|
||||
durationMs: rank.durationMs,
|
||||
}];
|
||||
case "hunter:Immolation Trap": {
|
||||
const child = HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK[rank.spellId];
|
||||
return [
|
||||
{
|
||||
kind: "utility",
|
||||
action: "class-script",
|
||||
effectType: 105,
|
||||
auraType: 0,
|
||||
miscValue: rank.effects.find((effect) => effect.effectType === 105)?.miscValue ?? 0,
|
||||
triggerSpellId: 0,
|
||||
durationMs: rank.durationMs,
|
||||
},
|
||||
...(child ? [{
|
||||
kind: "dot" as const,
|
||||
coefficient: 0,
|
||||
basePoints: child.basePoints,
|
||||
dieSides: child.dieSides,
|
||||
rangedAttackPowerCoefficient: 0.02,
|
||||
sourceLevel: Math.max(1, rank.sourceLevel || rank.level),
|
||||
...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}),
|
||||
school: "fire" as const,
|
||||
ticks: 5,
|
||||
intervalMs: 3_000,
|
||||
tag: "hunter-immolation-trap",
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
case "hunter:Explosive Trap": {
|
||||
const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[rank.spellId];
|
||||
return child ? [{
|
||||
@@ -1433,6 +1482,26 @@ function classScriptEffects(
|
||||
mode: "percent",
|
||||
amount: rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? -50,
|
||||
}];
|
||||
// Soul Link's populated talent row provides the gameplay percentage and
|
||||
// demon restrictions; its active DBC spell row is only a dummy marker.
|
||||
case "warlock:Soul Link": return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "wow335:warlock:soul-link",
|
||||
name: abilityName,
|
||||
description: "Shares 6% of incoming damage with your active, controlled demon within 30 yards.",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [WOW335_WARLOCK_SOUL_LINK_TAG],
|
||||
},
|
||||
sourceEffectType: 3,
|
||||
sourceAuraType: 0,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case "warlock:Life Tap": return [{
|
||||
kind: "resource",
|
||||
resource: "mana",
|
||||
@@ -1573,6 +1642,20 @@ const HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK: Readonly<Record<number, {
|
||||
49067: { directBasePoints: 523, directDieSides: 149, periodicBasePoints: 90, periodicDieSides: 1, sourceLevel: 77, maxScalingLevel: 81 },
|
||||
};
|
||||
|
||||
const HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK: Readonly<Record<number, {
|
||||
basePoints: number;
|
||||
dieSides: number;
|
||||
}>> = {
|
||||
13795: { basePoints: 21, dieSides: 1 },
|
||||
14302: { basePoints: 43, dieSides: 1 },
|
||||
14303: { basePoints: 68, dieSides: 1 },
|
||||
14304: { basePoints: 102, dieSides: 1 },
|
||||
14305: { basePoints: 138, dieSides: 1 },
|
||||
27023: { basePoints: 197, dieSides: 1 },
|
||||
49055: { basePoints: 308, dieSides: 1 },
|
||||
49056: { basePoints: 377, dieSides: 1 },
|
||||
};
|
||||
|
||||
export function hunterExplosiveTrapDirectScaling(
|
||||
spellId: number,
|
||||
sourceLevel = 0,
|
||||
@@ -2162,7 +2245,11 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank;
|
||||
})
|
||||
: playerRanks;
|
||||
const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
||||
const soulLinkTalentTooltip = classId === "warlock" && chain.name === "Soul Link"
|
||||
? WOW335_TALENT_NODES.find((node) => node.id === chain.talentNodeId)?.rankDescriptions[0]
|
||||
: undefined;
|
||||
const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name))
|
||||
.map((rank) => soulLinkTalentTooltip ? { ...rank, description: soulLinkTalentTooltip } : rank);
|
||||
const first = ranks[0];
|
||||
const firstRaw = castRanks[0];
|
||||
if (!first || !firstRaw) return null;
|
||||
@@ -2254,6 +2341,7 @@ function abilityNameTarget(classId: BaseClassId, abilityName: string): AbilityTa
|
||||
if (abilityName === "Soulshatter") return "self";
|
||||
if (abilityName === "Desperate Prayer") return "self";
|
||||
if (abilityName === "Lightwell") return "self";
|
||||
if (classId === "warlock" && abilityName === "Soul Link") return "self";
|
||||
if (abilityName === "Dispel Magic") return "any";
|
||||
if (abilityName === "Holy Shock") return "any";
|
||||
if (abilityName === "Volley") return "hostile";
|
||||
@@ -2272,13 +2360,21 @@ function classicCooldownGroup(classId: BaseClassId, abilityName: string): string
|
||||
}
|
||||
|
||||
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
||||
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
|
||||
classId,
|
||||
data.classes[classId]
|
||||
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => {
|
||||
const generated = data.classes[classId]
|
||||
.map((chain) => abilityDefinition(classId, chain))
|
||||
.filter((ability): ability is AbilityDefinition => Boolean(ability))
|
||||
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)),
|
||||
]))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
|
||||
.filter((ability): ability is AbilityDefinition => Boolean(ability));
|
||||
const specialized = classId === "warlock"
|
||||
? [
|
||||
...generated.filter((ability) => ability.id !== "wow335-warlock-metamorphosis"),
|
||||
wow335WarlockMetamorphosisAbility(),
|
||||
...WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
|
||||
]
|
||||
: generated;
|
||||
return [classId, specialized.sort((left, right) => (
|
||||
left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)
|
||||
))];
|
||||
}))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
|
||||
|
||||
// These ground channels store their actual damage in same-name triggered
|
||||
// child spells. Resolve the child once so each channel pulse can use its
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { useCombatStore, PLAYER_AURA_ENTITY_ID, type CombatSummon } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { configureOnlineSessionRuntime, onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG,
|
||||
WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID,
|
||||
} from "./wow335DeathKnightMarkOfBloodRuntime";
|
||||
|
||||
let now = 1_000;
|
||||
let restoreClock: (() => void) | undefined;
|
||||
|
||||
function markAbility() {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 49005);
|
||||
if (!ability) throw new Error("Missing generated Death Knight Mark of Blood spell.");
|
||||
return ability;
|
||||
}
|
||||
|
||||
function registerAttacker(id: string) {
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
id: `${id}-strike`,
|
||||
name: "Strike",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
damageMultiplier: 0.01,
|
||||
}],
|
||||
});
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
}
|
||||
|
||||
function castMark() {
|
||||
const ability = markAbility();
|
||||
const result = useCombatStore.getState().castAbility(ability.id, { now });
|
||||
expect(result.ok).toBe(true);
|
||||
return ability;
|
||||
}
|
||||
|
||||
function remoteVictim(health = 9_000): OnlineSessionPlayer {
|
||||
return {
|
||||
accountId: "mark-remote-victim",
|
||||
username: "Mark victim",
|
||||
character: {
|
||||
id: "mark-remote-character",
|
||||
name: "Mark victim",
|
||||
classId: "warrior",
|
||||
categoryId: "wow",
|
||||
raceId: "human",
|
||||
gender: "female",
|
||||
level: 80,
|
||||
},
|
||||
role: "tank",
|
||||
position: [0, 0, 0],
|
||||
yaw: 0,
|
||||
grounded: true,
|
||||
health,
|
||||
maxHealth: 10_000,
|
||||
resource: 0,
|
||||
maxResource: 100,
|
||||
resourceName: "Rage",
|
||||
selectedTargetId: null,
|
||||
activeCast: null,
|
||||
animationEvent: null,
|
||||
equipment: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WoW Death Knight Mark of Blood combat integration", () => {
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
restoreClock = installCombatClock(() => now);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-death-knight-mark-of-blood", 80, useCombatStore.getState().health, "tank", 5,
|
||||
);
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
talentRanks: { ...state.talentRanks, "talent-1949": 1 },
|
||||
resource: 100,
|
||||
resourcePools: { "runic-power": 100 },
|
||||
}));
|
||||
registerAttacker("mark-target");
|
||||
useCombatStore.getState().selectMob("mark-target");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreClock?.();
|
||||
restoreClock = undefined;
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
});
|
||||
|
||||
it("casts the generated talent spell with twenty charges and a twenty-second duration", () => {
|
||||
castMark();
|
||||
const mark = useCombatStore.getState().auras.find((aura) => aura.targetId === "mark-target"
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
|
||||
expect(mark).toMatchObject({
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
stacks: 20,
|
||||
expiresAt: now + 20_000,
|
||||
});
|
||||
expect(mark?.definition.maxStacks).toBe(20);
|
||||
});
|
||||
|
||||
it("heals the player when the marked enemy lands an attack and consumes one charge", () => {
|
||||
castMark();
|
||||
const maximumHealth = useCombatStore.getState().maxHealth;
|
||||
const healthBefore = maximumHealth - 5_000;
|
||||
useCombatStore.setState({ health: healthBefore });
|
||||
useCombatStore.getState().damageMob("mark-target", 1, now);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === "mark-target"
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID);
|
||||
const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing"
|
||||
&& event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID);
|
||||
const mark = state.auras.find((aura) => aura.targetId === "mark-target"
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
|
||||
expect(damage?.effectiveAmount).toBeGreaterThan(0);
|
||||
expect(healing).toMatchObject({ rawAmount: Math.round(maximumHealth * 0.04) });
|
||||
expect(healing?.effectiveAmount).toBe(healing?.rawAmount);
|
||||
expect(state.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount);
|
||||
expect(mark?.stacks).toBe(19);
|
||||
});
|
||||
|
||||
it("heals a party companion when the marked enemy lands an attack on them", () => {
|
||||
const target = usePartyStore.getState().members[0]!;
|
||||
const maxHealth = target.maxHealth;
|
||||
const healthBefore = maxHealth - 1_000;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === target.id
|
||||
? { ...member, health: healthBefore }
|
||||
: member),
|
||||
}));
|
||||
registerPartyRuntimePosition(target.id, [0, 0, 0]);
|
||||
castMark();
|
||||
useCombatStore.getState().damageMob("mark-target", 50_000, now, {
|
||||
actorId: target.id,
|
||||
role: target.role ?? "damage",
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const memberAfter = usePartyStore.getState().members.find((member) => member.id === target.id)!;
|
||||
const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === "mark-target"
|
||||
&& event.targetActorId === target.id);
|
||||
const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing"
|
||||
&& event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID
|
||||
&& event.targetActorId === target.id);
|
||||
const mark = state.auras.find((aura) => aura.targetId === "mark-target"
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
|
||||
expect(damage?.effectiveAmount).toBeGreaterThan(0);
|
||||
expect(healing).toMatchObject({ rawAmount: Math.round(maxHealth * 0.04) });
|
||||
expect(healing?.effectiveAmount).toBe(healing?.rawAmount);
|
||||
expect(memberAfter.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount);
|
||||
expect(mark?.stacks).toBe(19);
|
||||
});
|
||||
|
||||
it("heals a summoned companion from the marked enemy's damage event", () => {
|
||||
const summon: CombatSummon = {
|
||||
id: "mark-summon-victim",
|
||||
sourceAbilityId: "test-summon",
|
||||
creatureId: 0,
|
||||
targetId: "mark-target",
|
||||
expiresAt: now + 60_000,
|
||||
kind: "summon",
|
||||
name: "Companion",
|
||||
ownerActorId: PLAYER_AURA_ENTITY_ID,
|
||||
health: 9_000,
|
||||
maxHealth: 10_000,
|
||||
position: [0, 0, 0],
|
||||
nextActionAt: 0,
|
||||
pulseIntervalMs: 1_000,
|
||||
attackDamage: 0,
|
||||
abilityIds: [],
|
||||
resources: {},
|
||||
cooldowns: {},
|
||||
globalCooldownEndsAt: 0,
|
||||
cast: null,
|
||||
controlledUntil: 0,
|
||||
schoolLockedUntil: {},
|
||||
};
|
||||
useCombatStore.setState({ summons: [summon] });
|
||||
castMark();
|
||||
useCombatStore.getState().damageMob("mark-target", 50_000, now, {
|
||||
actorId: summon.id,
|
||||
role: "tank",
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const summonAfter = state.summons.find((candidate) => candidate.id === summon.id)!;
|
||||
const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === "mark-target"
|
||||
&& event.targetActorId === summon.id);
|
||||
const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing"
|
||||
&& event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID
|
||||
&& event.targetActorId === summon.id);
|
||||
const mark = state.auras.find((aura) => aura.targetId === "mark-target"
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
|
||||
expect(damage?.effectiveAmount).toBeGreaterThan(0);
|
||||
expect(healing).toMatchObject({ rawAmount: 400 });
|
||||
expect(summonAfter.health).toBe(summon.health - damage!.effectiveAmount + healing!.effectiveAmount);
|
||||
expect(mark?.stacks).toBe(19);
|
||||
});
|
||||
|
||||
it("heals an online player from the marked enemy's damage event", () => {
|
||||
const victim = remoteVictim();
|
||||
const victimId = onlinePlayerActorId(victim.accountId);
|
||||
configureOnlineSessionRuntime({
|
||||
active: true,
|
||||
authority: true,
|
||||
localAccountId: "mark-local",
|
||||
players: [victim],
|
||||
});
|
||||
registerPartyRuntimePosition(victimId, victim.position);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
castMark();
|
||||
useCombatStore.getState().damageMob("mark-target", 50_000, now, {
|
||||
actorId: victimId,
|
||||
role: victim.role ?? "damage",
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const victimAfter = state.actors[victimId]!;
|
||||
const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === "mark-target"
|
||||
&& event.targetActorId === victimId);
|
||||
const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing"
|
||||
&& event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID
|
||||
&& event.targetActorId === victimId);
|
||||
const mark = state.auras.find((aura) => aura.targetId === "mark-target"
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
|
||||
expect(damage?.effectiveAmount).toBeGreaterThan(0);
|
||||
expect(healing).toMatchObject({ rawAmount: 400 });
|
||||
expect(victimAfter.health).toBe(victim.health - damage!.effectiveAmount + healing!.effectiveAmount);
|
||||
expect(mark?.stacks).toBe(19);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG,
|
||||
WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID,
|
||||
wow335DeathKnightMarkOfBloodAuraDefinition,
|
||||
wow335DeathKnightMarkOfBloodOnDamage,
|
||||
wow335DeathKnightMarkOfBloodProfile,
|
||||
} from "./wow335DeathKnightMarkOfBloodRuntime";
|
||||
|
||||
describe("WoW Death Knight Mark of Blood source runtime", () => {
|
||||
it("reads heal percentage, duration, and hit cap from generated talent-1949", () => {
|
||||
expect(wow335DeathKnightMarkOfBloodProfile()).toEqual({
|
||||
healingFraction: 0.04,
|
||||
durationMs: 20_000,
|
||||
hitCap: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("configures the DBC aura with the generated twenty-hit cap and duration", () => {
|
||||
const definition = wow335DeathKnightMarkOfBloodAuraDefinition({
|
||||
id: "wow335:death-knight:mark-of-blood",
|
||||
name: "Mark of Blood",
|
||||
disposition: "debuff",
|
||||
durationMs: 0,
|
||||
});
|
||||
const applied = applyAura([], {
|
||||
definition,
|
||||
sourceId: "dk-player",
|
||||
targetId: "marked-enemy",
|
||||
now: 1_000,
|
||||
}).aura;
|
||||
|
||||
expect(applied.definition.durationMs).toBe(20_000);
|
||||
expect(applied.expiresAt).toBe(21_000);
|
||||
expect(applied.definition.maxStacks).toBe(20);
|
||||
expect(applied.stacks).toBe(20);
|
||||
expect(applied.definition.utilityTags).toContain(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG);
|
||||
});
|
||||
|
||||
it("heals for four percent of max health and consumes only landed-hit charges", () => {
|
||||
const mark = applyAura([], {
|
||||
definition: wow335DeathKnightMarkOfBloodAuraDefinition({
|
||||
id: "wow335:death-knight:mark-of-blood",
|
||||
name: "Mark of Blood",
|
||||
disposition: "debuff",
|
||||
durationMs: 20_000,
|
||||
}),
|
||||
sourceId: "dk-player",
|
||||
targetId: "marked-enemy",
|
||||
now: 1_000,
|
||||
}).aura;
|
||||
|
||||
const miss = wow335DeathKnightMarkOfBloodOnDamage([mark], "marked-enemy", 9_000, 10_000, 2_000, false);
|
||||
expect(miss.triggered).toBe(false);
|
||||
expect(miss.auras).toEqual([mark]);
|
||||
|
||||
const hit = wow335DeathKnightMarkOfBloodOnDamage([mark], "marked-enemy", 9_000, 10_000, 2_000, true);
|
||||
expect(hit).toMatchObject({
|
||||
triggered: true,
|
||||
healingAmount: 400,
|
||||
sourceActorId: "dk-player",
|
||||
abilityId: WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID,
|
||||
remainingHits: 19,
|
||||
});
|
||||
expect(hit.auras[0]?.stacks).toBe(19);
|
||||
});
|
||||
|
||||
it("removes the mark after its last hit, ignores expired marks, and consumes lethal-hit charges without reviving", () => {
|
||||
const oneCharge = applyAura([], {
|
||||
definition: {
|
||||
...wow335DeathKnightMarkOfBloodAuraDefinition({
|
||||
id: "wow335:death-knight:mark-of-blood",
|
||||
name: "Mark of Blood",
|
||||
disposition: "debuff",
|
||||
durationMs: 20_000,
|
||||
}),
|
||||
initialStacks: 1,
|
||||
},
|
||||
sourceId: "dk-player",
|
||||
targetId: "marked-enemy",
|
||||
now: 1_000,
|
||||
stacks: 1,
|
||||
}).aura;
|
||||
|
||||
const finalHit = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 4_000, 5_000, 2_000, true);
|
||||
expect(finalHit.healingAmount).toBe(200);
|
||||
expect(finalHit.remainingHits).toBe(0);
|
||||
expect(finalHit.auras).toEqual([]);
|
||||
|
||||
const expired = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 4_000, 5_000, 21_000, true);
|
||||
expect(expired.triggered).toBe(false);
|
||||
expect(expired.auras).toEqual([oneCharge]);
|
||||
|
||||
const lethal = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 0, 5_000, 2_000, true);
|
||||
expect(lethal).toMatchObject({ triggered: true, healingAmount: 0, remainingHits: 0 });
|
||||
expect(lethal.auras).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ActiveAura, AuraDefinition } from "./combatAuras";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG = "wow335-death-knight-mark-of-blood";
|
||||
export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID = 49005;
|
||||
export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID = "wow335-death-knight-mark-of-blood";
|
||||
|
||||
export interface Wow335DeathKnightMarkOfBloodProfile {
|
||||
readonly healingFraction: number;
|
||||
readonly durationMs: number;
|
||||
readonly hitCap: number;
|
||||
}
|
||||
|
||||
export interface Wow335DeathKnightMarkOfBloodDamageResult {
|
||||
readonly auras: readonly ActiveAura[];
|
||||
readonly triggered: boolean;
|
||||
readonly healingAmount: number;
|
||||
readonly sourceActorId?: string;
|
||||
readonly abilityId?: string;
|
||||
readonly remainingHits?: number;
|
||||
}
|
||||
|
||||
const MARK_OF_BLOOD_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1949");
|
||||
|
||||
/** Read Mark of Blood's heal, duration, and charge cap from the local generated rank. */
|
||||
export function wow335DeathKnightMarkOfBloodProfile(rank = 1): Wow335DeathKnightMarkOfBloodProfile | null {
|
||||
const normalizedRank = Math.trunc(rank);
|
||||
const description = MARK_OF_BLOOD_TALENT?.rankDescriptions[normalizedRank - 1];
|
||||
const rankEffect = MARK_OF_BLOOD_TALENT?.rankEffects[normalizedRank - 1];
|
||||
if (!description || !rankEffect) return null;
|
||||
|
||||
const healPercent = /healed for\s+(\d+(?:\.\d+)?)% of its maximum health/i.exec(description);
|
||||
const durationSeconds = /Lasts for\s+(\d+(?:\.\d+)?) seconds/i.exec(description);
|
||||
const healingFraction = healPercent ? Number(healPercent[1]) / 100 : 0;
|
||||
const durationMs = durationSeconds ? Math.round(Number(durationSeconds[1]) * 1_000) : 0;
|
||||
const hitCap = Math.floor(rankEffect.procCharges ?? 0);
|
||||
if (!(healingFraction > 0) || !(durationMs > 0) || !(hitCap > 0)) return null;
|
||||
|
||||
return { healingFraction, durationMs, hitCap };
|
||||
}
|
||||
|
||||
/** Attach the generated Mark of Blood hit count and trigger tag to its DBC aura. */
|
||||
export function wow335DeathKnightMarkOfBloodAuraDefinition(
|
||||
definition: AuraDefinition,
|
||||
): AuraDefinition {
|
||||
const profile = wow335DeathKnightMarkOfBloodProfile();
|
||||
if (!profile) return definition;
|
||||
return {
|
||||
...definition,
|
||||
durationMs: profile.durationMs,
|
||||
maxStacks: profile.hitCap,
|
||||
initialStacks: profile.hitCap,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [...new Set([...(definition.utilityTags ?? []), WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG])],
|
||||
};
|
||||
}
|
||||
|
||||
/** Consume one Mark charge for landed damage; only surviving targets can receive the heal. */
|
||||
export function wow335DeathKnightMarkOfBloodOnDamage(
|
||||
auras: readonly ActiveAura[],
|
||||
markedEnemyId: string,
|
||||
targetHealthAfterDamage: number,
|
||||
targetMaxHealth: number,
|
||||
now: number,
|
||||
landedDamage: boolean,
|
||||
): Wow335DeathKnightMarkOfBloodDamageResult {
|
||||
if (!landedDamage || !(targetMaxHealth > 0)) {
|
||||
return { auras, triggered: false, healingAmount: 0 };
|
||||
}
|
||||
const mark = auras.find((aura) => aura.targetId === markedEnemyId
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG));
|
||||
const profile = wow335DeathKnightMarkOfBloodProfile();
|
||||
if (!mark || !profile || mark.stacks <= 0) {
|
||||
return { auras, triggered: false, healingAmount: 0 };
|
||||
}
|
||||
|
||||
const remainingHits = mark.stacks - 1;
|
||||
const nextAuras = remainingHits > 0
|
||||
? auras.map((aura) => aura.instanceId === mark.instanceId
|
||||
? { ...aura, stacks: remainingHits }
|
||||
: aura)
|
||||
: auras.filter((aura) => aura.instanceId !== mark.instanceId);
|
||||
return {
|
||||
auras: nextAuras,
|
||||
triggered: true,
|
||||
healingAmount: targetHealthAfterDamage > 0
|
||||
? Math.round(targetMaxHealth * profile.healingFraction)
|
||||
: 0,
|
||||
sourceActorId: mark.sourceId,
|
||||
abilityId: WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID,
|
||||
remainingHits,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_RIME_READY_TAG,
|
||||
wow335DeathKnightRimeProcChance,
|
||||
} from "./wow335DeathKnightRimeRuntime";
|
||||
|
||||
let restoreClock: (() => void) | undefined;
|
||||
|
||||
function ability(name: string) {
|
||||
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
if (!found) throw new Error(`Missing Death Knight ability: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("WoW Death Knight Rime", () => {
|
||||
beforeEach(() => {
|
||||
restoreClock = installCombatClock(() => 1_000);
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-death-knight-rime", 80, useCombatStore.getState().health, "tank", 5,
|
||||
);
|
||||
useCombatStore.setState({
|
||||
resource: 100,
|
||||
resourcePools: { "runic-power": 100 },
|
||||
talentRanks: { "talent-1989": 1, "talent-1992": 3 },
|
||||
});
|
||||
useCombatStore.getState().registerMob("rime-target", {
|
||||
name: "Rime target", maxHealth: 1_000_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("rime-target", [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob("rime-target");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreClock?.();
|
||||
restoreClock = undefined;
|
||||
});
|
||||
|
||||
it("uses generated rank proc chances and makes the next Howling Blast cooldown- and rune-free", () => {
|
||||
expect([0, 1, 2, 3].map(wow335DeathKnightRimeProcChance)).toEqual([0, 0.05, 0.1, 0.15]);
|
||||
expect(talentModifiersForRanks({ "talent-1992": 3 }, "death-knight")).toContainEqual(
|
||||
expect.objectContaining({ kind: "critical-chance-percent", amount: 15 }),
|
||||
);
|
||||
|
||||
const obliterate = ability("Obliterate");
|
||||
const howlingBlast = ability("Howling Blast");
|
||||
let procAt = 0;
|
||||
for (let attempt = 0; attempt < 100 && procAt === 0; attempt += 1) {
|
||||
const now = 1_000 + attempt * 2_000;
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
cooldowns: { ...state.cooldowns, [howlingBlast.id]: now + 8_000 },
|
||||
}));
|
||||
const result = useCombatStore.getState().castAbility(obliterate.id, { now });
|
||||
const rimeReady = useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG)
|
||||
));
|
||||
if (result.ok && rimeReady) procAt = now;
|
||||
}
|
||||
|
||||
expect(procAt).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().cooldowns[howlingBlast.id]).toBeLessThanOrEqual(procAt);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: procAt + 1_500,
|
||||
deathKnightRunes: state.deathKnightRunes.map((rune) => ({
|
||||
...rune,
|
||||
readyAt: procAt + 60_000,
|
||||
})),
|
||||
}));
|
||||
const runesBefore = useCombatStore.getState().deathKnightRunes;
|
||||
const freeCast = useCombatStore.getState().castAbility(howlingBlast.id, { now: procAt + 2_000 });
|
||||
|
||||
expect(freeCast.ok).toBe(true);
|
||||
expect(freeCast.damage).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().deathKnightRunes).toEqual(runesBefore);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG)
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not waive Howling Blast runes without an earned Rime proc", () => {
|
||||
const howlingBlast = ability("Howling Blast");
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: state.deathKnightRunes.map((rune) => ({ ...rune, readyAt: 60_000 })),
|
||||
}));
|
||||
|
||||
const result = useCombatStore.getState().castAbility(howlingBlast.id, { now: 2_000 });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: "not-enough-runes" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
export const WOW335_DEATH_KNIGHT_RIME_READY_TAG = "wow335-death-knight-rime-ready";
|
||||
|
||||
const RIME_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1992");
|
||||
|
||||
/** Return the locally generated rank chance for Rime's Obliterate proc. */
|
||||
export function wow335DeathKnightRimeProcChance(rank: number): number {
|
||||
const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank)));
|
||||
if (normalizedRank <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, (RIME_TALENT?.rankEffects[normalizedRank - 1]?.procChance ?? 0) / 100));
|
||||
}
|
||||
|
||||
export function wow335DeathKnightRimeProcSucceeds(rank: number, roll: number): boolean {
|
||||
const chance = wow335DeathKnightRimeProcChance(rank);
|
||||
return chance > 0 && Number.isFinite(roll) && roll >= 0 && roll < chance;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { abilitiesForCharacter } from "./abilityCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG,
|
||||
WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID,
|
||||
} from "./wow335DeathKnightScentOfBloodRuntime";
|
||||
|
||||
const TARGET_ID = "scent-of-blood-target";
|
||||
const PARTY_MEMBER_SEED = "scent-of-blood-party-member";
|
||||
|
||||
let now = 1_000;
|
||||
let restoreClock: (() => void) | undefined;
|
||||
|
||||
function registerTarget() {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Scent of Blood target",
|
||||
maxHealth: 1_000_000_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
id: "scent-of-blood-incoming-strike",
|
||||
name: "Strike",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
damageMultiplier: 0.00001,
|
||||
}],
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function procIncomingForPlayer(): boolean {
|
||||
const player = useCombatStore.getState();
|
||||
player.damageMob(TARGET_ID, 1, now);
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
now = 1_450 + attempt * 1_000;
|
||||
useCombatStore.setState((state) => ({ health: state.maxHealth }));
|
||||
useCombatStore.getState().advanceMobCombat(now, [0, 0, 0]);
|
||||
if (useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function partyDeathKnight() {
|
||||
const base = createConfiguredPartyMember({
|
||||
name: "Scent of Blood DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
talentTreeId: "death-knight-blood",
|
||||
role: "tank",
|
||||
}, 80, 0, PARTY_MEMBER_SEED);
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const cooldowns = Object.fromEntries(abilities.map((ability) => [ability.id, now + 60_000]));
|
||||
return {
|
||||
...base,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
talentRanks: { [WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID]: 3 },
|
||||
resourceType: "runic-power" as const,
|
||||
resourceName: "Runic Power",
|
||||
resource: 0,
|
||||
maxResource: 100,
|
||||
resourcePools: { "runic-power": 0 },
|
||||
runes: createDeathKnightRunes(now),
|
||||
cooldowns,
|
||||
globalCooldownEndsAt: now + 60_000,
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
nextActionAt: now + 60_000,
|
||||
status: "attacking" as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WoW Death Knight Scent of Blood combat integration", () => {
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
restoreClock = installCombatClock(() => now);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-death-knight-scent-of-blood", 80, useCombatStore.getState().health, "tank", 1,
|
||||
);
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
talentRanks: {
|
||||
...state.talentRanks,
|
||||
[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID]: 3,
|
||||
},
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, "runic-power": 0 },
|
||||
}));
|
||||
registerTarget();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreClock?.();
|
||||
restoreClock = undefined;
|
||||
});
|
||||
|
||||
it("arms from a real incoming enemy hit and grants 10 Runic Power on the next landed player melee hit", () => {
|
||||
expect(procIncomingForPlayer()).toBe(true);
|
||||
const auraBefore = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG));
|
||||
expect(auraBefore?.stacks).toBe(3);
|
||||
|
||||
let landed = false;
|
||||
for (let attempt = 0; attempt < 20 && !landed; attempt += 1) {
|
||||
now += 100;
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 95,
|
||||
resourcePools: { ...state.resourcePools, "runic-power": 95 },
|
||||
}));
|
||||
const hit = useCombatStore.getState().damageMob(TARGET_ID, 1_000, now, {
|
||||
actorId: PLAYER_AURA_ENTITY_ID,
|
||||
role: "tank",
|
||||
abilityId: "scent-test-melee-hit",
|
||||
}, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: `scent-test-melee-hit-${attempt}`,
|
||||
abilityId: "scent-test-melee-hit",
|
||||
});
|
||||
landed = hit.combatResult?.landed === true;
|
||||
}
|
||||
|
||||
expect(landed).toBe(true);
|
||||
const state = useCombatStore.getState();
|
||||
const scentAura = state.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG));
|
||||
expect(scentAura?.stacks).toBe(2);
|
||||
expect(state.resourcePools["runic-power"]).toBe(100);
|
||||
expect(state.combatEvents.at(-1)).toMatchObject({
|
||||
kind: "resource",
|
||||
abilityId: "scent-test-melee-hit",
|
||||
rawAmount: 10,
|
||||
effectiveAmount: 5,
|
||||
overheal: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("arms a party Death Knight from an incoming attack and consumes a charge on its next landed auto-attack", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80, xp: 0 });
|
||||
registerTarget();
|
||||
const member = partyDeathKnight();
|
||||
usePartyStore.setState((state) => ({
|
||||
command: "attack",
|
||||
activeMobId: TARGET_ID,
|
||||
members: [member],
|
||||
}));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, now, {
|
||||
actorId: member.id,
|
||||
role: "tank",
|
||||
abilityId: "party-threat-setup",
|
||||
});
|
||||
|
||||
let armed = false;
|
||||
for (let attempt = 0; attempt < 500 && !armed; attempt += 1) {
|
||||
now = 1_450 + attempt * 1_000;
|
||||
useCombatStore.getState().advanceMobCombat(now, [100, 0, 0]);
|
||||
armed = useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG));
|
||||
}
|
||||
expect(armed).toBe(true);
|
||||
|
||||
let consumed = false;
|
||||
for (let attempt = 0; attempt < 20 && !consumed; attempt += 1) {
|
||||
now += 2_000;
|
||||
const currentMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((candidate) => candidate.id === member.id
|
||||
? { ...currentMember, globalCooldownEndsAt: now + 60_000, nextActionAt: now + 60_000 }
|
||||
: candidate),
|
||||
}));
|
||||
advanceDungeonPartyCombat(now);
|
||||
consumed = useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "resource"
|
||||
&& event.sourceActorId === member.id
|
||||
&& event.targetActorId === member.id
|
||||
&& event.abilityId === "party-auto-attack"
|
||||
&& event.effectiveAmount > 0
|
||||
&& event.occurredAt === now
|
||||
));
|
||||
}
|
||||
expect(consumed).toBe(true);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG))?.stacks).toBe(2);
|
||||
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.resourcePools[
|
||||
"runic-power"
|
||||
]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG,
|
||||
wow335DeathKnightScentOfBloodOnIncomingEvent,
|
||||
wow335DeathKnightScentOfBloodOnMeleeHit,
|
||||
wow335DeathKnightScentOfBloodProfile,
|
||||
} from "./wow335DeathKnightScentOfBloodRuntime";
|
||||
|
||||
describe("WoW Death Knight Scent of Blood source runtime", () => {
|
||||
it("reads the generated 15% proc, rank hit counts, and 10 Runic Power per hit", () => {
|
||||
expect([1, 2, 3].map(wow335DeathKnightScentOfBloodProfile)).toEqual([
|
||||
{ procChance: 0.15, meleeHitCharges: 1, runicPowerPerHit: 10 },
|
||||
{ procChance: 0.15, meleeHitCharges: 2, runicPowerPerHit: 10 },
|
||||
{ procChance: 0.15, meleeHitCharges: 3, runicPowerPerHit: 10 },
|
||||
]);
|
||||
expect(wow335DeathKnightScentOfBloodProfile(0)).toBeNull();
|
||||
});
|
||||
|
||||
it("procs at the generated chance only after damage taken, a dodge, or a parry", () => {
|
||||
for (const trigger of ["damage-taken", "dodge", "parry"] as const) {
|
||||
const proc = wow335DeathKnightScentOfBloodOnIncomingEvent(
|
||||
[], "dk-1", 3, trigger, 0.1499, 1_000, `event-${trigger}`,
|
||||
);
|
||||
expect(proc.triggered).toBe(true);
|
||||
expect(proc.chargesGranted).toBe(3);
|
||||
expect(proc.auras[0]?.definition.utilityTags).toContain(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG);
|
||||
expect(proc.auras[0]).toMatchObject({
|
||||
sourceId: "dk-1",
|
||||
targetId: "dk-1",
|
||||
stacks: 3,
|
||||
expiresAt: 21_000,
|
||||
});
|
||||
}
|
||||
expect(wow335DeathKnightScentOfBloodOnIncomingEvent(
|
||||
[], "dk-1", 3, "damage-taken", 0.15, 1_000, "outside-chance",
|
||||
).triggered).toBe(false);
|
||||
expect(wow335DeathKnightScentOfBloodOnIncomingEvent(
|
||||
[], "dk-1", 3, null, 0, 1_000, "unrelated-event",
|
||||
).triggered).toBe(false);
|
||||
});
|
||||
|
||||
it("spends one charge per landed melee hit and grants no Runic Power for misses or expired charges", () => {
|
||||
const armed = wow335DeathKnightScentOfBloodOnIncomingEvent(
|
||||
[], "dk-1", 2, "parry", 0, 1_000, "parry-event",
|
||||
).auras;
|
||||
const miss = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, false, 2_000);
|
||||
expect(miss).toEqual({ auras: armed, runicPowerGain: 0, remainingCharges: 0 });
|
||||
|
||||
const first = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, true, 2_000);
|
||||
expect(first).toMatchObject({ runicPowerGain: 10, remainingCharges: 1 });
|
||||
expect(first.auras[0]?.stacks).toBe(1);
|
||||
const last = wow335DeathKnightScentOfBloodOnMeleeHit(first.auras, "dk-1", 2, true, 2_500);
|
||||
expect(last).toMatchObject({ runicPowerGain: 10, remainingCharges: 0 });
|
||||
expect(last.auras).toEqual([]);
|
||||
|
||||
const expired = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, true, 21_001);
|
||||
expect(expired).toMatchObject({ runicPowerGain: 0, remainingCharges: 0 });
|
||||
|
||||
const unlearned = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 0, true, 2_000);
|
||||
expect(unlearned.runicPowerGain).toBe(0);
|
||||
expect(unlearned.auras).toEqual(armed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { applyAura, type ActiveAura, type AuraDefinition } from "./combatAuras";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
export const WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG = "wow335-death-knight-scent-of-blood";
|
||||
export const WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID = "talent-1948";
|
||||
|
||||
export type Wow335DeathKnightScentOfBloodTrigger = "damage-taken" | "dodge" | "parry";
|
||||
|
||||
export interface Wow335DeathKnightScentOfBloodProfile {
|
||||
readonly procChance: number;
|
||||
readonly meleeHitCharges: number;
|
||||
readonly runicPowerPerHit: number;
|
||||
}
|
||||
|
||||
export interface Wow335DeathKnightScentOfBloodProcResult {
|
||||
readonly auras: readonly ActiveAura[];
|
||||
readonly triggered: boolean;
|
||||
readonly chargesGranted: number;
|
||||
}
|
||||
|
||||
export interface Wow335DeathKnightScentOfBloodHitResult {
|
||||
readonly auras: readonly ActiveAura[];
|
||||
readonly runicPowerGain: number;
|
||||
readonly remainingCharges: number;
|
||||
}
|
||||
|
||||
const SCENT_OF_BLOOD_TALENT = WOW335_TALENT_NODES.find(
|
||||
(node) => node.id === WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID,
|
||||
);
|
||||
|
||||
/** Read proc chance, next-hit count, and Runic Power from generated WoW rank rows. */
|
||||
export function wow335DeathKnightScentOfBloodProfile(rank: number): Wow335DeathKnightScentOfBloodProfile | null {
|
||||
const normalizedRank = Math.trunc(rank);
|
||||
const rankEffect = SCENT_OF_BLOOD_TALENT?.rankEffects[normalizedRank - 1];
|
||||
const description = SCENT_OF_BLOOD_TALENT?.rankDescriptions[normalizedRank - 1];
|
||||
if (!rankEffect || !description) return null;
|
||||
|
||||
const meleeHits = /causing your next(?:\s+(\d+))?\s+melee hit/i.exec(description);
|
||||
const runicPower = /generate\s+(\d+)\s+runic power/i.exec(description);
|
||||
const procChance = rankEffect.procChance / 100;
|
||||
const meleeHitCharges = Number(meleeHits?.[1] ?? 1);
|
||||
const runicPowerPerHit = Number(runicPower?.[1] ?? 0);
|
||||
if (!(procChance > 0) || !(meleeHitCharges > 0) || !(runicPowerPerHit > 0)) return null;
|
||||
return { procChance, meleeHitCharges, runicPowerPerHit };
|
||||
}
|
||||
|
||||
/** Install one generated rank's charge batch after its exact source trigger. */
|
||||
export function wow335DeathKnightScentOfBloodOnIncomingEvent(
|
||||
auras: readonly ActiveAura[],
|
||||
sourceActorId: string,
|
||||
rank: number,
|
||||
trigger: Wow335DeathKnightScentOfBloodTrigger | null,
|
||||
roll: number,
|
||||
now: number,
|
||||
eventId: string,
|
||||
): Wow335DeathKnightScentOfBloodProcResult {
|
||||
const profile = wow335DeathKnightScentOfBloodProfile(rank);
|
||||
if (!profile || !trigger || !Number.isFinite(roll) || roll < 0 || roll >= profile.procChance) {
|
||||
return { auras, triggered: false, chargesGranted: 0 };
|
||||
}
|
||||
|
||||
const definition: AuraDefinition = {
|
||||
// Keep each proc's source-authored hit charges as a separate batch. The
|
||||
// generated talent links child 50421, whose stack/refresh row is not local.
|
||||
id: `wow335:death-knight:scent-of-blood:${eventId}`,
|
||||
name: "Scent of Blood",
|
||||
description: `Your next ${profile.meleeHitCharges} melee hit${profile.meleeHitCharges === 1 ? "" : "s"} generate ${profile.runicPowerPerHit} Runic Power each.`,
|
||||
disposition: "buff",
|
||||
// The generated talent ranks link to child spell 50421, whose aura lasts 20 seconds.
|
||||
durationMs: 20_000,
|
||||
maxStacks: profile.meleeHitCharges,
|
||||
initialStacks: profile.meleeHitCharges,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG],
|
||||
};
|
||||
const applied = applyAura(auras, {
|
||||
definition,
|
||||
sourceId: sourceActorId,
|
||||
targetId: sourceActorId,
|
||||
now,
|
||||
});
|
||||
return { auras: applied.auras, triggered: true, chargesGranted: profile.meleeHitCharges };
|
||||
}
|
||||
|
||||
/** Spend a single charge only on a landed direct melee hit. */
|
||||
export function wow335DeathKnightScentOfBloodOnMeleeHit(
|
||||
auras: readonly ActiveAura[],
|
||||
actorId: string,
|
||||
rank: number,
|
||||
landedMeleeHit: boolean,
|
||||
now: number,
|
||||
): Wow335DeathKnightScentOfBloodHitResult {
|
||||
const profile = wow335DeathKnightScentOfBloodProfile(rank);
|
||||
if (!profile || !landedMeleeHit) return { auras, runicPowerGain: 0, remainingCharges: 0 };
|
||||
const chargeAura = auras.find((aura) => aura.targetId === actorId
|
||||
&& aura.sourceId === actorId
|
||||
&& aura.stacks > 0
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG));
|
||||
if (!chargeAura) return { auras, runicPowerGain: 0, remainingCharges: 0 };
|
||||
|
||||
const remainingCharges = chargeAura.stacks - 1;
|
||||
const nextAuras = remainingCharges > 0
|
||||
? auras.map((aura) => aura.instanceId === chargeAura.instanceId
|
||||
? { ...aura, stacks: remainingCharges }
|
||||
: aura)
|
||||
: auras.filter((aura) => aura.instanceId !== chargeAura.instanceId);
|
||||
return {
|
||||
auras: nextAuras,
|
||||
runicPowerGain: profile.runicPowerPerHit,
|
||||
remainingCharges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
|
||||
const TARGET_ID = "sudden-doom-target";
|
||||
const PARTY_SUDDEN_DOOM_SEED = "sudden-doom-party-member";
|
||||
|
||||
let now = 1_000;
|
||||
let restoreClock: (() => void) | undefined;
|
||||
|
||||
function ability(name: string) {
|
||||
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
if (!found) throw new Error(`Missing generated Death Knight ability: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function registerTarget() {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Sudden Doom target",
|
||||
maxHealth: 1_000_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
describe("WoW Death Knight Sudden Doom combat integration", () => {
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
restoreClock = installCombatClock(() => now);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-death-knight-sudden-doom", 80, useCombatStore.getState().health, "tank", 5,
|
||||
);
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
talentRanks: { ...state.talentRanks, "talent-1955": 3 },
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, "runic-power": 0 },
|
||||
}));
|
||||
registerTarget();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreClock?.();
|
||||
restoreClock = undefined;
|
||||
});
|
||||
|
||||
it("launches one free Death Coil at the landed Blood Strike target", () => {
|
||||
const bloodStrike = ability("Blood Strike");
|
||||
const deathCoil = ability("Death Coil");
|
||||
let procAt = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 500 && procAt === 0; attempt += 1) {
|
||||
now = 1_000 + attempt * 2_000;
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
cooldowns: { ...state.cooldowns, [bloodStrike.id]: 0 },
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, "runic-power": 0 },
|
||||
}));
|
||||
const cast = useCombatStore.getState().castAbility(bloodStrike.id, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
const deathCoilHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.abilityId === deathCoil.id
|
||||
&& event.occurredAt === now
|
||||
));
|
||||
if (cast.ok && deathCoilHits.length > 0) {
|
||||
procAt = now;
|
||||
expect(deathCoilHits).toHaveLength(1);
|
||||
expect(deathCoilHits[0]?.targetActorId).toBe(TARGET_ID);
|
||||
expect(deathCoilHits[0]?.sourceActorId).toBe(PLAYER_AURA_ENTITY_ID);
|
||||
// Blood Strike adds 10 RP; a normal 40-RP Death Coil would have failed
|
||||
// from the initial empty pool. Keeping that gain proves the proc is free.
|
||||
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(10);
|
||||
}
|
||||
}
|
||||
|
||||
expect(procAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not dispatch for an evading/missing hit result or an unrelated landed ability", () => {
|
||||
const bloodStrike = ability("Blood Strike");
|
||||
const plagueStrike = ability("Plague Strike");
|
||||
const deathCoil = ability("Death Coil");
|
||||
const evadingNow = now;
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(evadingNow),
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[TARGET_ID]: { ...state.mobs[TARGET_ID]!, navigationEvadeUntil: evadingNow + 5_000 },
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().castAbility(bloodStrike.id, { targetActorId: TARGET_ID, now: evadingNow });
|
||||
|
||||
now += 2_000;
|
||||
useCombatStore.setState((state) => ({
|
||||
globalCooldownEndsAt: 0,
|
||||
deathKnightRunes: createDeathKnightRunes(now),
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[TARGET_ID]: { ...state.mobs[TARGET_ID]!, navigationEvadeUntil: 0 },
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().castAbility(plagueStrike.id, { targetActorId: TARGET_ID, now });
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.abilityId === deathCoil.id
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("launches one free Death Coil for a party Death Knight after its landed Heart Strike", () => {
|
||||
const dkAbilities = abilitiesForCharacter("death-knight", null);
|
||||
const heartStrikeBase = dkAbilities.find((candidate) => candidate.name === "Heart Strike");
|
||||
const deathCoil = dkAbilities.find((candidate) => candidate.name === "Death Coil");
|
||||
if (!heartStrikeBase || !deathCoil) throw new Error("Missing generated party Death Knight abilities.");
|
||||
const baseMember = createConfiguredPartyMember({
|
||||
name: "Sudden Doom DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
talentTreeId: "death-knight-blood",
|
||||
role: "damage",
|
||||
}, 80, 0, PARTY_SUDDEN_DOOM_SEED);
|
||||
const talentRanks = { "talent-1955": 3, "talent-1957": 1 };
|
||||
const heartStrike = learnedAbilityForBuild(heartStrikeBase, 80, talentRanks);
|
||||
if (!heartStrike) throw new Error("Party Death Knight Heart Strike was not learned at the configured talent rank.");
|
||||
let procAt = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 500 && procAt === 0; attempt += 1) {
|
||||
now = 1_000 + attempt * 2_000;
|
||||
const blockedCooldowns = Object.fromEntries(dkAbilities
|
||||
.filter((candidate) => candidate.id !== heartStrike.id)
|
||||
.map((candidate) => [candidate.id, now + 60_000]));
|
||||
const member = {
|
||||
...baseMember,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
talentRanks,
|
||||
resourceType: "runic-power" as const,
|
||||
resourceName: "Runic Power",
|
||||
resource: 0,
|
||||
maxResource: 100,
|
||||
resourcePools: { "runic-power": 0 },
|
||||
runes: createDeathKnightRunes(now),
|
||||
cooldowns: { ...blockedCooldowns, [heartStrike.id]: 0 },
|
||||
globalCooldownEndsAt: 0,
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
nextActionAt: 0,
|
||||
status: "attacking" as const,
|
||||
};
|
||||
usePartyStore.setState((state) => ({
|
||||
command: "attack",
|
||||
activeMobId: TARGET_ID,
|
||||
members: [member],
|
||||
}));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, now);
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
advanceDungeonPartyCombat(now);
|
||||
|
||||
const deathCoilHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.abilityId === deathCoil.id
|
||||
&& event.sourceActorId === member.id
|
||||
&& event.occurredAt === now
|
||||
));
|
||||
if (deathCoilHits.length > 0) {
|
||||
procAt = now;
|
||||
expect(deathCoilHits).toHaveLength(1);
|
||||
expect(deathCoilHits[0]?.targetActorId).toBe(TARGET_ID);
|
||||
const finalMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!;
|
||||
// Heart Strike grants 40 RP; the triggered Death Coil does not spend it.
|
||||
expect(finalMember.resourcePools["runic-power"]).toBe(40);
|
||||
}
|
||||
}
|
||||
|
||||
const partyAbilityIds = useCombatStore.getState().combatEvents
|
||||
.filter((event) => event.sourceActorId === baseMember.id)
|
||||
.map((event) => event.abilityId);
|
||||
expect(partyAbilityIds).toContain(heartStrike.id);
|
||||
expect(procAt).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID,
|
||||
WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS,
|
||||
wow335DeathKnightSuddenDoomTriggerForLandedHit,
|
||||
wow335DeathKnightSuddenDoomProcChance,
|
||||
} from "./wow335DeathKnightSuddenDoomRuntime";
|
||||
|
||||
describe("WoW Death Knight Sudden Doom source runtime", () => {
|
||||
it("reads the generated 5/10/15% rank chances", () => {
|
||||
expect([0, 1, 2, 3].map(wow335DeathKnightSuddenDoomProcChance)).toEqual([0, 0.05, 0.1, 0.15]);
|
||||
});
|
||||
|
||||
it("returns a one-use free Death Coil instruction on an eligible landed hit at the generated chance", () => {
|
||||
for (const triggerAbilityId of WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS) {
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, triggerAbilityId, "target-1", true, 0))
|
||||
.toEqual({
|
||||
abilityId: WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID,
|
||||
targetId: "target-1",
|
||||
free: true,
|
||||
});
|
||||
}
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(
|
||||
1,
|
||||
WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS[0],
|
||||
"target-1",
|
||||
true,
|
||||
0.05,
|
||||
)).toBeNull();
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(
|
||||
3,
|
||||
"wow335-death-knight-obliterate",
|
||||
"target-1",
|
||||
true,
|
||||
0,
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not proc on misses, targetless events, absent ranks, or invalid rolls", () => {
|
||||
const bloodStrike = WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS[0];
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", false, 0)).toBeNull();
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, null, true, 0)).toBeNull();
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(0, bloodStrike, "target-1", true, 0)).toBeNull();
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", true, -0.01)).toBeNull();
|
||||
expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", true, Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
// These ability IDs are generated from the local 3.3.5a class catalog.
|
||||
export const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS = [
|
||||
"wow335-death-knight-blood-strike",
|
||||
"wow335-death-knight-heart-strike",
|
||||
] as const;
|
||||
export const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID = "wow335-death-knight-death-coil";
|
||||
|
||||
const SUDDEN_DOOM_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1955");
|
||||
const SUDDEN_DOOM_TRIGGER_SET = new Set<string>(WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS);
|
||||
|
||||
export interface Wow335DeathKnightSuddenDoomTrigger {
|
||||
readonly abilityId: typeof WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID;
|
||||
readonly targetId: string;
|
||||
/** The generated tooltip says the triggered Death Coil is free. */
|
||||
readonly free: true;
|
||||
}
|
||||
|
||||
/** Return the locally generated rank chance for Sudden Doom. */
|
||||
export function wow335DeathKnightSuddenDoomProcChance(rank: number): number {
|
||||
const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank)));
|
||||
if (normalizedRank <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, (SUDDEN_DOOM_TALENT?.rankEffects[normalizedRank - 1]?.procChance ?? 0) / 100));
|
||||
}
|
||||
|
||||
/** Resolve one landed eligible attack into an immediate, one-use free Death Coil instruction. */
|
||||
export function wow335DeathKnightSuddenDoomTriggerForLandedHit(
|
||||
rank: number,
|
||||
triggeringAbilityId: string,
|
||||
targetId: string | null,
|
||||
landed: boolean,
|
||||
roll: number,
|
||||
): Wow335DeathKnightSuddenDoomTrigger | null {
|
||||
const chance = wow335DeathKnightSuddenDoomProcChance(rank);
|
||||
if (!landed
|
||||
|| !targetId
|
||||
|| !SUDDEN_DOOM_TRIGGER_SET.has(triggeringAbilityId)
|
||||
|| chance <= 0
|
||||
|| !Number.isFinite(roll)
|
||||
|| roll < 0
|
||||
|| roll >= chance) return null;
|
||||
return {
|
||||
abilityId: WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID,
|
||||
targetId,
|
||||
free: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { applyAura, resolveDamageValue } from "./combatAuras";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { talentModifiersForRanks, applyTalentModifierValue } from "./talentModifiers";
|
||||
import {
|
||||
WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG,
|
||||
WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG,
|
||||
} from "./wow335DruidTalentRuntime";
|
||||
|
||||
const TARGET_ID = "feral-instinct-target";
|
||||
|
||||
function abilityId(name: string): string {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
return ability!.id;
|
||||
}
|
||||
|
||||
function setFeralRank(rank: number): void {
|
||||
useCombatStore.setState((state) => {
|
||||
const talentRanks = { ...state.talentRanks, "talent-799": rank };
|
||||
return { talentRanks, talentModifiers: talentModifiersForRanks(talentRanks, "druid") };
|
||||
});
|
||||
}
|
||||
|
||||
describe("WoW Druid Feral Instinct", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
await preloadAbilityCatalogsForClasses(["druid"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", level: 1, maxHealth: 1_000_000 });
|
||||
setMobPosition(TARGET_ID, [4, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: state.maxResource,
|
||||
resourcePools: {
|
||||
...state.resourcePools,
|
||||
energy: 100,
|
||||
rage: 100,
|
||||
mana: state.maxResourcePools.mana,
|
||||
},
|
||||
maxResourcePools: { ...state.maxResourcePools, energy: 100, rage: 100 },
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "feral-instinct-test-hit",
|
||||
name: "Test Hit Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "hit", operation: "flat", value: 100 },
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 0 },
|
||||
],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 0,
|
||||
}).auras],
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies the rank bonus only to Swipe and Ravage, without unconditional damage or mitigation", () => {
|
||||
for (const [rank, percent] of [[1, 10], [2, 20], [3, 30]] as const) {
|
||||
const modifiers = talentModifiersForRanks({ "talent-799": rank }, "druid");
|
||||
expect(modifiers).toHaveLength(1);
|
||||
expect(modifiers[0]).toMatchObject({ kind: "damage-percent", amount: percent });
|
||||
for (const name of ["Swipe (Bear)", "Swipe (Cat)", "Ravage"]) {
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
||||
abilityName: name,
|
||||
tags: ["damage"],
|
||||
})).toBeCloseTo(100 + percent);
|
||||
}
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
|
||||
abilityName: "Claw",
|
||||
tags: ["damage"],
|
||||
})).toBe(100);
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-taken-percent", {
|
||||
tags: ["physical"],
|
||||
})).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("activates Tiger's Fury damage and Enrage mitigation only for their sourced durations", () => {
|
||||
setFeralRank(3);
|
||||
expect(useCombatStore.getState().castAbility(abilityId("Tiger's Fury"), { targetActorId: null, now: 1_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(abilityId("Enrage"), { targetActorId: null, now: 1_000 }).ok).toBe(true);
|
||||
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID);
|
||||
const tiger = auras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG));
|
||||
const enrage = auras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG));
|
||||
expect(tiger?.expiresAt).toBe(7_000);
|
||||
expect(enrage?.expiresAt).toBe(11_000);
|
||||
expect(resolveDamageValue(1_000, [tiger!], { direction: "dealt", school: "physical", attackKind: "melee" })).toBe(1_030);
|
||||
expect(resolveDamageValue(1_000, [enrage!], { direction: "taken", school: "physical", attackKind: "melee" })).toBe(970);
|
||||
|
||||
setFeralRank(0);
|
||||
expect(useCombatStore.getState().castAbility(abilityId("Tiger's Fury"), { targetActorId: null, now: 32_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > 32_000)
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("reduces Tiger's Fury and Enrage cooldowns by one second on landed Swipe damage", () => {
|
||||
setFeralRank(1);
|
||||
const tigerId = abilityId("Tiger's Fury");
|
||||
const enrageId = abilityId("Enrage");
|
||||
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [tigerId]: 20_000, [enrageId]: 20_000 } }));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(abilityId("Swipe (Bear)"), {
|
||||
targetActorId: TARGET_ID,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.targetActorId === TARGET_ID && event.effectiveAmount > 0
|
||||
))).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns[tigerId]).toBe(19_000);
|
||||
expect(useCombatStore.getState().cooldowns[enrageId]).toBe(19_000);
|
||||
|
||||
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [tigerId]: 30_000, [enrageId]: 30_000 } }));
|
||||
expect(useCombatStore.getState().castAbility(abilityId("Claw"), {
|
||||
targetActorId: TARGET_ID,
|
||||
now: 3_000,
|
||||
}).ok).toBe(true);
|
||||
expect(useCombatStore.getState().cooldowns[tigerId]).toBe(30_000);
|
||||
expect(useCombatStore.getState().cooldowns[enrageId]).toBe(30_000);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura, resolveStatValue } from "./combatAuras";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
|
||||
const TARGET_ID = "druid-proc-target";
|
||||
|
||||
@@ -48,6 +49,50 @@ function castAndFinish(name: string, startedAt: number) {
|
||||
)).at(-1);
|
||||
}
|
||||
|
||||
function guaranteeDruidMeleeCrits(): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "test-druid-guaranteed-melee-crit",
|
||||
name: "Test Melee Critical Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "hit", operation: "flat", value: 100 },
|
||||
{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 100 },
|
||||
{ kind: "stat", stat: "spell-hit", operation: "flat", value: 100 },
|
||||
{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 },
|
||||
],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras],
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[TARGET_ID]: {
|
||||
...state.mobs[TARGET_ID]!,
|
||||
characterDefender: {
|
||||
level: 80,
|
||||
armor: 0,
|
||||
dodgePercent: 0,
|
||||
parryPercent: 0,
|
||||
blockPercent: 0,
|
||||
blockValue: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function findDruidPrimalFurySeed(procTag: string, chance: number, shouldProc: boolean): string {
|
||||
for (let index = 0; index < 100_000; index += 1) {
|
||||
const seed = `druid-primal-fury:${procTag}:${index}`;
|
||||
if ((unit(`${seed}:wow335-druid-primal-fury-${procTag}`) < chance) === shouldProc) return seed;
|
||||
}
|
||||
throw new Error(`No deterministic Primal Fury ${procTag} seed found.`);
|
||||
}
|
||||
|
||||
describe("WoW Druid proc runtime", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
@@ -246,4 +291,311 @@ describe("WoW Druid proc runtime", () => {
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("stores 30% of an eligible critical heal and blooms on the target's next direct hit", () => {
|
||||
const healingTouch = abilityId("Healing Touch");
|
||||
const startedAt = 1_000;
|
||||
useCombatStore.setState((state) => ({
|
||||
health: 1,
|
||||
talentRanks: { ...state.talentRanks, "talent-1922": 3 },
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "test-druid-guaranteed-spell-crit",
|
||||
name: "Test Critical Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: startedAt,
|
||||
}).auras],
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast;
|
||||
expect(cast?.abilityId).toBe(healingTouch);
|
||||
useCombatStore.getState().tick(0.1, cast!.completesAt);
|
||||
|
||||
const heal = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "healing" && event.abilityId === healingTouch && event.occurredAt === cast!.completesAt
|
||||
));
|
||||
expect(heal).toMatchObject({ critical: true, effectiveAmount: expect.any(Number) });
|
||||
const storedSeed = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
));
|
||||
expect(storedSeed?.expiresAt).toBe(cast!.completesAt + 15_000);
|
||||
expect(storedSeed?.druidLivingSeedAmount).toBe(Math.round(heal!.effectiveAmount * 0.3));
|
||||
|
||||
const healthBeforeHit = useCombatStore.getState().health;
|
||||
const damage = useCombatStore.getState().damagePlayer(100, "physical", 80, cast!.completesAt + 1);
|
||||
expect(damage).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(healthBeforeHit - damage);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
))).toBe(false);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "healing" && event.abilityId === "wow335:druid:living-seed" && event.effectiveAmount > 0
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("expires an untriggered Living Seed after fifteen seconds", () => {
|
||||
const healingTouch = abilityId("Healing Touch");
|
||||
const startedAt = 1_000;
|
||||
useCombatStore.setState((state) => ({
|
||||
health: 1,
|
||||
talentRanks: { ...state.talentRanks, "talent-1922": 3 },
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "test-druid-guaranteed-spell-crit",
|
||||
name: "Test Critical Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: startedAt,
|
||||
}).auras],
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast;
|
||||
useCombatStore.getState().tick(0.1, cast!.completesAt);
|
||||
const expiresAt = cast!.completesAt + 15_000;
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
))).toBe(true);
|
||||
|
||||
const healthBeforeHit = useCombatStore.getState().health;
|
||||
const damage = useCombatStore.getState().damagePlayer(100, "physical", 80, expiresAt);
|
||||
expect(damage).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().health).toBe(healthBeforeHit - damage);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
))).toBe(false);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "healing" && event.abilityId === "wow335:druid:living-seed"
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("blooms on a landed direct enemy hit to a party ally", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
const memberMaxHealth = 1_000;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: 100, maxHealth: memberMaxHealth }
|
||||
: candidate),
|
||||
}));
|
||||
useCombatStore.setState((state) => ({
|
||||
actors: {
|
||||
...state.actors,
|
||||
[member.id]: { ...state.actors[member.id]!, health: 100, maxHealth: memberMaxHealth, alive: true },
|
||||
},
|
||||
talentRanks: { ...state.talentRanks, "talent-1922": 3 },
|
||||
auras: [...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "test-druid-guaranteed-spell-crit",
|
||||
name: "Test Critical Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
...applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "test-druid-guaranteed-ally-hit",
|
||||
name: "Test Incoming Hit Chance",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: member.id,
|
||||
now: 1_000,
|
||||
}).auras],
|
||||
}));
|
||||
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
||||
|
||||
const healingTouch = abilityId("Healing Touch");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().selectFriendlyActor(member.id);
|
||||
const cast = useCombatStore.getState().castAbility(healingTouch, { targetActorId: member.id, now: 1_000 });
|
||||
expect(cast.ok, JSON.stringify(cast)).toBe(true);
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
|
||||
const storedSeed = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
));
|
||||
expect(storedSeed?.druidLivingSeedAmount).toBeGreaterThan(0);
|
||||
|
||||
const mobId = "druid-living-seed-party-hit";
|
||||
const attackId = "druid-living-seed-party-swing";
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Party attacker",
|
||||
level: 1,
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
...DEFAULT_ENEMY_ATTACK,
|
||||
id: attackId,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
}],
|
||||
});
|
||||
setMobPosition(mobId, [4, 0, 0]);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
[mobId]: {
|
||||
...state.mobs[mobId]!,
|
||||
engaged: true,
|
||||
combatPhase: "attacking",
|
||||
targetActorId: member.id,
|
||||
threatByActor: { [member.id]: 100 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().advanceMobCombat(activeCast!.completesAt + 500);
|
||||
|
||||
const enemyDamage = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === member.id
|
||||
));
|
||||
expect(enemyDamage).toMatchObject({ outcome: "hit" });
|
||||
const remainingSeed = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("wow335-druid-living-seed")
|
||||
));
|
||||
expect(remainingSeed).toBeUndefined();
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "healing" && event.abilityId === "wow335:druid:living-seed"
|
||||
&& event.targetActorId === member.id && event.effectiveAmount > 0
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("grants five Rage on Bear and Dire Bear Form critical auto-attacks at Primal Fury's 50% rank", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-801": 1 },
|
||||
resourcePools: { ...state.resourcePools, rage: 0 },
|
||||
maxResourcePools: { ...state.maxResourcePools, rage: 100 },
|
||||
}));
|
||||
guaranteeDruidMeleeCrits();
|
||||
const form = useCombatStore.getState().castAbility(abilityId("Bear Form"), {
|
||||
targetActorId: null,
|
||||
now: 1_000,
|
||||
});
|
||||
expect(form.ok, JSON.stringify(form)).toBe(true);
|
||||
|
||||
const procSeed = findDruidPrimalFurySeed("rage", 0.5, true);
|
||||
const proc = useCombatStore.getState().damageMob(TARGET_ID, 100, 2_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: procSeed,
|
||||
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(proc.combatResult?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(5);
|
||||
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({
|
||||
kind: "resource",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID,
|
||||
abilityId: "wow335-druid-primal-fury",
|
||||
rawAmount: 5,
|
||||
effectiveAmount: 5,
|
||||
});
|
||||
|
||||
const noProcSeed = findDruidPrimalFurySeed("rage", 0.5, false);
|
||||
const noProc = useCombatStore.getState().damageMob(TARGET_ID, 100, 3_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: noProcSeed,
|
||||
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(noProc.combatResult?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(5);
|
||||
|
||||
const direBearForm = useCombatStore.getState().castAbility(abilityId("Dire Bear Form"), {
|
||||
targetActorId: null,
|
||||
now: 4_000,
|
||||
});
|
||||
expect(direBearForm.ok, JSON.stringify(direBearForm)).toBe(true);
|
||||
const direBearProc = useCombatStore.getState().damageMob(TARGET_ID, 100, 5_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: findDruidPrimalFurySeed("rage", 0.5, true),
|
||||
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(direBearProc.combatResult?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(10);
|
||||
});
|
||||
|
||||
it("adds one capped combo point on a Cat Form combo-builder critical and ignores other hits", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-801": 2 },
|
||||
resourcePools: { ...state.resourcePools, rage: 0 },
|
||||
maxResourcePools: { ...state.maxResourcePools, rage: 100 },
|
||||
}));
|
||||
guaranteeDruidMeleeCrits();
|
||||
const form = useCombatStore.getState().castAbility(abilityId("Cat Form"), {
|
||||
targetActorId: null,
|
||||
now: 1_000,
|
||||
});
|
||||
expect(form.ok, JSON.stringify(form)).toBe(true);
|
||||
|
||||
const claw = castAndFinish("Claw", 3_000);
|
||||
expect(claw).toMatchObject({ critical: true, sourceActorId: PLAYER_AGGRO_ID, targetActorId: TARGET_ID });
|
||||
expect(useCombatStore.getState().comboPoints).toBe(2);
|
||||
expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
comboPoints: 4,
|
||||
comboTargetId: TARGET_ID,
|
||||
resourcePools: {
|
||||
...state.resourcePools,
|
||||
energy: state.maxResourcePools.energy,
|
||||
},
|
||||
}));
|
||||
const cappedClaw = castAndFinish("Claw", 5_000);
|
||||
expect(cappedClaw?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().comboPoints).toBe(5);
|
||||
expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID);
|
||||
|
||||
useCombatStore.setState((state) => ({ comboPoints: 0, comboTargetId: null }));
|
||||
const clawId = abilityId("Claw");
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 100, 7_000, undefined, "physical", {
|
||||
attackKind: "melee",
|
||||
seed: "druid-primal-fury-cat-auto-attack",
|
||||
abilityId: clawId,
|
||||
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(useCombatStore.getState().comboPoints).toBe(0);
|
||||
expect(useCombatStore.getState().comboTargetId).toBeNull();
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
comboPoints: 0,
|
||||
comboTargetId: null,
|
||||
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy },
|
||||
}));
|
||||
const rake = castAndFinish("Rake", 8_000);
|
||||
expect(rake).toMatchObject({ critical: true, sourceActorId: PLAYER_AGGRO_ID, targetActorId: TARGET_ID });
|
||||
expect(useCombatStore.getState().comboPoints).toBe(2);
|
||||
useCombatStore.getState().tick(9, 17_000);
|
||||
expect(useCombatStore.getState().comboPoints).toBe(2);
|
||||
expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID);
|
||||
|
||||
useCombatStore.setState((state) => ({ comboPoints: 0, comboTargetId: null }));
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 100, 18_000, undefined, "arcane", {
|
||||
attackKind: "spell",
|
||||
seed: "druid-primal-fury-cat-non-combo",
|
||||
abilityId: abilityId("Moonfire"),
|
||||
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
||||
});
|
||||
expect(useCombatStore.getState().comboPoints).toBe(0);
|
||||
expect(useCombatStore.getState().comboTargetId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
||||
|
||||
describe("WoW Druid talent modifier runtime", () => {
|
||||
it.each([1, 2, 3])("scopes Savage Fury rank %i to its six generated abilities", (rank) => {
|
||||
const modifiers = talentModifiersForRanks({ "talent-805": rank }, "druid");
|
||||
const savageFury = modifiers.filter((modifier) => modifier.sourceTalentId === "talent-805");
|
||||
const abilityNames = ["Claw", "Rake", "Shred", "Mangle (Cat)", "Mangle (Bear)", "Maul"];
|
||||
|
||||
expect(savageFury).toHaveLength(abilityNames.length);
|
||||
for (const abilityName of abilityNames) {
|
||||
expect(applyTalentModifierValue(100, savageFury, "damage-percent", { abilityName }))
|
||||
.toBeCloseTo(100 + rank * 4);
|
||||
}
|
||||
for (const abilityName of ["Swipe (Bear)", "Moonfire"]) {
|
||||
expect(applyTalentModifierValue(100, savageFury, "damage-percent", { abilityName })).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("limits Nature's Majesty's rank-scaled cooldown reduction to Mass Entanglement", () => {
|
||||
for (const [rank, expectedReduction] of [[1, 5_000], [2, 10_000]] as const) {
|
||||
const modifiers = talentModifiersForRanks({ "talent-1822": rank }, "druid");
|
||||
|
||||
expect(applyTalentModifierValue(
|
||||
30_000,
|
||||
modifiers,
|
||||
"cooldown-flat-ms",
|
||||
{ abilityName: "Mass Entanglement" },
|
||||
)).toBe(30_000 - expectedReduction);
|
||||
expect(applyTalentModifierValue(
|
||||
30_000,
|
||||
modifiers,
|
||||
"cooldown-flat-ms",
|
||||
{ abilityName: "Typhoon" },
|
||||
)).toBe(30_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { TalentModifier } from "./talentModifiers";
|
||||
import type { AuraDefinition } from "./combatAuras";
|
||||
|
||||
export const WOW335_DRUID_FERAL_INSTINCT_TALENT_ID = "talent-799";
|
||||
export const WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG = "wow335-druid-feral-instinct-tiger-active";
|
||||
export const WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG = "wow335-druid-feral-instinct-enrage-active";
|
||||
export const WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES = new Set([
|
||||
"Swipe (Bear)",
|
||||
"Swipe (Cat)",
|
||||
"Ravage",
|
||||
]);
|
||||
|
||||
const FERAL_INSTINCT_DAMAGE_PERCENT_BY_RANK = [0, 10, 20, 30] as const;
|
||||
|
||||
export function wow335DruidFeralInstinctPercent(rank: number): number {
|
||||
return FERAL_INSTINCT_DAMAGE_PERCENT_BY_RANK[Math.max(0, Math.min(3, Math.trunc(rank)))] ?? 0;
|
||||
}
|
||||
|
||||
export function wow335DruidFeralInstinctConditionalPercent(rank: number): number {
|
||||
return Math.max(0, Math.min(3, Math.trunc(rank)));
|
||||
}
|
||||
|
||||
export function wow335DruidFeralInstinctTigerAura(rank: number, durationMs: number): AuraDefinition | null {
|
||||
const percent = wow335DruidFeralInstinctConditionalPercent(rank);
|
||||
if (percent <= 0 || durationMs <= 0) return null;
|
||||
return {
|
||||
id: "wow335-druid-feral-instinct-tiger-fury",
|
||||
name: "Feral Instinct: Tiger's Fury",
|
||||
description: `Increases all damage dealt by ${percent}% while Tiger's Fury is active.`,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
utilityTags: [WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG],
|
||||
modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: percent / 100 }],
|
||||
};
|
||||
}
|
||||
|
||||
export function wow335DruidFeralInstinctEnrageAura(rank: number, durationMs: number): AuraDefinition | null {
|
||||
const percent = wow335DruidFeralInstinctConditionalPercent(rank);
|
||||
if (percent <= 0 || durationMs <= 0) return null;
|
||||
return {
|
||||
id: "wow335-druid-feral-instinct-enrage",
|
||||
name: "Feral Instinct: Enrage",
|
||||
description: `Reduces all damage taken by ${percent}% while Enrage is active.`,
|
||||
disposition: "buff",
|
||||
durationMs,
|
||||
utilityTags: [WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG],
|
||||
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -percent / 100 }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-backed modifiers for WoW Druid talents whose generated tooltip needs
|
||||
* narrower scoping than the generic talent description parser can express.
|
||||
*/
|
||||
export function wow335DruidTalentModifierOverrides(
|
||||
talentId: string,
|
||||
rank: number,
|
||||
description: string,
|
||||
): readonly TalentModifier[] {
|
||||
if (talentId === WOW335_DRUID_FERAL_INSTINCT_TALENT_ID) {
|
||||
const swipeAndRavageDamage = description.match(/increases\s+swipe\s+and\s+ravage\s+damage\s+by\s+(\d+(?:\.\d+)?)%/i);
|
||||
if (!swipeAndRavageDamage) return [];
|
||||
return [{
|
||||
kind: "damage-percent",
|
||||
amount: Number(swipeAndRavageDamage[1]),
|
||||
unit: "percent",
|
||||
scope: "damage of your Swipe (Bear), Swipe (Cat), and Ravage abilities",
|
||||
sourceTalentId: talentId,
|
||||
sourceTalentName: "Feral Instinct",
|
||||
rank,
|
||||
description,
|
||||
}];
|
||||
}
|
||||
if (talentId === "talent-805") {
|
||||
// The generic modifier parser stops at the first comma in Savage Fury's
|
||||
// named list, so emit a precise scope for every ability from this rank's
|
||||
// generated tooltip instead of applying its bonus only to Claw.
|
||||
const damageBonus = description.match(
|
||||
/increases the damage caused by your (.+?) abilities by (\d+(?:\.\d+)?)%/i,
|
||||
);
|
||||
if (!damageBonus) return [];
|
||||
const abilityNames = damageBonus[1]!
|
||||
.replace(/\s+(?:and|or)\s+/gi, ",")
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const amount = Number(damageBonus[2]);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return [];
|
||||
return abilityNames.map((name) => ({
|
||||
kind: "damage-percent",
|
||||
amount,
|
||||
unit: "percent",
|
||||
scope: `damage of your ${name} ability`,
|
||||
sourceTalentId: talentId,
|
||||
sourceTalentName: "Savage Fury",
|
||||
rank,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
if (talentId !== "talent-1822") return [];
|
||||
|
||||
// Nature's Majesty reduces only Mass Entanglement's cooldown. The generic
|
||||
// parser recognizes the flat reduction but, without this ability scope,
|
||||
// would apply it to every cooldown-bearing ability.
|
||||
const cooldown = description.match(
|
||||
/\breduces?\s+(?:the\s+)?cooldown\s+(?:of|for)\s+your\s+Mass Entanglement\s+by\s+(\d+(?:\.\d+)?)\s+seconds?\b/i,
|
||||
);
|
||||
if (!cooldown) return [];
|
||||
|
||||
return [{
|
||||
kind: "cooldown-flat-ms",
|
||||
amount: -Number(cooldown[1]) * 1_000,
|
||||
unit: "milliseconds",
|
||||
scope: "cooldown of your Mass Entanglement ability",
|
||||
sourceTalentId: talentId,
|
||||
sourceTalentName: "Nature's Majesty",
|
||||
rank,
|
||||
description,
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime";
|
||||
import { partyAbilityCosts, partyAbilityIsReady } from "./partyAbilityAi";
|
||||
|
||||
describe("WoW Hunter Lock and Load companion runtime", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
usePartyStore.getState().initializeParty(
|
||||
"hunter-lock-and-load-party",
|
||||
80,
|
||||
useCombatStore.getState().health,
|
||||
"damage",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a companion spend two free Arcane Shots through its cooldown and mana checks", () => {
|
||||
const arcaneShot = abilitiesForClass("hunter").find((ability) => ability.name === "Arcane Shot")!;
|
||||
const hunterAbilities = abilitiesForClass("hunter");
|
||||
const hunterBase = createConfiguredPartyMember({
|
||||
name: "Survival Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Survival",
|
||||
specializationId: "hunter:survival",
|
||||
talentTreeId: "hunter-survival",
|
||||
role: "damage",
|
||||
talentRanks: { "talent-1306": 3 },
|
||||
}, 80, 0, "hunter-lock-and-load-party");
|
||||
const hunter = {
|
||||
...hunterBase,
|
||||
resource: 0,
|
||||
resourcePools: { ...hunterBase.resourcePools, mana: 0 },
|
||||
lastResourceSpentAt: {},
|
||||
cooldowns: Object.fromEntries(hunterAbilities.map((ability) => [ability.id, 60_000])),
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready" as const,
|
||||
};
|
||||
usePartyStore.setState({ members: [hunter], command: "attack" });
|
||||
registerPartyRuntimePosition(hunter.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("hunter-lock-and-load-party-target", {
|
||||
name: "Training Target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-lock-and-load-party-target", [0, 0, 12]);
|
||||
useCombatStore.getState().damageMob("hunter-lock-and-load-party-target", 1, 500);
|
||||
updatePartyRuntimeTargetVisibility(hunter.id, "hunter-lock-and-load-party-target", true);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "wow335:hunter:lock-and-load-ready",
|
||||
name: "Lock and Load",
|
||||
disposition: "buff",
|
||||
durationMs: 15_000,
|
||||
maxStacks: 2,
|
||||
initialStacks: 2,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG],
|
||||
},
|
||||
sourceId: hunter.id,
|
||||
targetId: hunter.id,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
|
||||
expect(partyAbilityIsReady(hunter, arcaneShot, 1_000)).toBe(true);
|
||||
expect(partyAbilityCosts(hunter, arcaneShot, 1_000)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ resource: "mana", amount: 0 }),
|
||||
]));
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(usePartyStore.getState().members[0]?.cooldowns[arcaneShot.id]).toBe(0);
|
||||
expect(usePartyStore.getState().members[0]?.lastResourceSpentAt.mana).toBe(0);
|
||||
expect(useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === hunter.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))?.stacks).toBe(1);
|
||||
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
|
||||
expect(usePartyStore.getState().members[0]?.cooldowns[arcaneShot.id]).toBe(0);
|
||||
expect(usePartyStore.getState().members[0]?.lastResourceSpentAt.mana).toBe(0);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === hunter.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a companion Freezing Arrow trap that procs the companion's talent", () => {
|
||||
const hunter = createConfiguredPartyMember({
|
||||
name: "Survival Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Survival",
|
||||
specializationId: "hunter:survival",
|
||||
talentTreeId: "hunter-survival",
|
||||
role: "damage",
|
||||
talentRanks: { "talent-1306": 3 },
|
||||
}, 80, 0, "hunter-freezing-arrow-party");
|
||||
usePartyStore.setState({ members: [hunter] });
|
||||
registerPartyRuntimePosition(hunter.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("hunter-freezing-arrow-party-target", {
|
||||
name: "Training Target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-freezing-arrow-party-target", [0, 0, 12]);
|
||||
const freezingArrow = abilitiesForClass("hunter").find((ability) => ability.name === "Freezing Arrow")!;
|
||||
const areaId = useCombatStore.getState().createActorAreaEffect(
|
||||
hunter.id, freezingArrow, [0, 0, 0], 1_000,
|
||||
);
|
||||
expect(areaId).toBeTruthy();
|
||||
setMobPosition("hunter-freezing-arrow-party-target", [0, 0, 1]);
|
||||
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs["hunter-freezing-arrow-party-target"]?.statuses)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ kind: "freeze", sourceActorId: hunter.id })]));
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ targetId: hunter.id, stacks: 2, expiresAt: 17_000 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { cooldownResetCandidates } from "./cooldownReset";
|
||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import {
|
||||
lockAndLoadRankMechanics,
|
||||
WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG,
|
||||
WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG,
|
||||
} from "./wow335HunterRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
describe("WoW 3.3.5 Hunter Readiness", () => {
|
||||
@@ -96,6 +103,15 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads Lock and Load chance, charges, and cooldown from each generated rank description", () => {
|
||||
expect([1, 2, 3].map(lockAndLoadRankMechanics)).toEqual([
|
||||
expect.objectContaining({ trapProcChance: 0.33, periodicProcChance: 0.1, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }),
|
||||
expect.objectContaining({ trapProcChance: 0.66, periodicProcChance: 0.2, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }),
|
||||
expect.objectContaining({ trapProcChance: 1, periodicProcChance: 0.3, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }),
|
||||
]);
|
||||
expect(lockAndLoadRankMechanics(0)).toBeNull();
|
||||
});
|
||||
|
||||
it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => {
|
||||
const readinessId = unlockHunterTalent("Readiness");
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
@@ -156,6 +172,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => {
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
const beastWithin = useCombatStore.getState().abilities.find((candidate) => candidate.name === "The Beast Within");
|
||||
const beastWithinTalent = runtimeTalentNodeById("talent-1803");
|
||||
expect(beastWithinTalent).toBeDefined();
|
||||
expect(beastWithin).toMatchObject({
|
||||
talentNodeId: "talent-1803",
|
||||
description: beastWithinTalent?.rankDescriptions[0],
|
||||
});
|
||||
expect(beastWithin?.ranks?.map((rank) => rank.description)).toEqual(beastWithinTalent?.rankDescriptions);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
|
||||
}));
|
||||
@@ -177,10 +201,11 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"));
|
||||
expect(beastWithinAuras).toHaveLength(3);
|
||||
expect(beastWithinAuras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
|
||||
expect(beastWithinAuras.every((aura) => aura.expiresAt === 9_000)).toBe(true);
|
||||
expect(beastWithinAuras.every((aura) => aura.definition.description === beastWithinTalent?.rankDescriptions[1])).toBe(true);
|
||||
expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.2 }),
|
||||
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.1 }),
|
||||
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }),
|
||||
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }),
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -205,8 +230,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
}));
|
||||
const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000);
|
||||
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"))).toBe(true);
|
||||
const beastWithinAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"));
|
||||
expect(beastWithinAuras).toHaveLength(3);
|
||||
expect(beastWithinAuras.every((aura) => aura.expiresAt === procAt + 6_000)).toBe(true);
|
||||
expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }),
|
||||
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => {
|
||||
@@ -278,7 +309,7 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => {
|
||||
it("keeps Kill Command's pet aura inert on generic melee swings", () => {
|
||||
registerHunterMob("kill-command-target", [0, 0, 3]);
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const killCommandId = hunterAbilityId("Kill Command");
|
||||
@@ -292,12 +323,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
entry.definition.utilityTags?.includes("wow335-hunter-kill-command")
|
||||
));
|
||||
expect(aura?.targetId).toBe(pet!.id);
|
||||
expect(aura?.stacks).toBe(60);
|
||||
expect(aura?.expiresAt).toBe(33_000);
|
||||
expect(aura?.definition.maxStacks).toBeUndefined();
|
||||
expect(aura?.definition.initialStacks).toBeUndefined();
|
||||
expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false);
|
||||
|
||||
useCombatStore.getState().tick(1, 4_000);
|
||||
|
||||
expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.stacks).toBe(60);
|
||||
expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.expiresAt).toBe(33_000);
|
||||
});
|
||||
|
||||
it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => {
|
||||
@@ -328,6 +361,206 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("procs Lock and Load when Freezing Arrow successfully traps a target", () => {
|
||||
registerHunterMob("lock-and-load-trap-target", [0, 0, 10]);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1306": 3 },
|
||||
}));
|
||||
const freezingArrowId = hunterAbilityId("Freezing Arrow");
|
||||
expect(useCombatStore.getState().castAbility(freezingArrowId, {
|
||||
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
|
||||
})).toMatchObject({ ok: true });
|
||||
|
||||
setMobPosition("lock-and-load-trap-target", [0, 0, 1]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
stacks: 2,
|
||||
expiresAt: 17_000,
|
||||
definition: expect.objectContaining({
|
||||
utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
expiresAt: 17_000,
|
||||
definition: expect.objectContaining({
|
||||
utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the generated periodic-damage chance for Black Arrow ticks", () => {
|
||||
const targetId = "lock-and-load-black-arrow-target";
|
||||
registerHunterMob(targetId, [0, 0, 12]);
|
||||
const blackArrowId = unlockHunterTalent("Black Arrow");
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1306": 3 },
|
||||
}));
|
||||
const firstProcTickAt = Array.from({ length: 60_000 }, (_, index) => 4_000 + index)
|
||||
.find((tickAt) => hunterProcRoll(
|
||||
`${PLAYER_AGGRO_ID}:${blackArrowId}:periodic:${targetId}:${tickAt}:wow335-hunter-lock-and-load`,
|
||||
) < 0.3);
|
||||
expect(firstProcTickAt).toBeDefined();
|
||||
const castAt = firstProcTickAt! - 3_000;
|
||||
expect(useCombatStore.getState().castAbility(blackArrowId, { targetActorId: targetId, now: castAt }))
|
||||
.toMatchObject({ ok: true });
|
||||
|
||||
useCombatStore.getState().tick(1, firstProcTickAt!);
|
||||
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
stacks: 2,
|
||||
expiresAt: firstProcTickAt! + 15_000,
|
||||
definition: expect.objectContaining({
|
||||
utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
expiresAt: firstProcTickAt! + 15_000,
|
||||
definition: expect.objectContaining({
|
||||
utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("makes the next two Arcane Shots free and cooldown-free, then restores normal costs", () => {
|
||||
registerHunterMob("lock-and-load-shot-target", [0, 0, 12]);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1306": 3 },
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, mana: 0 },
|
||||
}));
|
||||
const arcaneShotId = hunterAbilityId("Arcane Shot");
|
||||
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!;
|
||||
const readyAura = {
|
||||
kind: "apply-aura" as const,
|
||||
recipient: "caster" as const,
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
aura: {
|
||||
id: "wow335:hunter:lock-and-load-ready",
|
||||
name: "Lock and Load",
|
||||
description: "Two Arcane Shot or Explosive Shot casts cost no mana and trigger no cooldown.",
|
||||
disposition: "buff" as const,
|
||||
durationMs: 15_000,
|
||||
maxStacks: 2,
|
||||
initialStacks: 2,
|
||||
stackBehavior: "replace" as const,
|
||||
utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG],
|
||||
},
|
||||
};
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyAura(state.auras, {
|
||||
definition: readyAura.aura,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 1_000 }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0);
|
||||
expect(useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))?.stacks).toBe(1);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [arcaneShotId]: 40_000 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 3_000 }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))).toBe(false);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [arcaneShotId]: 0 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 5_000 }))
|
||||
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
||||
});
|
||||
|
||||
it("does not consume Lock and Load when an eligible shot cannot find a target", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1306": 3 },
|
||||
resource: 0,
|
||||
resourcePools: { ...state.resourcePools, mana: 0 },
|
||||
}));
|
||||
const arcaneShotId = hunterAbilityId("Arcane Shot");
|
||||
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!;
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff",
|
||||
durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace",
|
||||
utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(arcaneShotId, { now: 1_000 }))
|
||||
.toMatchObject({ ok: false, reason: "no-target" });
|
||||
expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(30_000);
|
||||
expect(useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))?.stacks).toBe(2);
|
||||
});
|
||||
|
||||
it("spends a Lock and Load charge on a cast that misses", () => {
|
||||
const targetId = "lock-and-load-miss-target";
|
||||
registerHunterMob(targetId, [0, 0, 12]);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1306": 3 },
|
||||
}));
|
||||
const arcaneShotId = hunterAbilityId("Arcane Shot");
|
||||
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!;
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff",
|
||||
durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace",
|
||||
utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras,
|
||||
}));
|
||||
const missAt = Array.from({ length: 13_000 }, (_, index) => 3_000 + index).find((now) => (
|
||||
hunterProcRoll(`${PLAYER_AGGRO_ID}:${arcaneShot.id}:${targetId}:${now}:direct:outcome`) < 0.05
|
||||
));
|
||||
expect(missAt).toBeDefined();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: targetId, now: missAt! }))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("miss");
|
||||
expect(useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)
|
||||
))?.stacks).toBe(1);
|
||||
});
|
||||
|
||||
it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => {
|
||||
registerHunterMob("explosive-center", [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 });
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
|
||||
import type { ActiveAura } from "./combatAuras";
|
||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
|
||||
const THE_BEAST_WITHIN_NODE_ID = "talent-1803";
|
||||
export const WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID = "talent-1306";
|
||||
export const WOW335_HUNTER_ENTRAPMENT_TALENT_ID = "talent-1304";
|
||||
export const WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG = "wow335-hunter-lock-and-load-ready";
|
||||
export const WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG = "wow335-hunter-lock-and-load-icd";
|
||||
|
||||
export interface BeastWithinRankMechanics {
|
||||
readonly description: string;
|
||||
readonly procChance: number;
|
||||
readonly additionalDamage: number;
|
||||
readonly manaCostReduction: number;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
export interface LockAndLoadRankMechanics {
|
||||
readonly description: string;
|
||||
readonly trapProcChance: number;
|
||||
readonly periodicProcChance: number;
|
||||
readonly freeCastCount: number;
|
||||
readonly durationMs: number;
|
||||
readonly internalCooldownMs: number;
|
||||
}
|
||||
|
||||
export interface EntrapmentRankMechanics {
|
||||
readonly description: string;
|
||||
readonly frostTrapSlowBonus: number;
|
||||
readonly rootProcChance: number;
|
||||
readonly rootDurationMs: number;
|
||||
}
|
||||
|
||||
function tooltipNumber(description: string, pattern: RegExp): number | null {
|
||||
const match = description.match(pattern);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Read The Beast Within's mechanics from the generated talent rank text. */
|
||||
export function beastWithinRankMechanics(rank: number): BeastWithinRankMechanics | null {
|
||||
const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID);
|
||||
const description = node?.rankDescriptions[Math.trunc(rank) - 1];
|
||||
if (!description) return null;
|
||||
|
||||
const procChance = tooltipNumber(description, /you have a (\d+(?:\.\d+)?)% chance/i);
|
||||
const additionalDamage = tooltipNumber(description, /causing (\d+(?:\.\d+)?)% additional damage/i);
|
||||
const manaCostReduction = tooltipNumber(description, /reducing mana costs of all spells by (\d+(?:\.\d+)?)%/i);
|
||||
const durationSeconds = tooltipNumber(description, /for (\d+(?:\.\d+)?) seconds/i);
|
||||
if (procChance === null || additionalDamage === null || manaCostReduction === null || durationSeconds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
procChance: Math.max(0, Math.min(1, procChance / 100)),
|
||||
additionalDamage: additionalDamage / 100,
|
||||
manaCostReduction: manaCostReduction / 100,
|
||||
durationMs: Math.max(0, Math.round(durationSeconds * 1_000)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read Lock and Load's rank values from its generated tooltip text. */
|
||||
export function lockAndLoadRankMechanics(rank: number): LockAndLoadRankMechanics | null {
|
||||
const node = runtimeTalentNodeById(WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID);
|
||||
const description = node?.rankDescriptions[Math.trunc(rank) - 1];
|
||||
if (!description) return null;
|
||||
|
||||
const trapProcPercent = tooltipNumber(description, /trapping a target[^.]*?has a (\d+(?:\.\d+)?)% chance/i);
|
||||
const periodicProcPercent = tooltipNumber(description, /periodic damage[^.]*?has a (\d+(?:\.\d+)?)% to trigger/i);
|
||||
const freeCastCount = tooltipNumber(description, /your next (\d+) Arcane Shot or Explosive Shot/i);
|
||||
const cooldownSeconds = tooltipNumber(description, /this effect has a (\d+(?:\.\d+)?) sec cooldown/i);
|
||||
if (trapProcPercent === null || periodicProcPercent === null || freeCastCount === null || cooldownSeconds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationMs = Math.max(0, Math.round(cooldownSeconds * 1_000));
|
||||
return {
|
||||
description,
|
||||
trapProcChance: Math.max(0, Math.min(1, trapProcPercent / 100)),
|
||||
periodicProcChance: Math.max(0, Math.min(1, periodicProcPercent / 100)),
|
||||
freeCastCount: Math.max(0, Math.trunc(freeCastCount)),
|
||||
durationMs,
|
||||
internalCooldownMs: durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the authored Entrapment slow/root values from the generated talent tooltip. */
|
||||
export function entrapmentRankMechanics(rank: number): EntrapmentRankMechanics | null {
|
||||
const normalizedRank = Math.trunc(rank);
|
||||
const node = runtimeTalentNodeById(WOW335_HUNTER_ENTRAPMENT_TALENT_ID);
|
||||
const description = node?.rankDescriptions[normalizedRank - 1];
|
||||
if (!description) return null;
|
||||
|
||||
const slowPercent = tooltipNumber(description, /increases the slow effect of Frost Trap by (\d+(?:\.\d+)?)%/i);
|
||||
const rootDurationSeconds = tooltipNumber(description, /preventing them from moving for (\d+(?:\.\d+)?) seconds/i);
|
||||
if (slowPercent === null || rootDurationSeconds === null) return null;
|
||||
|
||||
// The local Hunter DBC Entrapment rank rows carry 2/4/6 as their proc base
|
||||
// points; their companion child aura supplies the root duration.
|
||||
const rootProcChance = [0, 0.02, 0.04, 0.06][normalizedRank];
|
||||
if (rootProcChance === undefined) return null;
|
||||
return {
|
||||
description,
|
||||
frostTrapSlowBonus: slowPercent / 100,
|
||||
rootProcChance,
|
||||
rootDurationMs: Math.max(0, Math.round(rootDurationSeconds * 1_000)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Spell.dbc child-aura durations for Freezing Trap's three learned ranks. */
|
||||
export function freezingTrapDurationMs(spellId: number): number {
|
||||
switch (spellId) {
|
||||
case 1499: return 10_000;
|
||||
case 14310: return 15_000;
|
||||
case 14311:
|
||||
case 60192:
|
||||
case 60202: return 20_000;
|
||||
default: return 20_000;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeLockAndLoadCharge(
|
||||
auras: readonly ActiveAura[],
|
||||
auraInstanceId: string,
|
||||
): readonly ActiveAura[] {
|
||||
return auras.flatMap((aura) => aura.instanceId !== auraInstanceId
|
||||
? [aura]
|
||||
: aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []);
|
||||
}
|
||||
|
||||
function applyBeastWithinRankMechanics(
|
||||
effects: readonly AbilityEffect[],
|
||||
mechanics: BeastWithinRankMechanics,
|
||||
): readonly AbilityEffect[] {
|
||||
return effects.map((effect) => {
|
||||
if (effect.kind !== "apply-aura") return effect;
|
||||
return {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
description: mechanics.description,
|
||||
durationMs: mechanics.durationMs,
|
||||
modifiers: effect.aura.modifiers?.map((modifier) => {
|
||||
if (modifier.kind === "resource" && modifier.property === "cost" && modifier.resource === "power") {
|
||||
return { ...modifier, value: -mechanics.manaCostReduction };
|
||||
}
|
||||
if (modifier.kind === "damage" && modifier.direction === "dealt") {
|
||||
return { ...modifier, value: mechanics.additionalDamage };
|
||||
}
|
||||
return modifier;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the duplicate DBC child-rank text and payload with the generated talent's actual ranks. */
|
||||
export function applyGeneratedBeastWithinTalent(ability: AbilityDefinition): AbilityDefinition {
|
||||
if (ability.classId !== "hunter" || ability.name !== "The Beast Within") return ability;
|
||||
const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID);
|
||||
const sourceRank = ability.ranks?.[0];
|
||||
if (!node || !sourceRank || node.rankDescriptions.length === 0) return ability;
|
||||
|
||||
const ranks: AbilityRankDefinition[] = [];
|
||||
for (const [index, description] of node.rankDescriptions.entries()) {
|
||||
const mechanics = beastWithinRankMechanics(index + 1);
|
||||
if (!mechanics) return ability;
|
||||
ranks.push({
|
||||
...sourceRank,
|
||||
rank: index + 1,
|
||||
description,
|
||||
effects: applyBeastWithinRankMechanics(sourceRank.effects, mechanics),
|
||||
});
|
||||
}
|
||||
|
||||
const firstRank = ranks[0];
|
||||
if (!firstRank) return ability;
|
||||
return {
|
||||
...ability,
|
||||
talentNodeId: node.id,
|
||||
description: firstRank.description,
|
||||
effects: firstRank.effects,
|
||||
ranks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { resetPartyRuntimeClock } from "./partyRuntime";
|
||||
|
||||
const EMPTY_TALENTS: Readonly<Record<string, number>> = {};
|
||||
|
||||
function setupHunter(
|
||||
id: string,
|
||||
level: number,
|
||||
talentRanks: Readonly<Record<string, number>> = EMPTY_TALENTS,
|
||||
rangedAttackPower = 0,
|
||||
) {
|
||||
const base = createConfiguredPartyMember({
|
||||
name: "Companion Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Survival",
|
||||
specializationId: "hunter:survival",
|
||||
talentTreeId: "hunter-survival",
|
||||
role: "damage",
|
||||
talentRanks,
|
||||
}, level, 0, id);
|
||||
const hunter = {
|
||||
...base,
|
||||
gearStats: { ...base.gearStats, rangedAttackPower },
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready" as const,
|
||||
};
|
||||
usePartyStore.setState({ members: [hunter] });
|
||||
registerPartyRuntimePosition(hunter.id, [5, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
return hunter;
|
||||
}
|
||||
|
||||
function learnedHunterAbility(name: string, level: number, talentRanks: Readonly<Record<string, number>>) {
|
||||
const ability = abilitiesForClass("hunter").find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
const learned = learnedAbilityForBuild(ability!, level, talentRanks);
|
||||
expect(learned, `${name} should be available at level ${level}`).not.toBeNull();
|
||||
return learned!;
|
||||
}
|
||||
|
||||
function registerTarget(id: string, position: readonly [number, number, number]): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
}
|
||||
|
||||
function stableRoll(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
describe("WoW Hunter companion trap runtime", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
usePartyStore.getState().initializeParty("hunter-trap-party-runtime", 80, useCombatStore.getState().health);
|
||||
});
|
||||
|
||||
it("places a companion Freezing Trap at the owner and uses that owner's learned rank on trigger", () => {
|
||||
const hunter = setupHunter("freezing-trap-owner", 40);
|
||||
const targetId = "freezing-trap-party-target";
|
||||
registerTarget(targetId, [9, 0, 0]);
|
||||
const trap = learnedHunterAbility("Freezing Trap", hunter.level, hunter.talentRanks);
|
||||
expect(trap.dbcSpellId).toBe(14310);
|
||||
|
||||
const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000);
|
||||
const placed = useCombatStore.getState().areaEffects.find((area) => area.id === areaId);
|
||||
expect(placed).toMatchObject({
|
||||
sourceActorId: hunter.id,
|
||||
sourceAbilityId: trap.id,
|
||||
position: [5, 0, 0],
|
||||
expiresAt: 31_000,
|
||||
hunterTrap: { kind: "freezing", freezeDurationMs: 15_000 },
|
||||
});
|
||||
|
||||
setMobPosition(targetId, [6, 0, 0]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "freeze",
|
||||
sourceAbilityId: trap.id,
|
||||
sourceActorId: hunter.id,
|
||||
endsAt: 17_000,
|
||||
breakOnDamage: true,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("activates a companion Frost Trap zone and uses the owner's Entrapment rank", () => {
|
||||
const hunter = setupHunter("frost-trap-owner", 40, { "talent-1304": 3 });
|
||||
const targetId = "frost-trap-party-target";
|
||||
registerTarget(targetId, [9, 0, 0]);
|
||||
const trap = learnedHunterAbility("Frost Trap", hunter.level, hunter.talentRanks);
|
||||
|
||||
const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000);
|
||||
expect(areaId).toBeTruthy();
|
||||
expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({
|
||||
sourceActorId: hunter.id,
|
||||
position: [5, 0, 0],
|
||||
hunterTrap: { kind: "frost" },
|
||||
});
|
||||
|
||||
setMobPosition(targetId, [6, 0, 0]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
let zone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId);
|
||||
expect(zone).toMatchObject({ radius: 10, expiresAt: 32_000, tickIntervalMs: 2_000, hunterTrap: { kind: "frost-zone" } });
|
||||
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
const zoneSlowSourceId = `hunter-trap-zone:${areaId}`;
|
||||
expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }),
|
||||
]));
|
||||
|
||||
zone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId);
|
||||
const rootAt = Array.from({ length: 100 }, (_, index) => zone!.nextTickAt + index * 2_000)
|
||||
.find((pulseAt) => stableRoll(
|
||||
`${hunter.id}:${areaId}:${targetId}:${pulseAt}:wow335-hunter-entrapment`,
|
||||
) < 0.06);
|
||||
expect(rootAt).toBeDefined();
|
||||
useCombatStore.getState().tick(1, rootAt!);
|
||||
|
||||
expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "root", sourceAbilityId: trap.id, sourceActorId: hunter.id, endsAt: rootAt! + 3_000 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("snapshots companion Immolation Trap rank and ranged attack power when it triggers", () => {
|
||||
const hunter = setupHunter("immolation-trap-owner", 46, EMPTY_TALENTS, 2_000);
|
||||
const targetId = "immolation-trap-party-target";
|
||||
registerTarget(targetId, [9, 0, 0]);
|
||||
const trap = learnedHunterAbility("Immolation Trap", hunter.level, hunter.talentRanks);
|
||||
expect(trap.dbcSpellId).toBe(14304);
|
||||
|
||||
const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000);
|
||||
expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({
|
||||
sourceActorId: hunter.id,
|
||||
sourceAbilityId: trap.id,
|
||||
position: [5, 0, 0],
|
||||
expiresAt: 31_000,
|
||||
hunterTrap: { kind: "immolation" },
|
||||
});
|
||||
|
||||
setMobPosition(targetId, [6, 0, 0]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
const dot = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.sourceActorId === hunter.id && effect.sourceAbilityId === trap.id && effect.tag === "hunter-immolation-trap"
|
||||
));
|
||||
expect(dot).toMatchObject({
|
||||
kind: "dot",
|
||||
sourceActorId: hunter.id,
|
||||
sourceAbilityId: trap.id,
|
||||
targetId,
|
||||
remainingTicks: 5,
|
||||
intervalMs: 3_000,
|
||||
nextTickAt: 5_000,
|
||||
});
|
||||
expect(dot?.fixedAmount).toBeGreaterThan(100);
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === hunter.id
|
||||
? { ...member, gearStats: { ...member.gearStats, rangedAttackPower: 0 } }
|
||||
: member),
|
||||
}));
|
||||
useCombatStore.getState().tick(1, 5_000);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
occurredAt: 5_000,
|
||||
kind: "damage",
|
||||
sourceActorId: hunter.id,
|
||||
abilityId: trap.id,
|
||||
targetActorId: targetId,
|
||||
rawAmount: dot?.fixedAmount,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("executes a companion Explosive Trap from the owner's rank and snapshots both damage payloads", () => {
|
||||
const hunter = setupHunter("explosive-trap-owner", 44, EMPTY_TALENTS, 2_000);
|
||||
const targetId = "explosive-trap-party-target";
|
||||
registerTarget(targetId, [9, 0, 0]);
|
||||
const trap = learnedHunterAbility("Explosive Trap", hunter.level, hunter.talentRanks);
|
||||
expect(trap.dbcSpellId).toBe(14316);
|
||||
|
||||
const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000);
|
||||
expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({
|
||||
sourceActorId: hunter.id,
|
||||
sourceAbilityId: trap.id,
|
||||
position: [5, 0, 0],
|
||||
hunterTrap: { kind: "explosive", directDamageScaling: expect.any(Object) },
|
||||
});
|
||||
|
||||
setMobPosition(targetId, [6, 0, 0]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
const burningZone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId);
|
||||
const directHit = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.occurredAt === 2_000 && event.abilityId === trap.id && event.targetActorId === targetId
|
||||
));
|
||||
expect(directHit?.sourceActorId).toBe(hunter.id);
|
||||
expect(directHit?.rawAmount).toBeGreaterThan(100);
|
||||
expect(burningZone).toMatchObject({
|
||||
sourceActorId: hunter.id,
|
||||
radius: 10,
|
||||
hunterTrap: undefined,
|
||||
});
|
||||
expect(burningZone?.fixedDamagePerTick).toBeGreaterThan(0);
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === hunter.id
|
||||
? { ...member, gearStats: { ...member.gearStats, rangedAttackPower: 0 } }
|
||||
: member),
|
||||
}));
|
||||
useCombatStore.getState().tick(1, 4_000);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
occurredAt: 4_000,
|
||||
kind: "damage",
|
||||
sourceActorId: hunter.id,
|
||||
abilityId: trap.id,
|
||||
targetActorId: targetId,
|
||||
rawAmount: burningZone?.fixedDamagePerTick,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
|
||||
function hunterAbilityId(name: string): string {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
return ability!.id;
|
||||
}
|
||||
|
||||
function registerMob(id: string, position: readonly [number, number, number]): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
}
|
||||
|
||||
function stableRoll(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 Hunter trap runtime", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-hunter-trap-runtime-tests",
|
||||
1,
|
||||
useCombatStore.getState().health,
|
||||
);
|
||||
});
|
||||
|
||||
it("arms Freezing Trap, freezes an entrant for its local child-spell duration, and breaks on damage", () => {
|
||||
registerMob("freezing-trap-target", [0, 0, 3]);
|
||||
const abilityId = hunterAbilityId("Freezing Trap");
|
||||
const cast = useCombatStore.getState().castAbility(abilityId, undefined, 1_000);
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
const placed = useCombatStore.getState().areaEffects[0];
|
||||
expect(placed?.hunterTrap?.kind).toBe("freezing");
|
||||
expect(placed?.position).toEqual([0, 0, 0]);
|
||||
expect(useCombatStore.getState().mobs["freezing-trap-target"]?.statuses).toHaveLength(0);
|
||||
|
||||
setMobPosition("freezing-trap-target", [0, 0, 1]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
const freeze = useCombatStore.getState().mobs["freezing-trap-target"]?.statuses.find((status) => (
|
||||
status.kind === "freeze"
|
||||
));
|
||||
expect(freeze).toMatchObject({
|
||||
sourceAbilityId: abilityId,
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
endsAt: 2_000 + (placed?.hunterTrap?.freezeDurationMs ?? 0),
|
||||
breakOnDamage: true,
|
||||
});
|
||||
expect(useCombatStore.getState().areaEffects.some((area) => area.id === placed?.id)).toBe(false);
|
||||
|
||||
useCombatStore.getState().damageMob("freezing-trap-target", 1, 2_001);
|
||||
expect(useCombatStore.getState().mobs["freezing-trap-target"]?.statuses.some((status) => (
|
||||
status.kind === "freeze"
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("activates Frost Trap as a 10yd slow zone and uses Entrapment rank data for the slow and root", () => {
|
||||
registerMob("frost-trap-trigger", [0, 0, 3]);
|
||||
registerMob("frost-trap-nearby", [0, 0, 5]);
|
||||
registerMob("frost-trap-outside", [0, 0, 12]);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1304": 3 },
|
||||
}));
|
||||
const abilityId = hunterAbilityId("Frost Trap");
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
|
||||
setMobPosition("frost-trap-trigger", [0, 0, 1]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
let zone = useCombatStore.getState().areaEffects.find((area) => area.hunterTrap?.kind === "frost-zone");
|
||||
expect(zone).toMatchObject({ radius: 10, expiresAt: 32_000, tickIntervalMs: 2_000 });
|
||||
const seededTrapId = Array.from({ length: 1_000 }, (_, index) => `entrapment-test-zone-${index}`)
|
||||
.find((id) => stableRoll(
|
||||
`${PLAYER_AGGRO_ID}:${id}:frost-trap-trigger:${zone!.nextTickAt}:wow335-hunter-entrapment`,
|
||||
) < 0.06);
|
||||
expect(seededTrapId).toBeDefined();
|
||||
useCombatStore.setState((state) => ({
|
||||
areaEffects: state.areaEffects.map((area) => area.id === zone?.id
|
||||
? { ...area, id: seededTrapId! }
|
||||
: area),
|
||||
}));
|
||||
zone = useCombatStore.getState().areaEffects.find((area) => area.hunterTrap?.kind === "frost-zone");
|
||||
|
||||
useCombatStore.getState().tick(0, 2_001);
|
||||
const zoneSlowSourceId = `hunter-trap-zone:${zone!.id}`;
|
||||
expect(useCombatStore.getState().mobs["frost-trap-trigger"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }),
|
||||
]));
|
||||
expect(useCombatStore.getState().mobs["frost-trap-nearby"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }),
|
||||
]));
|
||||
expect(useCombatStore.getState().mobs["frost-trap-outside"]?.statuses.some((status) => (
|
||||
status.sourceAbilityId === zoneSlowSourceId
|
||||
))).toBe(false);
|
||||
|
||||
const rootAt = Array.from({ length: 14 }, (_, index) => zone!.nextTickAt + index * 2_000)
|
||||
.find((pulseAt) => stableRoll(
|
||||
`${PLAYER_AGGRO_ID}:${zone!.id}:frost-trap-trigger:${pulseAt}:wow335-hunter-entrapment`,
|
||||
) < 0.06);
|
||||
expect(rootAt).toBeDefined();
|
||||
useCombatStore.getState().tick(1, rootAt!);
|
||||
expect(useCombatStore.getState().mobs["frost-trap-trigger"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "root", sourceAbilityId: abilityId, endsAt: rootAt! + 3_000 }),
|
||||
]));
|
||||
|
||||
setMobPosition("frost-trap-nearby", [0, 0, 12]);
|
||||
useCombatStore.getState().tick(0, rootAt! + 1);
|
||||
expect(useCombatStore.getState().mobs["frost-trap-nearby"]?.statuses.some((status) => (
|
||||
status.sourceAbilityId === zoneSlowSourceId
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("applies Immolation Trap's sourced five fire ticks with ranged attack power scaling on activation", () => {
|
||||
registerMob("immolation-trap-target", [0, 0, 3]);
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, rangedAttackPower: 2_000 },
|
||||
}));
|
||||
const abilityId = hunterAbilityId("Immolation Trap");
|
||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
const before = useCombatStore.getState().mobs["immolation-trap-target"]!.health;
|
||||
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("immolation");
|
||||
|
||||
setMobPosition("immolation-trap-target", [0, 0, 1]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
const dot = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.sourceAbilityId === abilityId && effect.tag === "hunter-immolation-trap"
|
||||
));
|
||||
expect(dot).toMatchObject({
|
||||
kind: "dot",
|
||||
targetId: "immolation-trap-target",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
remainingTicks: 5,
|
||||
intervalMs: 3_000,
|
||||
nextTickAt: 5_000,
|
||||
damageSchool: "fire",
|
||||
});
|
||||
expect(dot?.fixedAmount).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs["immolation-trap-target"]!.health).toBe(before);
|
||||
|
||||
useCombatStore.getState().tick(1, 5_000);
|
||||
expect(useCombatStore.getState().mobs["immolation-trap-target"]!.health).toBeLessThan(before);
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
occurredAt: 5_000,
|
||||
kind: "damage",
|
||||
abilityId,
|
||||
targetActorId: "immolation-trap-target",
|
||||
school: "fire",
|
||||
rawAmount: dot?.fixedAmount,
|
||||
}),
|
||||
]));
|
||||
expect(useCombatStore.getState().effects.find((effect) => effect.id === dot?.id)?.remainingTicks).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import {
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { abilityTalentNodeId, abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import { usePartyStore, createConfiguredPartyMember } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import {
|
||||
WOW335_MAGE_ARCANE_CONCENTRATION_TAG,
|
||||
} from "./wow335MageTalentRuntime";
|
||||
import { WOW335_MAGE_ARCANE_POTENCY_TAG } from "./wow335MageConditionalTalentRuntime";
|
||||
|
||||
const TARGET_ID = "mage-conditional-mechanics-target";
|
||||
const FIREBALL_ID = "wow335-mage-fireball";
|
||||
const LIVING_BOMB_ID = "wow335-mage-living-bomb";
|
||||
|
||||
function auraOn(targetId: string, tag: string): boolean {
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.utilityTags?.includes(tag));
|
||||
}
|
||||
|
||||
function stableUnit(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
for (const character of seed) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function seedBelow(chance: number, makeSeed: (index: number) => string): string {
|
||||
for (let index = 0; index < 100_000; index += 1) {
|
||||
const seed = makeSeed(index);
|
||||
if (stableUnit(seed) < chance) return seed;
|
||||
}
|
||||
throw new Error("Could not find a deterministic proc seed.");
|
||||
}
|
||||
|
||||
async function resetMage(): Promise<void> {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
await preloadAbilityCatalogsForClasses(["mage"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("wow335-mage-conditional-tests", 80,
|
||||
useCombatStore.getState().health, "damage");
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [4, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function makePartyMage(talentRanks: Readonly<Record<string, number>> = {}) {
|
||||
const abilities = abilitiesForClass("mage");
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Test Mage",
|
||||
classId: "mage",
|
||||
specialization: "Fire",
|
||||
specializationId: "mage:fire",
|
||||
talentTreeId: "mage-fire",
|
||||
talentRanks,
|
||||
role: "damage",
|
||||
}, 80, 0, "wow335-mage-conditional-party");
|
||||
const fireball = abilities.find((ability) => ability.name === "Fireball")!;
|
||||
const lockedCooldowns = Object.fromEntries(abilities
|
||||
.filter((ability) => ability.id !== fireball.id)
|
||||
.map((ability) => [ability.id, 1_000_000]));
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 1_000_000, spellPower: 5_000 },
|
||||
resourceType: "mana",
|
||||
resourceName: "Mana",
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...member.resourcePools, mana: 10_000 },
|
||||
talentRanks,
|
||||
cooldowns: lockedCooldowns,
|
||||
nextActionAt: 0,
|
||||
globalCooldownEndsAt: 0,
|
||||
}] });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
||||
usePartyStore.getState().setCommand("attack");
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, 100);
|
||||
return { memberId: member.id, fireball };
|
||||
}
|
||||
|
||||
function applyTargetSlow(kind: "slow" | "attack-speed"): void {
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[TARGET_ID]!;
|
||||
return { mobs: {
|
||||
...state.mobs,
|
||||
[TARGET_ID]: {
|
||||
...target,
|
||||
statuses: [...target.statuses, {
|
||||
kind,
|
||||
sourceAbilityId: "test-mage-slow",
|
||||
endsAt: 20_000,
|
||||
}],
|
||||
},
|
||||
} };
|
||||
});
|
||||
}
|
||||
|
||||
async function castPlayerFireballWithSlow(slowKind?: "slow" | "attack-speed"): Promise<number> {
|
||||
await resetMage();
|
||||
const talentRanks = { ...useCombatStore.getState().talentRanks, "talent-2222": 3 };
|
||||
useCombatStore.setState({
|
||||
talentRanks,
|
||||
talentModifiers: talentModifiersForRanks(talentRanks, "mage"),
|
||||
});
|
||||
if (slowKind) applyTargetSlow(slowKind);
|
||||
expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast!;
|
||||
useCombatStore.getState().tick(0.1, cast.completesAt);
|
||||
return useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === FIREBALL_ID && event.targetActorId === TARGET_ID
|
||||
))?.rawAmount ?? 0;
|
||||
}
|
||||
|
||||
async function castPartyFireballWithSlow(slowKind?: "slow" | "attack-speed"): Promise<number> {
|
||||
await resetMage();
|
||||
const { memberId, fireball } = makePartyMage({ "talent-2222": 3 });
|
||||
if (slowKind) applyTargetSlow(slowKind);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const cast = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(cast.abilityId).toBe(fireball.id);
|
||||
advanceDungeonPartyCombat(cast.completesAt);
|
||||
return useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === memberId
|
||||
&& event.abilityId === fireball.id && event.targetActorId === TARGET_ID
|
||||
))?.rawAmount ?? 0;
|
||||
}
|
||||
|
||||
async function castPlayerMissilesWithSlow(slowKind?: "slow" | "attack-speed"): Promise<number[]> {
|
||||
await resetMage();
|
||||
const talentRanks = { ...useCombatStore.getState().talentRanks, "talent-2222": 3 };
|
||||
useCombatStore.setState({
|
||||
talentRanks,
|
||||
talentModifiers: talentModifiersForRanks(talentRanks, "mage"),
|
||||
});
|
||||
if (slowKind) applyTargetSlow(slowKind);
|
||||
const missiles = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Missiles")!;
|
||||
expect(useCombatStore.getState().castAbility(missiles.id, undefined, 1_000).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast!;
|
||||
useCombatStore.getState().tick(0.1, cast.completesAt);
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === missiles.id && event.targetActorId === TARGET_ID
|
||||
)).map((event) => event.rawAmount);
|
||||
}
|
||||
|
||||
async function castPartyMissilesWithSlow(slowKind?: "slow" | "attack-speed"): Promise<number[]> {
|
||||
await resetMage();
|
||||
const { memberId } = makePartyMage({ "talent-2222": 3 });
|
||||
const missiles = abilitiesForClass("mage").find((ability) => ability.name === "Arcane Missiles")!;
|
||||
if (slowKind) applyTargetSlow(slowKind);
|
||||
const member = usePartyStore.getState().members[0];
|
||||
const castDurationMs = missiles.castTimeMs;
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
activeCast: {
|
||||
abilityId: missiles.id,
|
||||
school: "arcane",
|
||||
mode: "channel",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000 + castDurationMs,
|
||||
originalDurationMs: castDurationMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
cooldowns: {},
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const cast = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(cast.abilityId).toBe(missiles.id);
|
||||
expect(cast.mode).toBe("channel");
|
||||
advanceDungeonPartyCombat(cast.completesAt);
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === memberId
|
||||
&& event.abilityId === missiles.id && event.targetActorId === TARGET_ID
|
||||
)).map((event) => event.rawAmount);
|
||||
}
|
||||
|
||||
describe("WoW Mage conditional mechanics integration", () => {
|
||||
beforeEach(resetMage);
|
||||
|
||||
it("grants Arcane Potency from Presence of Mind, applies rank crit to the next spell, and consumes it on any spell", () => {
|
||||
const abilities = useCombatStore.getState().abilities;
|
||||
const presenceOfMind = abilities.find((ability) => ability.name === "Presence of Mind")!;
|
||||
const intellect = abilities.find((ability) => ability.name === "Arcane Intellect")!;
|
||||
const pomTalentId = abilityTalentNodeId(presenceOfMind)!;
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
|
||||
talentRanks: { ...state.talentRanks, [pomTalentId]: 1, "talent-1725": 2 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(presenceOfMind.id, undefined, 1_000).ok).toBe(true);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 2_000).ok).toBe(true);
|
||||
// Presence of Mind makes Fireball resolve immediately; the AP bonus must
|
||||
// reach that direct damage event without a lingering aura.
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false);
|
||||
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||
kind: "damage",
|
||||
abilityId: FIREBALL_ID,
|
||||
targetActorId: TARGET_ID,
|
||||
critical: true,
|
||||
}));
|
||||
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBeGreaterThanOrEqual(30);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [presenceOfMind.id]: 0 },
|
||||
globalCooldownEndsAt: 0,
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(presenceOfMind.id, undefined, 20_000).ok).toBe(true);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(intellect.id, undefined, 22_000).ok).toBe(true);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it("grants party Arcane Potency from Presence of Mind and consumes it on the next cast", () => {
|
||||
const pom = abilitiesForClass("mage").find((ability) => ability.name === "Presence of Mind")!;
|
||||
const pomTalentId = abilityTalentNodeId(pom)!;
|
||||
const { memberId, fireball } = makePartyMage({ "talent-1725": 2, [pomTalentId]: 1 });
|
||||
const member = usePartyStore.getState().members[0];
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
activeCast: {
|
||||
abilityId: pom.id,
|
||||
school: "arcane",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: memberId,
|
||||
},
|
||||
activeCastTargetId: memberId,
|
||||
status: "healing",
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true);
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((entry) => ({ ...entry, nextActionAt: 0 })),
|
||||
}));
|
||||
advanceDungeonPartyCombat(2_000);
|
||||
const started = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(started.abilityId).toBe(fireball.id);
|
||||
expect(started.arcanePotencyCriticalChanceBonusPercent).toBe(30);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false);
|
||||
advanceDungeonPartyCombat(started.completesAt);
|
||||
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||
kind: "damage",
|
||||
sourceActorId: memberId,
|
||||
targetActorId: TARGET_ID,
|
||||
abilityId: fireball.id,
|
||||
critical: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("procs party Arcane Potency from Arcane Concentration Clearcasting", () => {
|
||||
const { memberId } = makePartyMage({ "talent-75": 3, "talent-1725": 2 });
|
||||
const fireball = abilitiesForClass("mage").find((ability) => ability.name === "Fireball")!;
|
||||
const expectedProcAt = 4_500;
|
||||
let procTargetId = "mage-party-clearcasting-target-0";
|
||||
for (let index = 0; stableUnit(
|
||||
`${memberId}:${fireball.id}:${procTargetId}:${expectedProcAt}:wow335-mage-arcane-concentration`,
|
||||
) >= 0.15; index += 1) {
|
||||
procTargetId = `mage-party-clearcasting-target-${index + 1}`;
|
||||
}
|
||||
useCombatStore.getState().registerMob(procTargetId, {
|
||||
name: "Clearcasting Target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition(procTargetId, [4, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(memberId, procTargetId, true);
|
||||
useCombatStore.getState().damageMob(procTargetId, 1, 100);
|
||||
useCombatStore.getState().selectMob(procTargetId);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const cast = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(cast.abilityId).toBe(fireball.id);
|
||||
expect(cast.completesAt).toBe(expectedProcAt);
|
||||
advanceDungeonPartyCombat(cast.completesAt);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(true);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.filter((aura) => !(aura.targetId === procTargetId && aura.sourceId === memberId)),
|
||||
effects: state.effects.filter((effect) => !(effect.targetId === procTargetId
|
||||
&& effect.sourceAbilityId === fireball.id)),
|
||||
}));
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((member) => ({
|
||||
...member,
|
||||
nextActionAt: 0,
|
||||
globalCooldownEndsAt: 0,
|
||||
cooldowns: {},
|
||||
})) }));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(cast.completesAt + 3_000);
|
||||
const empoweredCast = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(empoweredCast.abilityId).toBe(fireball.id);
|
||||
expect(empoweredCast.manaSpent).toBe(0);
|
||||
expect(empoweredCast.arcanePotencyCriticalChanceBonusPercent).toBe(30);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(false);
|
||||
expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it("procs player Arcane Potency from Arcane Concentration Clearcasting and consumes both on a free cast", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-75": 3, "talent-1725": 2 },
|
||||
}));
|
||||
const fireball = useCombatStore.getState().abilities.find((ability) => ability.id === FIREBALL_ID)!;
|
||||
const seed = seedBelow(0.15, (index) => {
|
||||
const candidate = "mage-arcane-concentration-seed-" + index;
|
||||
return stableUnit(candidate + ":mage-arcane-concentration") < 0.15
|
||||
? candidate
|
||||
: "outside-proc-window-" + index;
|
||||
});
|
||||
const hit = useCombatStore.getState().damageMob(TARGET_ID, 10, 1_000, undefined, "fire", {
|
||||
attackKind: "spell",
|
||||
seed,
|
||||
abilityId: fireball.id,
|
||||
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false },
|
||||
});
|
||||
expect(hit.amount).toBeGreaterThan(0);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(true);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true);
|
||||
|
||||
const intellect = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Intellect")!;
|
||||
const manaBefore = useCombatStore.getState().resourcePools.mana;
|
||||
expect(useCombatStore.getState().castAbility(intellect.id, undefined, 2_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(false);
|
||||
expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it("refunds 40% of actual Mana spent on a direct spell crit, even when the hit is fully absorbed", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1639": 3 },
|
||||
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
|
||||
}));
|
||||
const state = useCombatStore.getState();
|
||||
const manaBefore = state.resourcePools.mana!;
|
||||
const absorb = applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "mage-mechanic-fire-absorb",
|
||||
name: "Test Fire Absorb",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
absorbs: [{ id: "fire", amount: 1_000_000, school: "fire" }],
|
||||
},
|
||||
sourceId: TARGET_ID,
|
||||
targetId: TARGET_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: [...absorb.auras] });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast!;
|
||||
const manaSpent = cast.manaSpent!;
|
||||
expect(cast.manaSpent).toBe(manaSpent);
|
||||
useCombatStore.getState().tick(0.1, cast.completesAt);
|
||||
const damage = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === FIREBALL_ID && event.targetActorId === TARGET_ID
|
||||
));
|
||||
expect(damage).toMatchObject({ critical: true, effectiveAmount: 0 });
|
||||
const refund = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "resource" && event.abilityId === FIREBALL_ID
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||
));
|
||||
const expectedRefund = Math.round(manaSpent * 0.4);
|
||||
expect(refund?.effectiveAmount).toBe(expectedRefund);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore - manaSpent + expectedRefund);
|
||||
});
|
||||
|
||||
it("caps a player Master of Elements refund at maximum Mana", () => {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1639": 3 },
|
||||
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true);
|
||||
const cast = useCombatStore.getState().activeCast!;
|
||||
const actualCost = cast.manaSpent!;
|
||||
// Simulate Mana refilled during the cast while retaining its spend snapshot,
|
||||
// so the refund request exceeds the available room and must be capped.
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: state.maxResourcePools.mana!,
|
||||
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! },
|
||||
activeCast: { ...cast, manaSpent: actualCost * 4 },
|
||||
}));
|
||||
const maximumMana = useCombatStore.getState().maxResourcePools.mana!;
|
||||
useCombatStore.getState().tick(0.1, cast.completesAt);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(maximumMana);
|
||||
const refund = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "resource" && event.abilityId === FIREBALL_ID
|
||||
&& event.targetActorId === PLAYER_AURA_ENTITY_ID
|
||||
));
|
||||
expect(refund?.effectiveAmount).toBe(actualCost);
|
||||
expect(refund?.overheal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refunds party Mage Mana on direct crits and caps at maximum", () => {
|
||||
const { fireball } = makePartyMage({ "talent-1639": 3 });
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const cast = usePartyStore.getState().members[0].activeCast!;
|
||||
expect(cast.abilityId).toBe(fireball.id);
|
||||
expect(cast.manaSpent).toBeGreaterThan(0);
|
||||
const spentMana = usePartyStore.getState().members[0].resourcePools.mana!;
|
||||
advanceDungeonPartyCombat(cast.completesAt);
|
||||
const refundedMana = usePartyStore.getState().members[0].resourcePools.mana!;
|
||||
expect(refundedMana).toBeGreaterThan(spentMana);
|
||||
|
||||
const current = usePartyStore.getState().members[0];
|
||||
const maxMana = current.maxResource;
|
||||
const capCast = {
|
||||
...cast,
|
||||
startedAt: 6_000,
|
||||
completesAt: 9_500,
|
||||
originalDurationMs: 3_500,
|
||||
manaSpent: 10_000,
|
||||
};
|
||||
usePartyStore.setState({ members: [{
|
||||
...current,
|
||||
resource: maxMana - 5,
|
||||
resourcePools: { ...current.resourcePools, mana: maxMana - 5 },
|
||||
nextActionAt: 0,
|
||||
globalCooldownEndsAt: 0,
|
||||
activeCast: capCast,
|
||||
activeCastTargetId: TARGET_ID,
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(capCast.completesAt);
|
||||
const cappedMember = usePartyStore.getState().members[0];
|
||||
expect(cappedMember.resourcePools.mana).toBe(maxMana);
|
||||
expect(capCast.manaSpent * 0.4).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("applies Empowered Fire scaling to Living Bomb ticks and its final explosion", () => {
|
||||
const ability = useCombatStore.getState().abilities.find((entry) => entry.id === LIVING_BOMB_ID)!;
|
||||
const talentId = abilityTalentNodeId(ability)!;
|
||||
const baseDot = ability.effects.find((effect) => effect.kind === "dot")!;
|
||||
expect(baseDot.kind).toBe("dot");
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, [talentId]: 1, "talent-1734": 3 },
|
||||
gearStats: { ...state.gearStats, spellPower: 5_000 },
|
||||
}));
|
||||
useCombatStore.getState().registerMob("mage-conditional-mechanics-neighbor", {
|
||||
name: "Neighbor", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("mage-conditional-mechanics-neighbor", [6, 0, 0]);
|
||||
expect(useCombatStore.getState().castAbility(LIVING_BOMB_ID, undefined, 1_000).ok).toBe(true);
|
||||
const dot = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.targetId === TARGET_ID && effect.sourceAbilityId === LIVING_BOMB_ID
|
||||
));
|
||||
const originalBonusCoefficient = baseDot.kind === "dot"
|
||||
? baseDot.bonusPowerCoefficient ?? baseDot.coefficient
|
||||
: 0;
|
||||
expect(dot?.bonusPowerCoefficient).toBeCloseTo(originalBonusCoefficient * 1.2);
|
||||
const neighborBefore = useCombatStore.getState().mobs["mage-conditional-mechanics-neighbor"].health;
|
||||
useCombatStore.getState().tick(0, 13_000);
|
||||
expect(useCombatStore.getState().mobs["mage-conditional-mechanics-neighbor"].health).toBeLessThan(neighborBefore);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.abilityId === LIVING_BOMB_ID
|
||||
&& event.targetActorId === "mage-conditional-mechanics-neighbor" && event.occurredAt === 13_000
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not refund Mana for periodic party channel crits", () => {
|
||||
const { memberId } = makePartyMage({ "talent-1639": 3 });
|
||||
const missiles = abilitiesForClass("mage").find((ability) => ability.name === "Arcane Missiles")!;
|
||||
const member = usePartyStore.getState().members[0];
|
||||
const activeCast = {
|
||||
abilityId: missiles.id,
|
||||
school: "arcane" as const,
|
||||
mode: "channel" as const,
|
||||
startedAt: 1_000,
|
||||
completesAt: 3_000,
|
||||
originalDurationMs: 2_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0] as const,
|
||||
targetId: TARGET_ID,
|
||||
manaSpent: 500,
|
||||
arcanePotencyCriticalChanceBonusPercent: 1_000,
|
||||
};
|
||||
usePartyStore.setState({ members: [{
|
||||
...member,
|
||||
activeCast,
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
cooldowns: {},
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const manaBefore = usePartyStore.getState().members[0].resourcePools.mana!;
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === memberId
|
||||
&& event.abilityId === missiles.id && event.critical
|
||||
))).toBe(true);
|
||||
const manaAfter = usePartyStore.getState().members[0].resourcePools.mana!;
|
||||
// Includes the ordinary small regeneration applied by party runtime.
|
||||
expect(manaAfter - manaBefore).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("applies Torment the Weak to player Fireball only while a modeled slow is active", async () => {
|
||||
const normal = await castPlayerFireballWithSlow();
|
||||
const movementSlowed = await castPlayerFireballWithSlow("slow");
|
||||
expect(normal).toBeGreaterThan(0);
|
||||
expect(movementSlowed).toBe(Math.round(normal * 1.09));
|
||||
});
|
||||
|
||||
it("applies Torment the Weak to party Mage Fireball against movement- and attack-speed-slowed targets", async () => {
|
||||
const normal = await castPartyFireballWithSlow();
|
||||
const movementSlowed = await castPartyFireballWithSlow("slow");
|
||||
const attackSlowed = await castPartyFireballWithSlow("attack-speed");
|
||||
expect(normal).toBeGreaterThan(0);
|
||||
expect(movementSlowed).toBe(Math.round(normal * 1.09));
|
||||
expect(attackSlowed).toBe(Math.round(normal * 1.09));
|
||||
});
|
||||
|
||||
it("applies Torment the Weak to each player and party Arcane Missiles channel pulse", async () => {
|
||||
const playerNormal = await castPlayerMissilesWithSlow();
|
||||
const playerSlowed = await castPlayerMissilesWithSlow("slow");
|
||||
expect(playerSlowed).toHaveLength(playerNormal.length);
|
||||
expect(playerNormal.length).toBeGreaterThan(0);
|
||||
expect(playerSlowed).toEqual(playerNormal.map((amount) => Math.round(amount * 1.09)));
|
||||
|
||||
const partyNormal = await castPartyMissilesWithSlow();
|
||||
const partySlowed = await castPartyMissilesWithSlow("slow");
|
||||
expect(partySlowed).toHaveLength(partyNormal.length);
|
||||
expect(partyNormal.length).toBeGreaterThan(0);
|
||||
expect(partySlowed).toEqual(partyNormal.map((amount) => Math.round(amount * 1.09)));
|
||||
});
|
||||
});
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user