Update Healer Man server

This commit is contained in:
phenom committed 2026-09-30 09:57:13 -04:00
1 parent 35dfdaed61
commit 4fb7a0b9c8
161 files changed
+24618 -529

No files matched your search

+5 -5
View File
@@ -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)) {
+1 -1
View File
@@ -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);
}
+4
View File
@@ -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,
+7 -1
View File
@@ -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 ||
+48
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+158
View File
@@ -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);
+3 -3
View File
@@ -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(
+101
View File
@@ -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)]);
+11
View File
@@ -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
View File
@@ -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;
}
+468 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+13 -1
View File
@@ -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[];
+65 -3
View File
@@ -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);
+8 -2
View File
@@ -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" }),
]));
+9 -1
View File
@@ -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;
+193
View File
@@ -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
+248 -2
View File
@@ -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,
});
});
});
+1 -1
View File
@@ -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([]);
});
+19 -2
View File
@@ -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.
+74 -3
View File
@@ -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);
});
+70 -4
View File
@@ -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;
+264 -3
View File
@@ -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);
});
});
+85
View File
@@ -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]);
});
});
+139 -1
View File
@@ -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,
}];
}
+140 -1
View File
@@ -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();
});
});
+106
View File
@@ -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();
});
});
+40
View File
@@ -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]),
},
};
});
}
+154
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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",
+25 -25
View File
@@ -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);
});
+189
View File
@@ -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);
});
});
+86
View File
@@ -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"]);
}
});
});
+55 -1
View File
@@ -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);
});
});
+53
View File
@@ -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"]);
});
});
+80
View File
@@ -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, {
+131 -1
View File
@@ -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();
});
});
+104
View File
@@ -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" });
});
});
+114
View File
@@ -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,
};
}
+34
View File
@@ -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);
});
});
+1 -1
View File
@@ -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.",
};
+112
View File
@@ -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);
});
});
+50 -4
View File
@@ -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"
+50
View File
@@ -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 },
],
},
};
}
+344
View File
@@ -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) => {
+23
View File
@@ -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,
});
});
});
+16
View File
@@ -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,
};
}
+156
View File
@@ -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);
});
});
+137
View File
@@ -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);
}
});
});
+72
View File
@@ -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,
}],
},
};
}
+294 -2
View File
@@ -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);
});
});
+65
View File
@@ -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));
});
});
+66
View File
@@ -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,
};
}
+155 -6
View File
@@ -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 {
+83 -2
View File
@@ -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" },
];
}
+203 -3
View File
@@ -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))
+15
View File
@@ -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.
+128 -2
View File
@@ -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) => ({
+72 -2
View File
@@ -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);
}
});
});
+18
View File
@@ -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;
+108
View File
@@ -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
View File
@@ -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) {
+111 -15
View File
@@ -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,
};
}
+103
View File
@@ -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" });
});
});
+17
View File
@@ -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,
};
}
+131
View File
@@ -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);
});
});
+353 -1
View File
@@ -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();
});
});
+38
View File
@@ -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);
}
});
});
+120
View File
@@ -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 }),
]));
});
});
+241 -8
View File
@@ -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 });
+188
View File
@@ -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,
}),
]));
});
});
+172
View File
@@ -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