Update Healer Man server

This commit is contained in:
phenom committed 2026-10-02 10:38:12 -04:00
1 parent 4c768fbec6
commit b16821e92e
330 files changed
+35406 -1760

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager";
import { CombatBridge } from "./game/CombatBridge";
import { connectDpsMeter } from "./game/dpsMeterStore";
import { connectFloatingCombatText } from "./game/floatingCombatText";
import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
@@ -116,6 +117,7 @@ function InputBridge() {
export default function GameRuntime() {
useEffect(connectDpsMeter, []);
useEffect(connectFloatingCombatText, []);
useEffect(() => installCombatClock(dungeonSimulationNow), []);
useEffect(() => { void useDungeonRouteStore.getState().refresh(); }, []);
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
+46 -1
View File
@@ -5,7 +5,10 @@ import {
dungeonEncounterSnapshot,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import { applySharedWorld } from "./OnlineSessionBridge";
import { applySharedWorld, eventForRelay } from "./OnlineSessionBridge";
import { PLAYER_AGGRO_ID } from "./aggro";
import { usePartyStore } from "./partyStore";
import type { StructuredCombatEvent } from "./combatEvents";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
import {
@@ -75,3 +78,45 @@ describe("online shared-world hydration", () => {
expect(useCombatStore.getState().inventory).toEqual([]);
});
});
describe("online damage presentation relay", () => {
it("relays authority companion hits so followers can display them", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
useCombatStore.getState().registerMob("relay-mob", { name: "Relay mob", maxHealth: 100 });
const oldMembers = usePartyStore.getState().members;
usePartyStore.setState({ members: [{ id: "companion" } as (typeof oldMembers)[number]] });
try {
const event = {
id: 1, occurredAt: 1_000, kind: "damage", sourceActorId: "companion", targetActorId: "relay-mob",
rawAmount: 55, effectiveAmount: 40, overheal: 0, absorbed: 0, critical: true,
} satisfies StructuredCombatEvent;
expect(eventForRelay(event, "leader", true)).toMatchObject({
kind: "damage", sourceActorId: "companion", targetActorId: "relay-mob", effectiveAmount: 40, critical: true,
});
expect(eventForRelay(event, "leader", false)).toBeNull();
} finally {
usePartyStore.setState({ members: oldMembers });
}
});
it("relays authority AI hits on the leader and companions with their crit flag", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
const oldMembers = usePartyStore.getState().members;
usePartyStore.setState({ members: [{ id: "companion" } as (typeof oldMembers)[number]] });
try {
const hit = {
id: 2, occurredAt: 2_000, kind: "damage", sourceActorId: "mob", targetActorId: PLAYER_AGGRO_ID,
rawAmount: 80, effectiveAmount: 50, overheal: 0, absorbed: 30, critical: true,
} satisfies StructuredCombatEvent;
expect(eventForRelay(hit, "leader", true)).toMatchObject({
sourceActorId: "mob", targetActorId: "online-player:leader", effectiveAmount: 50, critical: true,
});
expect(eventForRelay({ ...hit, targetActorId: "companion" }, "leader", true)).toMatchObject({
sourceActorId: "mob", targetActorId: "companion", effectiveAmount: 50, critical: true,
});
expect(eventForRelay(hit, "leader", false)).toBeNull();
} finally {
usePartyStore.setState({ members: oldMembers });
}
});
});
+17 -3
View File
@@ -28,6 +28,7 @@ import {
} from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { ingestRelayDamageText, withSuppressedReplayDamageText } from "./floatingCombatText";
import { useManastormStore } from "./manastormStore";
import { advanceManastormSession } from "./manastormSession";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
@@ -112,7 +113,7 @@ export function applySharedWorld(world: OnlineSharedWorld): void {
}
}
function eventForRelay(
export function eventForRelay(
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
localAccountId: string,
authority: boolean,
@@ -124,7 +125,17 @@ function eventForRelay(
));
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
const partyMembers = usePartyStore.getState().members;
const sourceIsCompanion = partyMembers.some((member) => member.id === event.sourceActorId)
|| combat.summons.some((summon) => partyMembers.some((member) => member.id === summon.ownerActorId)
&& summon.id === event.sourceActorId);
const companionDamageToMob = authority && event.kind === "damage"
&& Boolean(combat.mobs[event.targetActorId]) && sourceIsCompanion;
const authorityDamageToFriendly = authority && event.kind === "damage" && !sourceIsLocal
&& (event.targetActorId === PLAYER_AGGRO_ID
|| partyMembers.some((member) => member.id === event.targetActorId));
if (!sourceIsLocal && !(authority && targetsRemotePlayer)
&& !companionDamageToMob && !authorityDamageToFriendly) return null;
const localActorId = onlinePlayerActorId(localAccountId);
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
? localActorId
@@ -342,7 +353,10 @@ export function OnlineSessionBridge() {
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
}
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
for (const event of snapshot.events) {
withSuppressedReplayDamageText(() => applyRelayEvent(event, snapshot, applyingRef));
ingestRelayDamageText(event, snapshot);
}
useCombatStore.getState().synchronizeActors();
accept(snapshot);
} catch (error) {
+73 -7
View File
@@ -140,13 +140,19 @@ export interface AbilityAmountScaling {
readonly bonusPowerCoefficient?: number;
readonly attackPowerCoefficient?: number;
readonly rangedAttackPowerCoefficient?: number;
/** Add a fraction of the source character's Intelligence to the resolved amount. */
readonly intellectCoefficient?: number;
readonly spiritCoefficient?: number;
/** Add a fraction of the source character's Stamina to the resolved amount. */
readonly staminaCoefficient?: number;
/** Add main-hand weapon DPS multiplied by this value to the resolved amount. */
readonly weaponDpsCoefficient?: number;
readonly sourceLevel?: number;
readonly maxScalingLevel?: number;
}
export interface AbilityHealingRecipients {
readonly recipient?: "caster" | "ability-target";
readonly recipient?: "caster" | "ability-target" | "pet";
readonly scope?: "party";
readonly center?: "caster" | "ability-target";
readonly radius?: number;
@@ -170,7 +176,7 @@ export type AbilityEffect =
/** Optional stack-based payload used by poison-consuming finishers. */
readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling;
}
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number; readonly requiresLandedHit?: boolean } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
@@ -185,7 +191,14 @@ export type AbilityEffect =
/** Source-defined party absorb area carried by a stationary Death Knight zone. */
readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number };
}
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
| {
readonly kind: "resource";
readonly amount: number;
readonly resource?: ResourceType;
readonly percentage?: boolean;
/** Party-scoped resource effects use the ability's radius and targeting shape. */
readonly scope?: "target" | "party";
}
| { readonly kind: "combo"; readonly amount: number }
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
@@ -196,6 +209,8 @@ export type AbilityEffect =
readonly recipient?: "ability-target" | "caster" | "pet";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
/** Optional range for a party aura sourced by a nearby-target proc. */
readonly radius?: number;
/** Some party effects benefit allies but exclude the caster. */
readonly excludeCaster?: boolean;
readonly control?: AbilityAuraControl;
@@ -203,6 +218,10 @@ export type AbilityEffect =
readonly sourceAuraType: number;
readonly miscValue: number;
readonly triggerSpellId: number;
/** Resolve this rider only if the cast landed at least one direct hit. */
readonly requiresLandedHit?: boolean;
/** Resolve this rider only if the cast landed at least one direct critical hit. */
readonly requiresCriticalHit?: boolean;
}
| {
readonly kind: "dispel";
@@ -227,6 +246,8 @@ export type AbilityEffect =
readonly kind: "movement";
readonly movement: "teleport" | "leap" | "charge" | "knockback" | "sanctuary";
readonly toward: "target" | "destination" | "away";
/** Source row movement distance in yards when the spell supplies one. */
readonly distance?: number;
}
| { readonly kind: "dispel-mechanic"; readonly mechanic: number; readonly maxCount: number }
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
@@ -340,6 +361,15 @@ export interface AbilityDefinition {
readonly talentNodeId?: string;
/** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */
readonly talentEntryId?: number;
/** A pair-only upgrade applied after its source passive unlock level. */
readonly buildUpgrade?: AbilityBuildUpgrade;
}
export interface AbilityBuildUpgrade {
readonly unlockLevel: number;
readonly values: Partial<Pick<AbilityDefinition,
"description" | "target" | "castMode" | "castTimeMs" | "cooldownMs" | "cost" | "costs" | "effects"
>>;
}
export interface ClassResourceProfile {
@@ -1063,7 +1093,37 @@ export function abilitiesForCharacter(
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
));
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
return [...primary, ...secondaryGeneral, ...elites];
const characterAbilities = [...primary, ...secondaryGeneral, ...elites];
if (classId !== "rom-warlock" || secondaryClassId !== "rom-priest") return characterAbilities;
return characterAbilities.map((ability) => {
if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256) {
return {
...ability,
buildUpgrade: {
unlockLevel: 30,
values: {
castMode: "instant",
castTimeMs: 0,
cooldownMs: 5_000,
description: "Instant cast with a 5-second cooldown. Restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
},
},
};
}
if (ability.classId === "rom-warlock" && ability.dbcSpellId === 497958) {
return {
...ability,
buildUpgrade: {
unlockLevel: 15,
values: {
target: "any",
description: "Can target a hostile enemy to deal damage or a friendly ally to heal them when Healing Fortune is learned. Ally-target redirection is not modeled.",
},
},
};
}
return ability;
});
}
/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */
@@ -1140,6 +1200,12 @@ export function learnedAbilityForBuild(
return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank);
}
function applyBuildUpgradeAtLevel(ability: AbilityDefinition, playerLevel: number): AbilityDefinition {
return ability.buildUpgrade && playerLevel >= ability.buildUpgrade.unlockLevel
? { ...ability, ...ability.buildUpgrade.values }
: ability;
}
export function abilityAtLevel(
ability: AbilityDefinition,
playerLevel: number,
@@ -1148,8 +1214,8 @@ export function abilityAtLevel(
const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank);
const effects = rank?.effects ?? ability.effects;
const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target;
if (!rank) return target === ability.target ? ability : { ...ability, target };
return {
if (!rank) return applyBuildUpgradeAtLevel(target === ability.target ? ability : { ...ability, target }, playerLevel);
return applyBuildUpgradeAtLevel({
...ability,
target,
dbcSpellId: rank.spellId,
@@ -1167,7 +1233,7 @@ export function abilityAtLevel(
targeting: rank.targeting ?? ability.targeting,
castRules: rank.castRules ?? ability.castRules,
runeCost: rank.runeCost ?? ability.runeCost,
};
}, playerLevel);
}
export function isAbilityUnlocked(ability: AbilityDefinition, playerLevel: number): boolean {
+10
View File
@@ -6,6 +6,13 @@ export type CombatResourceId = string;
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
export type CombatActorPosition = readonly [number, number, number];
export type DeathKnightRuneConversionSource = "blood-tap" | "reaping";
export interface DeathKnightRuneConversion {
readonly source: DeathKnightRuneConversionSource;
readonly expiresAt: number;
}
export interface CombatResourcePoolState {
readonly id: CombatResourceId;
readonly current: number;
@@ -20,7 +27,10 @@ export interface DeathKnightRuneState {
readonly baseType: Exclude<DeathKnightRuneType, "death">;
readonly type: DeathKnightRuneType;
readonly readyAt: number;
/** Legacy/latest expiry retained for existing Blood Tap consumers. */
readonly deathRuneExpiresAt?: number;
/** Independent aura ownership prevents one temporary conversion expiring another. */
readonly deathRuneConversions?: readonly DeathKnightRuneConversion[];
}
export interface CombatActorCastState {
+18
View File
@@ -57,6 +57,24 @@ describe("channel amount expansion", () => {
expect(channelPulseAt(accelerated, 0, 1_000, 1)).toBe(500);
});
it("honors an explicit channel cadence through the cast end", () => {
const mindFlay = abilitiesForClass("priest").find((candidate) => candidate.name === "Mind Flay")!;
const necroticWoundChannel = {
...mindFlay,
castMode: "channel" as const,
castTimeMs: 1_500,
channelPulses: 3,
channelTickMs: 500,
};
expect([0, 1, 2].map((pulse) => channelPulseAt(necroticWoundChannel, 1_000, 1_500, pulse)))
.toEqual([1_500, 2_000, 2_500]);
const penance = abilitiesForClass("priest").find((candidate) => candidate.name === "Penance")!;
const explicitPenance = { ...penance, channelTickMs: 1_000 };
expect([0, 1, 2].map((pulse) => channelPulseAt(explicitPenance, 1_000, explicitPenance.castTimeMs, pulse)))
.toEqual([1_000, 2_000, 3_000]);
});
it("gives damaging and healing classic channels a pulse payload", () => {
const expected = [
["priest", "Penance"], ["priest", "Mind Flay"], ["priest", "Mind Sear"],
+10
View File
@@ -1,4 +1,5 @@
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { isRomDruidRoguePair } from "./romDruidRogueCurrentCoa";
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
@@ -12,6 +13,10 @@ export function channelPulseCount(ability: AbilityDefinition): number {
}
export function channelPulseAt(ability: AbilityDefinition, startedAt: number, durationMs: number, pulseIndex: number): number {
if (ability.channelTickMs && ability.channelTickMs > 0) {
const intervals = Math.max(1, channelPulseCount(ability) - (ability.channelStartsWithPulse ? 1 : 0));
return startedAt + (pulseIndex + (ability.channelStartsWithPulse ? 0 : 1)) * durationMs / intervals;
}
const intervals = Math.max(1, channelPulseCount(ability) - (ability.channelStartsWithPulse ? 1 : 0));
return startedAt + (pulseIndex + (ability.channelStartsWithPulse ? 0 : 1)) * durationMs / intervals;
}
@@ -23,12 +28,17 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
}[] {
const ticks = Math.max(1, totalTicks);
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
const hasPerHitNecroticWoundDamage = ability.source === "runewaker"
&& ability.dbcSpellId === 494321
&& isRomDruidRoguePair(ability);
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
amounts.push({ effect, multiplier: ability.name === "Penance"
? 1
: ability.dbcSpellId === 491164 && effect.kind === "damage"
? 1
: hasPerHitNecroticWoundDamage && effect.kind === "damage"
? 1
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) {
+23
View File
@@ -0,0 +1,23 @@
import type { DamageSchool } from "./combatAuras";
/**
* A damage packet can carry more than one school tag. This is distinct from a
* single DamageSchool: for example, CoA's "Physical Dark" must remain both
* physical and Dark (represented by the project's shadow school mapping).
*/
export interface CombatDamageProfile {
readonly schools: readonly DamageSchool[];
}
/** CoA Dark is represented by the simulator's existing shadow school. */
export const ROM_PHYSICAL_DARK_DAMAGE_PROFILE = Object.freeze({
schools: Object.freeze(["physical", "shadow"] as const),
}) satisfies CombatDamageProfile;
/** A school-specific modifier/absorb matches a composite packet by either tag. */
export function combatDamageProfileMatchesSchool(
profile: CombatDamageProfile,
school: DamageSchool | undefined,
): boolean {
return school === undefined || profile.schools.includes(school);
}
+6
View File
@@ -325,6 +325,12 @@ export function presentationSourceForAbility(ability: AbilityDefinition): Combat
}
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
if (ability.source === "runewaker" && ability.classId === "rom-priest" && ability.dbcSpellId === 490276) {
// Holy Void contains "Void" in its current CoA name, but its catalog hit
// is Light damage. Resolve the explicit Priest-primary identity before
// the generic keyword check classifies it as Shadow.
return "holy";
}
const name = `${ability.name} ${ability.description}`.toLowerCase();
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
+37 -1
View File
@@ -55,7 +55,39 @@ export function normalizeDeathKnightRunes(
now: number,
): readonly DeathKnightRuneState[] {
return Object.freeze(runes.map((rune) => {
if (rune.deathRuneExpiresAt === undefined || rune.deathRuneExpiresAt > now) return rune;
if (rune.deathRuneConversions !== undefined) {
const activeConversions = rune.deathRuneConversions.filter((conversion) => (
Number.isFinite(conversion.expiresAt) && conversion.expiresAt > now
));
if (activeConversions.length > 0) {
const latestExpiry = Math.max(...activeConversions.map((conversion) => conversion.expiresAt));
if (rune.type === "death"
&& rune.deathRuneExpiresAt === latestExpiry
&& activeConversions.length === rune.deathRuneConversions.length) return rune;
return Object.freeze({
...rune,
type: "death",
deathRuneExpiresAt: latestExpiry,
deathRuneConversions: Object.freeze(activeConversions),
});
}
const { deathRuneExpiresAt: _legacyExpiry, deathRuneConversions: _conversions, ...baseRune } = rune;
void _legacyExpiry;
void _conversions;
return Object.freeze({ ...baseRune, type: rune.baseType });
}
if (rune.deathRuneExpiresAt === undefined) return rune;
if (rune.deathRuneExpiresAt > now) {
// Older snapshots only stored Blood Tap's latest expiration. Treat that
// legacy shape as a Blood Tap owner before any newer conversion touches it.
return Object.freeze({
...rune,
deathRuneConversions: Object.freeze([{
source: "blood-tap" as const,
expiresAt: rune.deathRuneExpiresAt,
}]),
});
}
const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune;
void _expiredAt;
return Object.freeze({ ...baseRune, type: rune.baseType });
@@ -86,6 +118,10 @@ export function activateDeathKnightBloodTapRune(
type: "death" as const,
readyAt: Math.min(rune.readyAt, now),
deathRuneExpiresAt: now + Math.max(1, durationMs),
deathRuneConversions: Object.freeze([{
source: "blood-tap" as const,
expiresAt: now + Math.max(1, durationMs),
}]),
})
: rune));
}
+56 -6
View File
@@ -56,6 +56,8 @@ export interface RotationContext {
readonly comboPoints: number;
readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[];
/** Learned Reaping rank, used only to expose its otherwise-omitted Blood Strike trigger fallback. */
readonly deathKnightReapingRank?: number;
readonly abilities: readonly AbilityDefinition[];
/** Learned actions capable of hostile damage, including unavailable ones. */
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
@@ -76,6 +78,8 @@ export interface RotationContext {
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
/** True only when an in-range living ally has a depleted pool restored by this party-scoped effect. */
readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean;
/** Proc/state-gated actions that the caller has positively made available. */
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
@@ -104,9 +108,9 @@ const profile = (
/** Playerbots priorities translated to the executable Healer Man spell model. */
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Taunt", "Shield Bash", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Victory Rush", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Victory Rush", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Taunt", "Shield Bash", "Bloodrage", "Shield Slam", "Revenge", "Victory Rush", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
@@ -143,6 +147,7 @@ const PROFILE_BY_SPECIALIZATION = new Map(
const CONDITIONAL_ABILITY_NAMES = new Set([
"Overpower",
"Pestilence",
"Revenge",
"Victory Rush",
]);
@@ -163,6 +168,7 @@ const TARGET_HEALTH_GATES = new Map<string, number>([
]);
const MINIMUM_HOSTILE_TARGETS = new Map<string, number>([
["Pestilence", 2],
["Multi-Shot", 2],
["Volley", 3],
["Flamestrike", 3],
@@ -401,6 +407,15 @@ function scoreAbility(
if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null;
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
if (ability.name === "Victory Rush" && context.availableConditionalAbilityNames?.has("Victory Rush")) {
return {
ability,
score: 1_016_500,
ruleId: "reactive-improved-victory-rush",
reason: "a parry opened the Victory Rush window",
targetId: context.hostileTarget?.id ?? null,
};
}
if (context.classId === "druid") {
const specializationId = rotationProfile?.specializationId;
if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null;
@@ -641,7 +656,18 @@ function scoreAbility(
reason = "missing pet or summon";
}
}
if (categories.has("resource")) score += resourceFraction < 0.3 ? 2_600 : -1_000;
if (categories.has("resource")) {
const partyResourceDeficit = ability.effects.some((effect) => (
effect.kind === "resource" && effect.scope === "party"
)) && (context.partyResourceDeficit?.(ability) ?? false);
if (partyResourceDeficit) {
score += 2_600;
ruleId = "party-resource-restoration";
reason = "an in-range ally has a depleted resource pool";
} else {
score += resourceFraction < 0.3 ? 2_600 : -1_000;
}
}
if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10;
if (isComboFinisher(ability)) score += 2_400;
if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0;
@@ -655,11 +681,32 @@ function scoreAbility(
}
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
const rotationProfile = rotationProfileFor(
const baseRotationProfile = rotationProfileFor(
context.classId,
context.specializationId,
context.specialization,
);
const hasSpendableBaseBloodRune = context.runes.some((rune) => (
rune.baseType === "blood" && rune.readyAt <= context.now
));
const reapingRank = Math.max(0, Math.trunc(context.deathKnightReapingRank ?? 0));
const reapingTriggerFallbacks: string[] = [];
if (baseRotationProfile?.specializationId === "death-knight:blood"
&& reapingRank > 0
&& hasSpendableBaseBloodRune) {
reapingTriggerFallbacks.push("Blood Strike");
if (context.availableConditionalAbilityNames?.has("Pestilence")) {
reapingTriggerFallbacks.push("Pestilence");
}
}
const rotationProfile = baseRotationProfile && reapingTriggerFallbacks.length > 0
? {
...baseRotationProfile,
// Keep the normal Blood priority intact. Reaping triggers are final
// fallbacks, with Blood Strike preferred over Pestilence.
abilityPriority: Object.freeze([...baseRotationProfile.abilityPriority, ...reapingTriggerFallbacks]),
}
: baseRotationProfile;
const lowest = [...(context.friendlyTargets ?? [])]
.filter((target) => target.health > 0 && !target.fallen)
.sort((left, right) => (
@@ -668,7 +715,10 @@ export function evaluateCombatRotation(context: RotationContext): RotationDecisi
))[0] ?? null;
const decision = context.abilities
.filter((ability) => !ability.passive)
.filter(abilityHasRotationSemantics)
.filter((ability) => abilityHasRotationSemantics(ability)
|| (ability.name === "Pestilence"
&& reapingRank > 0
&& context.availableConditionalAbilityNames?.has("Pestilence") === true))
.filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => {
if (context.classId !== "death-knight") return true;
+305 -1
View File
@@ -19,12 +19,13 @@ import {
useCombatStore,
} from "./combatStore";
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { armorReductionPercent, EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { applyAura as applyCombatAura } from "./combatAuras";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { wow335HunterPetArmorForLevel } from "./wow335HunterPetInheritedDefenseRuntime";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
@@ -89,6 +90,43 @@ function setPartyMemberHealth(id: string, health: number): void {
}));
}
function registerHunterPetDefenseProbe(id: string, school: "physical" | "fire"): string {
const attackId = `${id}-attack`;
useCombatStore.getState().registerMob(id, {
name: id,
level: 80,
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: attackId,
name: "Defense probe",
school,
range: 5,
}],
});
setMobPosition(id, [2, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [id]: { ...state.mobs[id]!, attackDamage: 1_000 } },
}));
return attackId;
}
function incomingDamageEventForSummon(mobId: string, attackId: string, summonId: string) {
useCombatStore.getState().damageMob(mobId, 1, 1_000, {
actorId: summonId,
role: "damage",
abilityId: "hunter-pet-defense-probe",
});
useCombatStore.getState().advanceMobCombat(1_450);
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "damage"
&& event.sourceActorId === mobId
&& event.targetActorId === summonId
&& event.abilityId === attackId
));
}
function preparePartyMember(
index: number,
position: PartyWorldPosition,
@@ -949,6 +987,56 @@ describe("combat store", () => {
});
});
it("mirrors a player Death Knight's landed spell during Dancing Rune Weapon's 12-second window", () => {
useCombatStore.getState().initializeCharacter({
classId: "death-knight",
level: 80,
xp: 0,
});
useCombatStore.setState({
talentRanks: { "talent-1961": 1 },
resource: 100,
resourcePools: { "runic-power": 100 },
});
registerTarget("dancing-rune-weapon-player-target", [2, 0, 0], 1_000_000, 0);
expect(useCombatStore.getState().castAbility(
"wow335-death-knight-dancing-rune-weapon", undefined, 1_000,
).ok).toBe(true);
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
targetId: PLAYER_AURA_ENTITY_ID,
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
appliedAt: 1_000,
expiresAt: 13_000,
}));
expect(useCombatStore.getState().summons.some((summon) => (
summon.sourceAbilityId === "wow335-death-knight-dancing-rune-weapon"
))).toBe(false);
const deathCoil = useCombatStore.getState().abilities.find((ability) => ability.name === "Death Coil");
expect(deathCoil).toBeDefined();
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 3_000).ok).toBe(true);
const activeHits = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === "dancing-rune-weapon-player-target"
&& event.abilityId === deathCoil!.id
&& event.occurredAt === 3_000
));
expect(activeHits).toHaveLength(2);
expect(activeHits[1].rawAmount).toBe(Math.floor(activeHits[0].rawAmount * 0.5));
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 14_000).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === "dancing-rune-weapon-player-target"
&& event.abilityId === deathCoil!.id
&& event.occurredAt === 14_000
))).toHaveLength(1);
});
it("swings at the resolved companion without redirecting player damage", () => {
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
usePartyStore.setState((state) => ({
@@ -1242,6 +1330,12 @@ describe("combat store", () => {
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
expect(useCombatStore.getState().settings).toMatchObject({
showOwnDamageText: true,
showAllyDamageText: false,
showIncomingDamageText: false,
showFloatingCombatText: true,
});
useCombatStore.getState().updateSettings({
masterVolume: 9,
uiScale: 0.1,
@@ -1273,6 +1367,18 @@ describe("combat store", () => {
settings: { masterVolume: 0.6 },
});
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
expect(useCombatStore.getState().settings).toMatchObject({
showOwnDamageText: true,
showAllyDamageText: false,
showIncomingDamageText: false,
});
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showAllyDamageText: true, showIncomingDamageText: true });
expect(useCombatStore.getState().settings).toMatchObject({
showOwnDamageText: false,
showAllyDamageText: true,
showIncomingDamageText: true,
showFloatingCombatText: true,
});
useCombatStore.getState().resetSettings();
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
});
@@ -2141,6 +2247,204 @@ describe("combat store", () => {
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
});
it("uses player Hunter pet commands and owner attack events while Growl adds flat pet threat", () => {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
expect(pet).toMatchObject({
hunterPetCommand: "follow",
hunterPetReactMode: "defensive",
hunterPetGrowlAutocastEnabled: false,
});
useCombatStore.getState().registerMob("hunter-pet-first-target", {
name: "Hunter pet target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("hunter-pet-first-target", [6, 0, 0]);
useCombatStore.getState().registerMob("hunter-pet-owner-action-target", {
name: "Owner action target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("hunter-pet-owner-action-target", [8, 0, 0]);
useCombatStore.getState().selectMob("hunter-pet-first-target");
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Shot")!;
const firstShot = useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 2_600);
expect(firstShot.ok, firstShot.reason).toBe(true);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-first-target");
useCombatStore.getState().tick(0.1, 2_700);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
.toBe("hunter-pet-first-target");
expect(useCombatStore.getState().mobs["hunter-pet-first-target"]?.threatByActor[pet.id] ?? 0).toBe(0);
expect(useCombatStore.getState().setHunterPetGrowlAutocast(pet.id, true)).toBe(true);
useCombatStore.getState().tick(0.1, 2_800);
useCombatStore.getState().tick(0.1, 2_900);
const afterGrowl = useCombatStore.getState();
expect(afterGrowl.mobs["hunter-pet-first-target"]?.threatByActor[pet.id]).toBeGreaterThanOrEqual(1_234);
expect(afterGrowl.mobs["hunter-pet-first-target"]?.forcedTarget).toBeNull();
expect(afterGrowl.summons.find((summon) => summon.id === pet.id)?.hunterPetGrowlReadyAt).toBe(7_900);
useCombatStore.getState().selectMob("hunter-pet-owner-action-target");
expect(useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 10_000).ok).toBe(true);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-owner-action-target");
useCombatStore.getState().tick(0.1, 10_100);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
.toBe("hunter-pet-first-target");
useCombatStore.getState().selectMob("hunter-pet-first-target");
useCombatStore.getState().tick(0.1, 10_200);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
.toBe("hunter-pet-first-target");
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({
targetId: null,
hunterPetCommand: "follow",
});
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.hunterPetCommand)
.toBe("stay");
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
? { ...summon, position: [10, 0, 0] as const }
: summon) }));
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
useCombatStore.getState().tick(0.1, 10_300);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeLessThan(10);
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
? { ...summon, position: [0, 0, 0] as const }
: summon) }));
useCombatStore.getState().tick(0.1, 10_400);
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeGreaterThan(0);
});
it.each(["physical", "fire"] as const)(
"applies inherited WoW 3.3.5 defenses to a player Hunter pet against %s damage",
(school) => {
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
useCombatStore.setState({ gearStats });
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
const attackId = registerHunterPetDefenseProbe(`player-pet-defense-${school}`, school);
const event = incomingDamageEventForSummon(`player-pet-defense-${school}`, attackId, pet.id);
expect(event).toBeDefined();
expect(event?.rawAmount).toBe(1_000);
if (school === "physical") {
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
expect(event?.effectiveAmount).toBe(expected);
expect(event?.effectiveAmount).toBeLessThan(1_000);
} else {
// 40% of the owner's 500 Fire Resistance yields a 200-resistance
// pet profile: the level-80 partial-resist table selects 20% or 30%.
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
}
},
);
it.each(["physical", "fire"] as const)(
"applies inherited WoW 3.3.5 defenses to a generated companion Hunter pet against %s damage",
(school) => {
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
usePartyStore.getState().initializeParty(
`hunter-pet-defense-companion-${school}`,
80,
useCombatStore.getState().health,
);
const memberId = usePartyStore.getState().members[0]!.id;
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === memberId
? {
...member,
classId: "hunter",
secondaryClassId: null,
raceId: "human",
level: 80,
gearStats,
talentRanks: {},
health: member.maxHealth,
}
: member),
}));
registerPartyRuntimePosition(memberId, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
const petId = useCombatStore.getState().summonActor(memberId, callPet, {
kind: "summon",
creatureId: 1,
coefficient: 1,
durationMs: 60_000,
}, [0, 0, 0], null, 1_000);
expect(petId).not.toBeNull();
const attackId = registerHunterPetDefenseProbe(`companion-pet-defense-${school}`, school);
const event = incomingDamageEventForSummon(`companion-pet-defense-${school}`, attackId, petId!);
expect(event).toBeDefined();
expect(event?.rawAmount).toBe(1_000);
if (school === "physical") {
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
expect(event?.effectiveAmount).toBe(expected);
expect(event?.effectiveAmount).toBeLessThan(1_000);
} else {
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
}
},
);
it("includes the player's active armor aura in inherited Hunter-pet physical mitigation", () => {
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000 };
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
useCombatStore.setState({ gearStats });
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
const ownerArmorWithoutAura = deriveCharacterStats("hunter", 80, gearStats, "human").armor;
const aura = applyCombatAura(useCombatStore.getState().auras, {
definition: {
id: "test:hunter-owner-armor",
name: "Test Armor Bonus",
disposition: "buff",
durationMs: 30_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 1_000 }],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
});
useCombatStore.setState({ auras: aura.auras });
const attackId = registerHunterPetDefenseProbe("player-pet-owner-armor-aura", "physical");
const event = incomingDamageEventForSummon("player-pet-owner-armor-aura", attackId, pet.id);
const petArmorWithAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura + 1_000);
const expectedWithAura = 1_000
- Math.round(1_000 * armorReductionPercent(petArmorWithAura, 80) / 100);
const petArmorWithoutAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura);
const expectedWithoutAura = 1_000
- Math.round(1_000 * armorReductionPercent(petArmorWithoutAura, 80) / 100);
expect(event?.effectiveAmount).toBe(expectedWithAura);
expect(event?.effectiveAmount).toBeLessThan(expectedWithoutAura);
});
it("leaves a non-Hunter summon on its existing incoming-damage path", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
const mirrorImage = useCombatStore.getState().abilities.find((ability) => ability.name === "Mirror Image")!;
expect(useCombatStore.getState().castAbility(mirrorImage.id, [0, 0, 0], 1_000).ok).toBe(true);
const summon = useCombatStore.getState().summons.find((candidate) => !candidate.hunterPet)!;
const attackId = registerHunterPetDefenseProbe("non-hunter-summon-defense", "physical");
const event = incomingDamageEventForSummon("non-hunter-summon-defense", attackId, summon.id);
expect(event).toBeDefined();
expect(event?.rawAmount).toBe(1_000);
expect(event?.effectiveAmount).toBe(1_000);
});
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
unlockReadySpell("wow335-mage-combustion");
+4746 -376
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -140,6 +140,18 @@ describe("Death Knight source-backed runtime mechanics", () => {
expect(attempts.every((result) => result.healing === 0)).toBe(true);
});
it("extends Blood Plague and Frost Fever by Epidemic's generated duration", () => {
registerTarget();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1963": 2 },
}));
const icyTouch = ability("Icy Touch");
expect(useCombatStore.getState().castAbility(icyTouch.id, { targetActorId: "dk-target", now }).ok).toBe(true);
expect(useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.tag === "frost-fever" && effect.targetId === "dk-target"
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
});
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);
+153
View File
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OnlineRelayEvent, OnlineSessionSnapshot } from "../app/onlineSessionTypes";
import { damageTextClassName } from "../ui/FloatingDamageText";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { appendCombatEvent, type CombatEventInput } from "./combatEvents";
import {
DAMAGE_TEXT_LIFETIME_MS,
MAX_DAMAGE_TEXT_PER_TARGET,
MAX_DAMAGE_TEXT_TOTAL,
clearDamageText,
connectFloatingCombatText,
damageTextCategory,
formatDamageText,
ingestRelayDamageText,
useDamageTextStore,
withSuppressedReplayDamageText,
} from "./floatingCombatText";
let disconnect: (() => void) | null = null;
function damage(id: number, overrides: Partial<CombatEventInput> = {}): void {
appendCombatEvent([], {
occurredAt: id * 100,
kind: "damage",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: "target",
abilityId: "priest-smite",
rawAmount: 500,
effectiveAmount: 18,
overheal: 0,
absorbed: 0,
critical: false,
...overrides,
}, id);
}
beforeEach(() => {
vi.useFakeTimers();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 1_000 });
disconnect = connectFloatingCombatText();
});
afterEach(() => {
disconnect?.();
disconnect = null;
clearDamageText();
vi.useRealTimers();
});
describe("floating damage text", () => {
it("classifies own, ally, and incoming hits independently", () => {
const mobs = new Set(["target"]);
const party = new Set(["companion"]);
const online = new Set(["online-player:local", "online-player:friend"]);
expect(damageTextCategory(PLAYER_AGGRO_ID, "target", mobs, party, online, "online-player:local")).toBe("own");
expect(damageTextCategory("companion", "target", mobs, party, online, "online-player:local")).toBe("ally");
expect(damageTextCategory("online-player:friend", "target", mobs, party, online, "online-player:local")).toBe("ally");
expect(damageTextCategory("target", PLAYER_AGGRO_ID, mobs, party, online, "online-player:local")).toBe("incoming");
expect(damageTextCategory("target", "companion", mobs, party, online, "online-player:local")).toBe("incoming");
});
it("shows effective Smite damage, preserves crit, and expires after 1.5 seconds", () => {
damage(1, { effectiveAmount: 17.6, critical: true });
expect(useDamageTextStore.getState().byTarget.target).toEqual([
expect.objectContaining({ amount: 18, critical: true, category: "own" }),
]);
expect(formatDamageText(1_234.4)).toBe("1,234");
expect(damageTextClassName(useDamageTextStore.getState().byTarget.target[0]!)).toContain("is-critical");
vi.advanceTimersByTime(DAMAGE_TEXT_LIFETIME_MS);
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
});
it("honors each switch and removes a category immediately when disabled", () => {
damage(1);
expect(useDamageTextStore.getState().byTarget.target).toHaveLength(1);
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showIncomingDamageText: true });
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
damage(2);
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
damage(3, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID });
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([
expect.objectContaining({ category: "incoming", amount: 18 }),
]);
damage(4, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID, effectiveAmount: 0 });
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toHaveLength(1);
});
it("bounds bursts per target and across the scene", () => {
for (let id = 1; id <= 7; id += 1) damage(id);
expect(useDamageTextStore.getState().byTarget.target).toHaveLength(MAX_DAMAGE_TEXT_PER_TARGET);
const target = useCombatStore.getState().mobs.target;
useCombatStore.setState({ mobs: Object.fromEntries(Array.from({ length: 90 }, (_, index) => [`mob-${index}`, { ...target, id: `mob-${index}` }])) });
for (let id = 0; id < 90; id += 1) damage(100 + id, { targetActorId: `mob-${id}` });
expect(Object.values(useDamageTextStore.getState().byTarget).flat()).toHaveLength(MAX_DAMAGE_TEXT_TOTAL);
});
it("shows periodic and area hits on their own targets, including a killing hit", () => {
const first = useCombatStore.getState().mobs.target;
useCombatStore.setState({ mobs: {
target: { ...first, dead: true, health: 0 },
second: { ...first, id: "second", health: 50 },
} });
damage(1, { targetActorId: "target", abilityId: "priest-shadow-word-pain", effectiveAmount: 9 });
damage(2, { targetActorId: "second", abilityId: "priest-holy-nova", effectiveAmount: 14 });
expect(useDamageTextStore.getState().byTarget.target?.[0]?.amount).toBe(9);
expect(useDamageTextStore.getState().byTarget.second?.[0]?.amount).toBe(14);
});
it("uses one relayed online crit and suppresses damage generated during replay", () => {
useCombatStore.getState().updateSettings({ showAllyDamageText: true });
const snapshot = {
localAccountId: "local",
players: [{ accountId: "local" }, { accountId: "friend" }],
} as unknown as OnlineSessionSnapshot;
const relay = {
id: 40,
kind: "damage",
sourceAccountId: "friend",
sourceActorId: "online-player:friend",
targetActorId: "target",
effectiveAmount: 61,
critical: true,
} as OnlineRelayEvent;
withSuppressedReplayDamageText(() => damage(40, { sourceActorId: "online-player:friend", effectiveAmount: 61 }));
ingestRelayDamageText(relay, snapshot);
ingestRelayDamageText(relay, snapshot);
expect(useDamageTextStore.getState().byTarget.target).toEqual([
expect.objectContaining({ amount: 61, critical: true, category: "ally", key: "relay:40" }),
]);
});
it("maps relayed damage to the local player's party frame with its original crit", () => {
useCombatStore.getState().updateSettings({ showIncomingDamageText: true });
const snapshot = {
localAccountId: "local",
players: [{ accountId: "local" }, { accountId: "friend" }],
} as unknown as OnlineSessionSnapshot;
ingestRelayDamageText({
id: 41,
kind: "damage",
sourceAccountId: "friend",
sourceActorId: "target",
targetActorId: "online-player:local",
effectiveAmount: 36,
critical: true,
} as OnlineRelayEvent, snapshot);
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([
expect.objectContaining({ amount: 36, critical: true, category: "incoming" }),
]);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { create } from "zustand";
import type { OnlineRelayEvent, OnlineSessionSnapshot } from "../app/onlineSessionTypes";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { isOnlinePlayerActorId, onlinePlayerActorId } from "./onlineSessionRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
export type DamageTextCategory = "own" | "ally" | "incoming";
export interface DamageTextPopup {
readonly key: string;
readonly targetActorId: string;
readonly amount: number;
readonly critical: boolean;
readonly category: DamageTextCategory;
readonly slot: number;
readonly sequence: number;
}
export const DAMAGE_TEXT_LIFETIME_MS = 1_500;
export const MAX_DAMAGE_TEXT_PER_TARGET = 4;
export const MAX_DAMAGE_TEXT_TOTAL = 80;
export const EMPTY_DAMAGE_TEXT_POPUPS: readonly DamageTextPopup[] = Object.freeze([]);
interface DamageTextState {
readonly byTarget: Readonly<Record<string, readonly DamageTextPopup[]>>;
}
export const useDamageTextStore = create<DamageTextState>(() => ({ byTarget: {} }));
let sequence = 0;
let replayDepth = 0;
const expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
const seenRelayIds = new Set<number>();
export function formatDamageText(amount: number): string {
return Math.max(1, Math.round(amount)).toLocaleString("en-US");
}
function sourceOwner(sourceId: string): string {
const combat = useCombatStore.getState();
const visited = new Set<string>();
let id = sourceId;
while (!visited.has(id)) {
visited.add(id);
if (id === PLAYER_AGGRO_ID || isOnlinePlayerActorId(id)
|| usePartyStore.getState().members.some((member) => member.id === id)) break;
const owner = combat.actors[id]?.ownerId
?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
if (!owner) break;
id = owner;
}
return id;
}
export function damageTextCategory(
sourceActorId: string,
targetActorId: string,
mobs: ReadonlySet<string>,
party: ReadonlySet<string>,
online: ReadonlySet<string>,
localOnlineId: string | null,
): DamageTextCategory | null {
if (mobs.has(targetActorId)) {
if (sourceActorId === PLAYER_AGGRO_ID || sourceActorId === localOnlineId) return "own";
if (party.has(sourceActorId) || online.has(sourceActorId)) return "ally";
return null;
}
if (targetActorId === PLAYER_AGGRO_ID || targetActorId === localOnlineId
|| party.has(targetActorId) || online.has(targetActorId)) return "incoming";
return null;
}
function enabled(category: DamageTextCategory): boolean {
const settings = useCombatStore.getState().settings;
return category === "own" ? settings.showOwnDamageText
: category === "ally" ? settings.showAllyDamageText
: settings.showIncomingDamageText;
}
function removePopup(key: string, targetActorId: string): void {
expiryTimers.delete(key);
useDamageTextStore.setState((state) => {
const current = state.byTarget[targetActorId];
if (!current?.some((popup) => popup.key === key)) return state;
const next = current.filter((popup) => popup.key !== key);
const byTarget = { ...state.byTarget };
if (next.length) byTarget[targetActorId] = next;
else delete byTarget[targetActorId];
return { byTarget };
});
}
function addPopup(popup: Omit<DamageTextPopup, "slot" | "sequence">): void {
sequence += 1;
const nextPopup = { ...popup, slot: sequence % MAX_DAMAGE_TEXT_PER_TARGET, sequence };
useDamageTextStore.setState((state) => {
const byTarget = { ...state.byTarget };
const old = byTarget[popup.targetActorId] ?? EMPTY_DAMAGE_TEXT_POPUPS;
const target = [...old, nextPopup];
for (const dropped of target.slice(0, -MAX_DAMAGE_TEXT_PER_TARGET)) {
clearTimeout(expiryTimers.get(dropped.key));
expiryTimers.delete(dropped.key);
}
byTarget[popup.targetActorId] = target.slice(-MAX_DAMAGE_TEXT_PER_TARGET);
const all = Object.values(byTarget).flat();
if (all.length > MAX_DAMAGE_TEXT_TOTAL) {
const oldest = all.reduce((left, right) => left.sequence < right.sequence ? left : right);
const remaining = byTarget[oldest.targetActorId].filter((item) => item.key !== oldest.key);
if (remaining.length) byTarget[oldest.targetActorId] = remaining;
else delete byTarget[oldest.targetActorId];
clearTimeout(expiryTimers.get(oldest.key));
expiryTimers.delete(oldest.key);
}
return { byTarget };
});
expiryTimers.set(nextPopup.key, setTimeout(() => removePopup(nextPopup.key, nextPopup.targetActorId), DAMAGE_TEXT_LIFETIME_MS));
}
export function clearDamageText(): void {
for (const timer of expiryTimers.values()) clearTimeout(timer);
expiryTimers.clear();
seenRelayIds.clear();
useDamageTextStore.setState({ byTarget: {} });
}
function ingestDamage(input: {
readonly key: string;
readonly sourceActorId: string;
readonly targetActorId: string;
readonly effectiveAmount: number;
readonly critical: boolean;
}, onlineOverride?: OnlineSessionSnapshot): void {
if (!Number.isFinite(input.effectiveAmount) || input.effectiveAmount <= 0) return;
const combat = useCombatStore.getState();
const onlineSnapshot = onlineOverride ?? useOnlineSessionStore.getState().snapshot;
const localOnlineId = onlineSnapshot ? onlinePlayerActorId(onlineSnapshot.localAccountId) : null;
const party = new Set(usePartyStore.getState().members.map((member) => member.id));
const online = new Set((onlineSnapshot?.players ?? []).map((player) => onlinePlayerActorId(player.accountId)));
const mobs = new Set(Object.keys(combat.mobs));
const category = damageTextCategory(sourceOwner(input.sourceActorId), input.targetActorId, mobs, party, online, localOnlineId);
if (!category || !enabled(category)) return;
addPopup({
key: input.key,
targetActorId: input.targetActorId === localOnlineId ? PLAYER_AGGRO_ID : input.targetActorId,
amount: Math.max(1, Math.round(input.effectiveAmount)),
critical: input.critical,
category,
});
}
export function withSuppressedReplayDamageText<T>(run: () => T): T {
replayDepth += 1;
try { return run(); } finally { replayDepth -= 1; }
}
export function ingestRelayDamageText(event: OnlineRelayEvent, snapshot: OnlineSessionSnapshot): void {
if (event.kind !== "damage" || event.sourceAccountId === snapshot.localAccountId || seenRelayIds.has(event.id)) return;
seenRelayIds.add(event.id);
if (seenRelayIds.size > 1_000) seenRelayIds.delete(seenRelayIds.values().next().value!);
ingestDamage({
key: `relay:${event.id}`,
sourceActorId: event.sourceActorId,
targetActorId: event.targetActorId,
effectiveAmount: event.effectiveAmount ?? 0,
critical: event.critical ?? false,
}, snapshot);
}
export function connectFloatingCombatText(): () => void {
clearDamageText();
const onCombatEvent = (event: StructuredCombatEvent) => {
if (replayDepth || event.kind !== "damage") return;
ingestDamage({
key: `local:${event.id}`,
sourceActorId: event.sourceActorId,
targetActorId: event.targetActorId,
effectiveAmount: event.effectiveAmount,
critical: event.critical,
});
};
const unsubscribeEvents = subscribeCombatEvents(onCombatEvent);
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
if (state.combatEvents.length === 0 && previous.combatEvents.length > 0) clearDamageText();
if (state.settings === previous.settings) return;
useDamageTextStore.setState((current) => {
const byTarget: Record<string, readonly DamageTextPopup[]> = {};
for (const [id, popups] of Object.entries(current.byTarget)) {
const kept = popups.filter((popup) => enabled(popup.category));
for (const dropped of popups.filter((popup) => !enabled(popup.category))) {
clearTimeout(expiryTimers.get(dropped.key));
expiryTimers.delete(dropped.key);
}
if (kept.length) byTarget[id] = kept.length === popups.length ? popups : kept;
}
return { byTarget };
});
});
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
if (state.sessionRevision !== previous.sessionRevision) clearDamageText();
});
return () => {
unsubscribeEvents();
unsubscribeCombat();
unsubscribeGame();
clearDamageText();
};
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GRAPHICS_SETTINGS,
ambientFillMultiplier,
nextGraphicsChoice,
normalizeGraphicsSettings,
RENDER_SCALES,
} from "./graphicsSettings";
describe("dungeon graphics settings", () => {
it("keeps known choices and rejects invalid saved values", () => {
expect(normalizeGraphicsSettings(DEFAULT_GRAPHICS_SETTINGS, {
renderScale: 2,
textureFiltering: 16,
shadowQuality: "high",
ambientFill: "low",
})).toEqual({ renderScale: 2, textureFiltering: 16, shadowQuality: "high", ambientFill: "low" });
expect(normalizeGraphicsSettings(DEFAULT_GRAPHICS_SETTINGS, {
renderScale: 5 as 2,
textureFiltering: 8 as 16,
shadowQuality: "ultra" as "high",
})).toEqual(DEFAULT_GRAPHICS_SETTINGS);
});
it("cycles all resolution choices and keeps authored ambient fill unchanged", () => {
expect(nextGraphicsChoice(2, RENDER_SCALES)).toBe("adaptive");
expect(ambientFillMultiplier("authored")).toBe(1);
expect(ambientFillMultiplier("low")).toBeLessThan(1);
expect(ambientFillMultiplier("high")).toBeGreaterThan(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
export type RenderScale = "adaptive" | 1 | 1.5 | 2;
export type TextureFiltering = 1 | 4 | 16;
export type ShadowQuality = "off" | "standard" | "high";
export type AmbientFill = "low" | "authored" | "high";
export interface GraphicsSettings {
readonly renderScale: RenderScale;
readonly textureFiltering: TextureFiltering;
readonly shadowQuality: ShadowQuality;
readonly ambientFill: AmbientFill;
}
export const DEFAULT_GRAPHICS_SETTINGS: GraphicsSettings = Object.freeze({
renderScale: "adaptive",
textureFiltering: 1,
shadowQuality: "standard",
ambientFill: "authored",
});
export const RENDER_SCALES: readonly RenderScale[] = ["adaptive", 1, 1.5, 2];
export const TEXTURE_FILTERING_LEVELS: readonly TextureFiltering[] = [1, 4, 16];
export const SHADOW_QUALITIES: readonly ShadowQuality[] = ["off", "standard", "high"];
export const AMBIENT_FILL_LEVELS: readonly AmbientFill[] = ["low", "authored", "high"];
export function normalizeGraphicsSettings(
current: GraphicsSettings,
patch: Partial<GraphicsSettings>,
): GraphicsSettings {
return {
renderScale: RENDER_SCALES.includes(patch.renderScale as RenderScale)
? patch.renderScale! : current.renderScale,
textureFiltering: TEXTURE_FILTERING_LEVELS.includes(patch.textureFiltering as TextureFiltering)
? patch.textureFiltering! : current.textureFiltering,
shadowQuality: SHADOW_QUALITIES.includes(patch.shadowQuality as ShadowQuality)
? patch.shadowQuality! : current.shadowQuality,
ambientFill: AMBIENT_FILL_LEVELS.includes(patch.ambientFill as AmbientFill)
? patch.ambientFill! : current.ambientFill,
};
}
export function ambientFillMultiplier(fill: AmbientFill): number {
return fill === "low" ? 0.6 : fill === "high" ? 1.4 : 1;
}
export function nextGraphicsChoice<T>(current: T, choices: readonly T[]): T {
return choices[(choices.indexOf(current) + 1) % choices.length];
}
+80 -3
View File
@@ -1,15 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilitiesForClass } from "./abilityCatalog";
import {
registerCombatSpatialProvider,
resetCombatSpatialProvider,
} from "./combatSpatial";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useGameStore } from "./store";
describe("Mage Blink runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
resetCombatSpatialProvider();
useGameStore.getState().resetAtEntrance();
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0 });
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
});
it.each(["root", "stun"] as const)("clears active %s control on a successful cast", (mechanic) => {
it("uses the generated DBC radius as its forward travel distance", () => {
const blink = abilitiesForClass("mage").find((ability) => ability.id === "wow335-mage-blink");
expect(blink?.effects).toContainEqual(expect.objectContaining({
kind: "movement",
movement: "teleport",
distance: 20,
}));
});
it.each(["root", "stun"] as const)("moves forward and clears active %s control", (mechanic) => {
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: mechanic });
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
@@ -18,6 +38,9 @@ describe("Mage Blink runtime", () => {
});
expect(result).toMatchObject({ ok: true, abilityId: "wow335-mage-blink" });
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 20]);
expect(useGameStore.getState().playerPosition).toEqual([0, 0, 20]);
expect(useGameStore.getState().activeSpawn.footPosition).not.toEqual([0, 0, 20]);
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID))
.toEqual(expect.arrayContaining([
@@ -32,13 +55,14 @@ describe("Mage Blink runtime", () => {
]));
});
it("does not move away from a target when forward facing is unavailable", () => {
it("uses the player's facing rather than moving relative to the selected target", () => {
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: Math.PI / 2 });
useCombatStore.getState().registerMob("blink-target", {
name: "Blink target",
maxHealth: 100,
xpReward: 0,
});
setMobPosition("blink-target", [5, 0, 0]);
setMobPosition("blink-target", [0, 0, 5]);
useCombatStore.getState().selectMob("blink-target");
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
@@ -46,7 +70,60 @@ describe("Mage Blink runtime", () => {
now: 1_000,
});
expect(result.ok).toBe(true);
expect(useCombatStore.getState().playerPosition[0]).toBeCloseTo(20);
expect(useCombatStore.getState().playerPosition[2]).toBeCloseTo(0);
});
it("projects the destination to ground and stops at the first line-of-sight blocker", () => {
const projectedRequests: Array<{ distance: number; origin: readonly number[] }> = [];
registerCombatSpatialProvider({
actor: (actorId) => actorId === PLAYER_AGGRO_ID
? { id: actorId, position: [0, 0, 0], yaw: 0 }
: null,
hasLineOfSight: (origin, target) => Math.hypot(target[0] - origin[0], target[2] - origin[2]) < 6,
projectGroundPoint: (requested, distance, origin = [0, 0, 0]) => {
projectedRequests.push({
distance,
origin: [...origin],
});
return [requested[0], 2, requested[2]];
},
});
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
origin: [0, 0, 0],
now: 1_000,
});
expect(result.ok).toBe(true);
expect(useCombatStore.getState().playerPosition[1]).toBe(2);
expect(useCombatStore.getState().playerPosition[2]).toBeLessThan(6);
expect(useCombatStore.getState().playerPosition[2]).toBeGreaterThan(5.9);
expect(useGameStore.getState().playerPosition).toEqual(useCombatStore.getState().playerPosition);
expect(projectedRequests.length).toBeGreaterThan(1);
expect(projectedRequests[0].distance).toBe(20);
expect(projectedRequests.every((entry) => entry.distance <= 20 && entry.origin[2] === 0)).toBe(true);
});
it("does not relocate when no reachable ground destination exists", () => {
registerCombatSpatialProvider({
actor: (actorId) => actorId === PLAYER_AGGRO_ID
? { id: actorId, position: [0, 0, 0], yaw: 0 }
: null,
hasLineOfSight: () => false,
projectGroundPoint: () => null,
});
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: "root" });
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
origin: [0, 0, 0],
now: 1_000,
});
expect(result.ok).toBe(true);
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]);
expect(useGameStore.getState().playerPosition).toEqual([0, 0, 0]);
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
});
});
+122 -2
View File
@@ -1,9 +1,15 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
import {
abilitiesForCharacter,
abilitiesForClass,
abilityAtLevel,
resourceProfileForClass,
} from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import { createDeathKnightRunes } from "./combatActors";
import { useCombatStore } from "./combatStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityHasDepletedPartyResource, partyAbilityIsReady, partyAbilityAmount, partyAbilityCosts } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
@@ -58,6 +64,20 @@ describe("catalog-driven party rotations", () => {
}));
});
it("applies Coerce's five-Energy Low Blow reduction to generated Rogue/Scout companions only", () => {
const rogueScout = {
...memberFor("rom-rogue", "damage"),
secondaryClassId: "rom-scout" as const,
};
const roguePriest = { ...rogueScout, secondaryClassId: "rom-priest" as const };
const lowBlow = abilitiesForCharacter("rom-rogue", "rom-scout")
.find((ability) => ability.dbcSpellId === 490323)!;
expect(partyAbilityCosts(rogueScout, lowBlow)).toEqual([{ resource: "energy", amount: 25 }]);
expect(partyAbilityCosts(roguePriest, lowBlow)).toEqual([{ resource: "energy", amount: 30 }]);
expect(commitPartyAbilityUse(rogueScout, lowBlow, 2_000)?.resourcePools.energy).toBe(75);
});
it("prioritizes an interrupt over ordinary damage deterministically", () => {
const member = memberFor("rogue", "damage");
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
@@ -67,6 +87,57 @@ describe("catalog-driven party rotations", () => {
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
});
it("adds Blood Strike as a Reaping-only Blood rotation fallback when a base Blood rune is ready", () => {
const abilities = abilitiesForCharacter("death-knight", null);
const strike = abilities.find((ability) => ability.name === "Blood Strike")!;
const cooldowns = Object.fromEntries(abilities
.filter((ability) => ability.id !== strike.id)
.map((ability) => [ability.id, 60_000]));
const base = {
...memberFor("death-knight", "damage"),
specialization: "Blood",
specializationId: "death-knight:blood",
runes: createDeathKnightRunes(2_000),
cooldowns,
};
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
expect(choosePartyAbility({ ...base, talentRanks: {} }, { now: 2_000, target })).toBeNull();
expect(choosePartyAbility({
...base,
talentRanks: { "talent-2001": 1 },
}, { now: 2_000, target })?.name).toBe("Blood Strike");
const bloodRunesCooling = base.runes.map((rune) => rune.baseType === "blood"
? { ...rune, readyAt: 60_000 }
: rune);
expect(choosePartyAbility({
...base,
talentRanks: { "talent-2001": 1 },
runes: bloodRunesCooling,
}, { now: 2_000, target })).toBeNull();
});
it("does not select Pestilence without a caster-owned disease available to spread", () => {
const abilities = abilitiesForCharacter("death-knight", null);
const pestilence = abilities.find((ability) => ability.name === "Pestilence")!;
const member = {
...memberFor("death-knight", "damage"),
specialization: "Blood",
specializationId: "death-knight:blood",
talentRanks: { "talent-2001": 1 },
runes: createDeathKnightRunes(2_000),
cooldowns: Object.fromEntries(abilities
.filter((ability) => ability.id !== pestilence.id)
.map((ability) => [ability.id, 60_000])),
};
expect(choosePartyAbility(member, {
now: 2_000,
target: { ...useCombatStore.getState().mobs.caster, activeCast: null },
hostileCount: 2,
})).toBeNull();
});
it("selects a real healer catalog spell and spends its resource/GCD", () => {
const member = memberFor("priest", "healer");
const ability = choosePartyAbility(member, {
@@ -81,6 +152,29 @@ describe("catalog-driven party rotations", () => {
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
});
it("keeps Chain of Light cooldown after current CoA Priest/Champion Rage Control rework", () => {
const pairAbilities = abilitiesForCharacter("rom-priest", "rom-champion");
const chain = pairAbilities.map((ability) => abilityAtLevel(ability, 80))
.find((ability) => ability.dbcSpellId === 491164)!;
const rageControl = pairAbilities.find((ability) => (
ability.source === "runewaker"
&& ability.passive
&& ability.classId === "rom-priest"
&& ability.secondaryClassId === "rom-champion"
&& ability.dbcSpellId === 498610
))!;
const member = {
...memberFor("rom-priest", "healer"),
secondaryClassId: "rom-champion" as const,
cooldowns: { [chain.id]: 60_000 },
};
expect(rageControl.unlockLevel).toBeLessThanOrEqual(member.level);
expect(partyAbilityIsReady(member, chain, 2_000)).toBe(false);
expect(commitPartyAbilityUse(member, chain, 2_000)).toBeNull();
expect(partyAbilityIsReady({ ...member, secondaryClassId: "rom-warlock" }, chain, 2_000)).toBe(false);
});
it("lets a companion use Inner Focus during a GCD while preserving that GCD", () => {
const member = {
...memberFor("priest", "healer"),
@@ -311,6 +405,32 @@ describe("catalog-driven party rotations", () => {
expect(partyAbilityAmount(talented, wrath, damage)).toBe(partyAbilityAmount(base, wrath, damage));
});
it("selects Sinister Request for a depleted in-range party pool even when its Druid/Rogue caster is full", () => {
const member = {
...memberFor("rom-druid", "damage"),
secondaryClassId: "rom-rogue" as const,
resource: 100,
maxResource: 100,
resourcePools: { mana: 100 },
};
const request = abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === 494023)!;
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const chooseWithPartyPools = (resources: readonly { resources: Record<string, { current: number; maximum: number }> }[]) => (
choosePartyAbility(member, {
now: 2_000,
target,
canUseAbility: ability => ability.id === request.id,
partyResourceDeficit: ability => partyAbilityHasDepletedPartyResource(ability, resources),
})
);
expect(chooseWithPartyPools([{ resources: { energy: { current: 12, maximum: 100 } } }])?.id)
.toBe(request.id);
expect(chooseWithPartyPools([{ resources: { energy: { current: 100, maximum: 100 } } }])).toBeNull();
expect(chooseWithPartyPools([{ resources: {} }])).toBeNull();
});
it("scales Envenom from consumed Deadly Poison doses", () => {
const member = {
...memberFor("rogue", "damage"),
+227 -15
View File
@@ -14,9 +14,11 @@ import {
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources";
import { resolveResourceValue } from "./combatAuras";
import { combatHasLineOfSight } from "./combatSpatial";
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
import { getMobPosition } from "./mobRuntimeRegistry";
import type { PartyMember } from "./partyStore";
import { baseResourcesFor } from "./wotlkStats";
import { baseResourcesFor, deriveCharacterStats } from "./wotlkStats";
import { companionResourceMaximum } from "./combatActorAuthority";
import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy";
import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime";
@@ -24,13 +26,28 @@ import {
WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID,
wow335MageImprovedCounterspellCooldownReductionMs,
} from "./wow335MageImprovedCounterspellRuntime";
import {
WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID,
wow335MageImprovedFireBlastCooldownReductionMs,
wow335MageImprovedFireBlastDamage,
} from "./wow335MageImprovedFireBlastRuntime";
import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime";
import {
romPriestWarriorStanceRequired,
romPriestWarriorStanceUseAllowed,
} from "./romPriestKnightOverrides";
import { romKnightWarriorWhirlwindAdjustment } from "./romKnightOverrides";
import {
romKnightCompassionWhirlwindShieldCooldownReductionMs,
romKnightWarriorWhirlwindAdjustment,
} from "./romKnightOverrides";
import { romWarriorDruidWhirlwindAdjustment } from "./romWarriorDruidRageOfCalmness";
import {
ROM_ROGUE_COERCE_PASSIVE_SPELL_ID,
romRogueCoerceAbilityAdjustment,
} from "./romRogueMechanics";
import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides";
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting";
export interface PartyAbilityContext {
readonly now: number;
@@ -52,6 +69,7 @@ export interface PartyAbilityContext {
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean;
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null;
@@ -59,10 +77,52 @@ export interface PartyAbilityContext {
readonly nextHealingSpellFree?: boolean;
}
export interface PartyResourcePoolSnapshot {
readonly resources: Readonly<Partial<Record<ResourceType, { readonly current: number; readonly maximum: number }>>>;
}
export function partyAbilityHasDepletedPartyResource(
ability: AbilityDefinition,
recipients: readonly PartyResourcePoolSnapshot[],
): boolean {
return ability.effects.some((effect) => {
if (effect.kind !== "resource" || effect.scope !== "party" || !effect.resource) return false;
return recipients.some(({ resources }) => {
const pool = resources[effect.resource!];
return Boolean(pool && pool.current < pool.maximum);
});
});
}
function currentResource(member: PartyMember, type: ResourceType): number {
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
}
function partyRomPriestChampionCooldownOverride(
member: PartyMember,
ability: AbilityDefinition,
): number | null {
if (member.classId !== "rom-priest" || member.secondaryClassId !== "rom-champion") return null;
const infusedLightChain = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => (
candidate.source === "runewaker"
&& candidate.classId === "rom-priest"
&& candidate.secondaryClassId === "rom-champion"
&& candidate.passive
&& candidate.dbcSpellId === 498610
));
if (!infusedLightChain
|| !learnedAbilityForBuild(infusedLightChain, member.level, member.talentRanks)) return null;
return romPriestChampionCooldownOverride(ability, [infusedLightChain.dbcSpellId]);
}
function partyAbilityWithRomPriestChampionCooldown(
member: PartyMember,
ability: AbilityDefinition,
): AbilityDefinition {
const cooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
return cooldownOverride === null ? ability : { ...ability, cooldownMs: cooldownOverride };
}
export function partyHunterLockAndLoadReady(
member: PartyMember,
ability: AbilityDefinition,
@@ -112,27 +172,107 @@ function partyKnightWarriorWhirlwindAdjustment(
);
}
function partyWarriorDruidWhirlwindAdjustment(
member: PartyMember,
ability: AbilityDefinition,
): ReturnType<typeof romWarriorDruidWhirlwindAdjustment> {
if (member.classId !== "rom-warrior" || member.secondaryClassId !== "rom-druid") {
return { rageCostReduction: 0, cooldownReductionMs: 0 };
}
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-warrior"
&& candidate.secondaryClassId === "rom-druid"
&& candidate.dbcSpellId === 494377
&& candidate.passive
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romWarriorDruidWhirlwindAdjustment({
classId: member.classId,
secondaryClassId: member.secondaryClassId,
learnedPassiveSpellIds,
ability,
});
}
function partyKnightCompassionCooldownReductionMs(
member: PartyMember,
ability: AbilityDefinition,
): number {
if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-priest") return 0;
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-knight"
&& candidate.secondaryClassId === "rom-priest"
&& candidate.dbcSpellId === 491635
&& candidate.passive
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romKnightCompassionWhirlwindShieldCooldownReductionMs(
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 knightAdjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
const warriorDruidAdjustment = partyWarriorDruidWhirlwindAdjustment(member, ability);
const rageCostReduction = knightAdjustment.rageCostReduction + warriorDruidAdjustment.rageCostReduction;
const cooldownReductionMs = knightAdjustment.cooldownReductionMs + warriorDruidAdjustment.cooldownReductionMs;
if (rageCostReduction <= 0 && 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, amount: Math.max(0, cost.amount - rageCostReduction) }
: cost
));
return {
...ability,
cooldownMs: Math.max(0, ability.cooldownMs - adjustment.cooldownReductionMs),
cooldownMs: Math.max(0, ability.cooldownMs - cooldownReductionMs),
cost: costs[0] ?? ability.cost,
costs,
};
}
function partyRogueCoerceEnergyCostReduction(member: PartyMember, ability: AbilityDefinition): number {
if (member.classId !== "rom-rogue" || member.secondaryClassId !== "rom-scout") return 0;
const passiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
.filter((candidate) => candidate.source === "runewaker"
&& candidate.classId === "rom-rogue"
&& candidate.secondaryClassId === "rom-scout"
&& candidate.passive
&& candidate.dbcSpellId === ROM_ROGUE_COERCE_PASSIVE_SPELL_ID
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
.map((candidate) => candidate.dbcSpellId);
return romRogueCoerceAbilityAdjustment(
member.classId,
member.secondaryClassId,
ability.source,
ability.dbcSpellId,
passiveSpellIds,
).energyCostReduction;
}
function partyAbilityWithRogueCoerce(member: PartyMember, ability: AbilityDefinition): AbilityDefinition {
const reduction = partyRogueCoerceEnergyCostReduction(member, ability);
if (reduction <= 0) return ability;
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
cost.resource === "energy" && !cost.percentage
? { ...cost, amount: Math.max(0, cost.amount - reduction) }
: cost
));
return { ...ability, 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 adjustedAbility = partyAbilityWithRogueCoerce(
member,
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) => (
@@ -165,14 +305,22 @@ export function partyAbilityIsReady(
ignoreResourceCosts = false,
): boolean {
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
if (member.classId === "rom-warlock" && member.secondaryClassId === "rom-mage"
&& ability.source === "runewaker" && ability.dbcSpellId === 498710
&& !romWarlockMageSoulBrandStingHasWillpowerBladeState(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > 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;
}
const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null;
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
|| (!partyHunterLockAndLoadReady(member, ability, now) && (member.cooldowns[ability.id] ?? 0) > now)) return false;
|| (!partyHunterLockAndLoadReady(member, ability, now)
&& !hasRomPriestChampionCooldownOverride
&& (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 && partyAbilityCosts(member, ability, now).some(cost => cost.resource === "health"
@@ -184,7 +332,8 @@ export function partyAbilityIsReady(
function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] {
return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null);
.filter((ability): ability is AbilityDefinition => ability !== null)
.map((ability) => partyAbilityWithRomPriestChampionCooldown(member, ability));
}
function isComboFinisher(ability: AbilityDefinition): boolean {
@@ -244,6 +393,37 @@ export function choosePartyRotationDecision(
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
);
const learned = learnedAbilities(member);
const reapingRank = member.classId === "death-knight"
? Math.max(0, Math.trunc(member.talentRanks["talent-2001"] ?? 0))
: 0;
const activeConditionalAbilityNames = new Set(context.availableConditionalAbilityNames ?? []);
const pestilence = learned.find((ability) => ability.name === "Pestilence");
const targetPosition = context.target ? getMobPosition(context.target.id) : null;
const hasNearbyPestilenceTarget = Boolean(targetPosition && pestilence && reapingRank > 0
&& member.specializationId === "death-knight:blood"
&& member.runes.some((rune) => rune.baseType === "blood" && rune.readyAt <= context.now)
&& Object.values(useCombatStore.getState().mobs).some((mob) => {
if (mob.id === context.target?.id || !mob.engaged || mob.dead) return false;
const position = getMobPosition(mob.id);
if (!position) return false;
const dx = position[0] - targetPosition[0];
const dy = position[1] - targetPosition[1];
const dz = position[2] - targetPosition[2];
const radius = pestilence!.radius ?? 10;
return dx * dx + dy * dy + dz * dz <= radius * radius
&& combatHasLineOfSight(targetPosition, position);
}));
const hasCasterOwnedDiseaseOnAnchor = Boolean(context.target && useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot"
&& effect.sourceActorId === member.id
&& effect.targetId === context.target?.id
&& (effect.tag === "blood-plague" || effect.tag === "frost-fever")
&& effect.remainingTicks > 0
&& effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs > context.now
)));
if (hasNearbyPestilenceTarget && hasCasterOwnedDiseaseOnAnchor) {
activeConditionalAbilityNames.add("Pestilence");
}
const decision = evaluateCombatRotation({
now: context.now,
classId: member.classId,
@@ -261,6 +441,7 @@ export function choosePartyRotationDecision(
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
deathKnightReapingRank: reapingRank,
abilities: learned.filter((ability) => partyAbilityIsReady(
member,
ability,
@@ -285,7 +466,10 @@ export function choosePartyRotationDecision(
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
availableConditionalAbilityNames: context.availableConditionalAbilityNames,
partyResourceDeficit: context.partyResourceDeficit,
availableConditionalAbilityNames: activeConditionalAbilityNames.size > 0
? activeConditionalAbilityNames
: undefined,
canUseAbility: context.canUseAbility,
cooldownPolicy: currentCompanionCooldownPolicy(),
incomingDefensiveMechanic: context.incomingDefensiveMechanic,
@@ -324,14 +508,24 @@ 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 romCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
const cooldownMs = member.classId === "mage"
? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs(
member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0,
ability.name,
) - wow335MageImprovedFireBlastCooldownReductionMs(
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
ability.name,
))
: ability.cooldownMs;
: romCooldownOverride ?? ability.cooldownMs;
const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs;
const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs);
const warriorDruidCooldownReductionMs = partyWarriorDruidWhirlwindAdjustment(member, ability).cooldownReductionMs;
const knightCompassionCooldownReductionMs = partyKnightCompassionCooldownReductionMs(member, ability);
const adjustedCooldownMs = Math.max(0,
cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs);
const cooldowns = { ...member.cooldowns };
if (romCooldownOverride !== null) delete cooldowns[ability.id];
if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs;
return {
...member,
health,
@@ -339,9 +533,7 @@ export function commitPartyAbilityUse(
resourcePools,
lastResourceSpentAt,
runes,
cooldowns: adjustedCooldownMs > 0
? { ...member.cooldowns, [ability.id]: now + adjustedCooldownMs }
: member.cooldowns,
cooldowns,
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
@@ -354,7 +546,16 @@ export function partyAbilityAmount(
ability: AbilityDefinition,
effect: AbilityEffect,
poisonDoses = 0,
intellectOverride?: number,
): number {
const sourceIntellect = intellectOverride ?? deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
).intellect;
if (effect.kind === "finisher-damage" && effect.poison) {
const consumedDoses = Math.max(0, Math.min(
effect.poison.maximumDoses,
@@ -371,6 +572,8 @@ export function partyAbilityAmount(
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
);
}
const coefficient = "coefficient" in effect
@@ -384,6 +587,8 @@ export function partyAbilityAmount(
member.gearStats,
member.classId,
ability,
0,
sourceIntellect,
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
return applyTalentModifierValue(
@@ -393,5 +598,12 @@ export function partyAbilityAmount(
{ abilityName: ability.name },
);
}
if (member.classId === "mage" && effect.kind === "damage") {
return wow335MageImprovedFireBlastDamage(
baseAmount,
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
ability.name,
);
}
return baseAmount;
}
+567 -3
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useCombatStore, type EnemyActiveCast } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
@@ -16,18 +16,31 @@ import {
partyAttackDamage,
partyCombatRangeBand,
partyCommandTargetId,
partyHunterPetRecipientIds,
partyUsesRangedAttacks,
partyAbilityTargetIsValid,
resetPartyRuntimeClock,
} from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { partyAbilityAmount } from "./partyAbilityAi";
import { createDeathKnightRunes } from "./combatActors";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
import { abilitiesForCharacter, abilitiesForClass, abilityAtLevel, abilityTalentNodeId, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { baseClassFor } from "../app/characterCatalog";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { ROM_ROGUE_OFF_HAND_MASTERY_TAG } from "./romRogueMechanics";
import {
WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
wow335HunterPetBaseStatsForLevel,
} from "./wow335HunterPetRuntime";
import {
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -93,6 +106,10 @@ function registerFrostbiteTarget(id: string): void {
}
describe("party combat runtime", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-druid"]);
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
@@ -109,6 +126,398 @@ describe("party combat runtime", () => {
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
});
it("extends a companion Druid's cast-time damage range with Nature's Reach", () => {
const seed = "druid-natures-reach-party";
const druid = createConfiguredPartyMember({
name: "Balance Druid",
classId: "druid",
specialization: "Balance",
specializationId: "druid:balance",
talentTreeId: "druid-balance",
talentRanks: { "talent-764": 3 },
role: "damage",
}, 80, 0, seed);
const wrath = abilitiesForCharacter("druid", null).find((ability) => ability.name === "Wrath");
if (!wrath) throw new Error("Druid Wrath is missing from the learned ability catalog.");
const learnedWrath = abilityAtLevel(wrath, 80);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({ members: [druid] });
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
useCombatStore.getState().registerMob("druid-natures-reach-party-target", {
name: "Nature's Reach party target",
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition("druid-natures-reach-party-target", [35, 0, 0]);
updatePartyRuntimeTargetVisibility(druid.id, "druid-natures-reach-party-target", true);
useCombatStore.getState().synchronizeActors();
expect(partyAbilityTargetIsValid(druid, learnedWrath, "druid-natures-reach-party-target")).toBe(true);
expect(partyAbilityTargetIsValid({ ...druid, talentRanks: {} }, learnedWrath,
"druid-natures-reach-party-target")).toBe(false);
});
it("spreads a companion Druid/Rogue's current Corrosive Poison with Necrotic Wound", () => {
const seed = "druid-rogue-necrotic-wound-party";
const druid = createConfiguredPartyMember({
name: "Druid/Rogue",
classId: "rom-druid",
secondaryClassId: "rom-rogue",
raceId: "rom-elf",
specialization: "Druid/Rogue",
specializationId: "rom-druid:rom-rogue",
talentTreeId: "rom-druid-rom-rogue",
role: "damage",
}, 80, 0, seed);
const findPairAbility = (spellId: number) => {
const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId
&& candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-rogue");
if (!found) throw new Error(`Missing Druid/Rogue spell ${spellId}.`);
return found;
};
const wound = learnedAbilityForBuild(findPairAbility(494321), 80, druid.talentRanks)!;
const poison = findPairAbility(494024);
const poisonDot = poison.effects.find((effect) => effect.kind === "dot");
const sinisterRequest = findPairAbility(494023);
if (!poisonDot || poisonDot.kind !== "dot") throw new Error("Corrosive Poison has no current DoT.");
if (!wound) throw new Error("Companion Druid/Rogue did not learn Necrotic Wound.");
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({ members: [{
...druid,
resourcePools: { ...druid.resourcePools, mana: 960, energy: 75, "nature-power": 0 },
cooldowns: { ...druid.cooldowns, [sinisterRequest.id]: 20_000 },
activeCast: {
abilityId: wound.id,
school: "shadow",
mode: "channel",
startedAt: 1_000,
completesAt: 2_500,
originalDurationMs: 1_500,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: "druid-rogue-wound-primary",
channelTicksCompleted: 0,
},
activeCastTargetId: "druid-rogue-wound-primary",
status: "attacking",
}] });
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const primary = "druid-rogue-wound-primary";
const nearby = "druid-rogue-wound-nearby";
const outside = "druid-rogue-wound-outside";
for (const [id, position] of [
[primary, [4, 0, 0]],
[nearby, [11, 0, 0]],
[outside, [13, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().damageMob(id, 1, 100);
}
updatePartyRuntimeTargetVisibility(druid.id, primary, true);
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(false);
expect(useCombatStore.getState().applyTimedActorEffect(druid.id, primary, poison, poisonDot, 1_000)).toBe(true);
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(true);
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === druid.id
? { ...member, cooldowns: { ...member.cooldowns, [sinisterRequest.id]: 20_000 } }
: member) }));
advanceDungeonPartyCombat(2_500);
const poisonTargets = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12
)).map((effect) => effect.targetId).sort();
expect(poisonTargets).toEqual([nearby, primary].sort());
expect(useCombatStore.getState().mobs[primary]!.health).toBeLessThan(999_999);
expect(useCombatStore.getState().mobs[nearby]!.health).toBeLessThan(999_999);
expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999);
expect(usePartyStore.getState().members[0]!.cooldowns[sinisterRequest.id]).toBe(19_000);
expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false);
});
it("extends a companion Death Knight's diseases with Epidemic", () => {
const seed = "death-knight-epidemic-party";
const deathKnight = createConfiguredPartyMember({
name: "Epidemic DK",
classId: "death-knight",
specialization: "Frost",
specializationId: "death-knight:frost",
talentTreeId: "death-knight-frost",
talentRanks: { "talent-1963": 2 },
role: "damage",
}, 80, 0, seed);
const abilities = abilitiesForCharacter("death-knight", null);
const icyTouch = abilities.find((candidate) => candidate.name === "Icy Touch");
if (!icyTouch) throw new Error("Missing Death Knight Icy Touch.");
const learnedIcyTouch = learnedAbilityForBuild(icyTouch, 80, deathKnight.talentRanks);
if (!learnedIcyTouch) throw new Error("Companion Death Knight did not learn Icy Touch.");
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({
members: [{
...deathKnight,
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 },
resource: 100,
maxResource: 100,
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
runes: createDeathKnightRunes(1_000),
globalCooldownEndsAt: 0,
nextActionAt: 0,
status: "ready",
}],
command: "attack",
activeMobId: "party-dk-epidemic-target",
});
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("party-dk-epidemic-target", 1_000_000);
for (const now of [1_000, 2_500, 4_000, 5_500]) advanceDungeonPartyCombat(now);
expect(useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceActorId === deathKnight.id
&& effect.tag === "frost-fever" && effect.targetId === "party-dk-epidemic-target"
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
});
it("activates Dancing Rune Weapon for a companion and mirrors its direct spell and melee hits", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const seed = "death-knight-dancing-rune-weapon-party";
const deathKnight = createConfiguredPartyMember({
name: "Rune Weapon DK",
classId: "death-knight",
specialization: "Blood",
specializationId: "death-knight:blood",
talentTreeId: "death-knight-blood",
talentRanks: { "talent-1961": 1 },
role: "damage",
}, 80, 0, seed);
const abilities = abilitiesForCharacter("death-knight", null);
const dancingRuneWeapon = abilities.find((ability) => ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID);
const learnedDancingRuneWeapon = dancingRuneWeapon
? learnedAbilityForBuild(dancingRuneWeapon, 80, deathKnight.talentRanks)
: null;
const deathCoil = abilities.find((ability) => ability.name === "Death Coil");
if (!learnedDancingRuneWeapon || !deathCoil) {
throw new Error("Companion Death Knight is missing Dancing Rune Weapon or Death Coil.");
}
const learnedDeathCoil = learnedAbilityForBuild(deathCoil, 80, deathKnight.talentRanks);
if (!learnedDeathCoil) throw new Error("Companion Death Knight did not learn Death Coil.");
const targetId = "party-dk-dancing-rune-weapon-target";
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({ members: [{
...deathKnight,
talentRanks: { ...deathKnight.talentRanks, "talent-1961": 1 },
gearStats: EMPTY_ITEM_STATS,
resource: 100,
maxResource: 100,
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
runes: createDeathKnightRunes(1_000),
activeCast: {
abilityId: learnedDancingRuneWeapon.id,
school: "shadow",
mode: "cast",
startedAt: 1_000,
completesAt: 1_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: deathKnight.id,
},
activeCastTargetId: deathKnight.id,
status: "attacking",
}] });
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob(targetId, 1_000_000);
advanceDungeonPartyCombat(1_000);
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: deathKnight.id,
targetId: deathKnight.id,
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
appliedAt: 1_000,
expiresAt: 13_000,
}));
expect(useCombatStore.getState().summons.some((summon) => (
summon.ownerActorId === deathKnight.id
&& summon.sourceAbilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID
))).toBe(false);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === deathKnight.id ? {
...member,
activeCast: {
abilityId: learnedDeathCoil.id,
school: "shadow",
mode: "cast",
startedAt: 3_000,
completesAt: 3_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
},
activeCastTargetId: targetId,
status: "attacking" as const,
} : member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(3_000);
const spellHits = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === deathKnight.id
&& event.targetActorId === targetId
&& event.abilityId === learnedDeathCoil.id
&& event.occurredAt === 3_000
));
expect(spellHits).toHaveLength(2);
expect(spellHits[1].rawAmount).toBe(Math.floor(spellHits[0].rawAmount * 0.5));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === deathKnight.id ? {
...member,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 100_000,
status: "attacking" as const,
} : member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(5_000);
const meleeHits = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === deathKnight.id
&& event.targetActorId === targetId
&& event.abilityId === "party-auto-attack"
&& event.occurredAt === 5_000
));
expect(meleeHits).toHaveLength(2);
expect(meleeHits[1].rawAmount).toBe(Math.floor(meleeHits[0].rawAmount * 0.5));
});
it("gives a RoM Rogue a separate off-hand swing and activates learned Off Hand Mastery", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const rogue = createConfiguredPartyMember({
name: "RoM Rogue",
classId: "rom-rogue",
secondaryClassId: "rom-warrior",
raceId: "rom-human",
specialization: "Rogue/Warrior",
role: "damage",
}, 80, 0, "rom-rogue-offhand-runtime");
const targetId = "rom-rogue-offhand-target";
usePartyStore.getState().initializeParty("rom-rogue-offhand-runtime", 80,
useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({
members: [{ ...rogue, gearStats: EMPTY_ITEM_STATS, nextActionAt: 100_000 }],
command: "attack",
activeMobId: targetId,
});
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob(targetId, 1_000_000);
const presentations: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") presentations.push(message.event);
});
advanceDungeonPartyCombat(1_000);
unsubscribe();
expect(presentations).toContainEqual(expect.objectContaining({
sourceActorId: rogue.id,
abilityId: "party-auto-attack-offhand",
name: "Off-hand Auto Attack",
phase: "impact",
}));
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === rogue.id
&& aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
))).toBe(true);
});
it("adds hand-specific CoA Chaos Dagger Fire packets to generated Rogue/Mage autos", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const rogue = createConfiguredPartyMember({
name: "RoM Rogue/Mage",
classId: "rom-rogue",
secondaryClassId: "rom-mage",
raceId: "rom-human",
specialization: "Rogue/Mage",
role: "damage",
}, 80, 0, "rom-rogue-mage-chaos-dagger-party");
const mainHandWeapon = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 };
const offHandWeapon = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 };
const offHandId = "rom-chaos-dagger-party-offhand";
const offHandItem = {
instanceId: offHandId,
itemId: 1,
itemKey: offHandId,
name: "Chaos Dagger Test Off-hand",
quality: "uncommon" as const,
inventorySlot: "off-hand",
armorType: null,
weaponType: "dagger",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...EMPTY_ITEM_STATS, meleeWeapon: offHandWeapon },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
usePartyStore.getState().initializeParty("rom-rogue-mage-chaos-dagger-party", 80,
useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({ members: [{
...rogue,
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000, meleeWeapon: mainHandWeapon },
nextActionAt: 100_000,
}] });
useCombatStore.setState((state) => ({
inventory: [...state.inventory, offHandItem],
equipment: { ...state.equipment, [rogue.id]: { "off-hand": offHandId } },
}));
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const targetId = "rom-rogue-mage-chaos-dagger-target";
useCombatStore.getState().registerMob(targetId, {
name: "Chaos Dagger party target",
maxHealth: 1_000_000,
level: 80,
schoolImmunities: ["fire"],
});
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().damageMob(targetId, 1, 100);
updatePartyRuntimeTargetVisibility(rogue.id, targetId, true);
usePartyStore.setState({ command: "attack", activeMobId: targetId });
advanceDungeonPartyCombat(1_000);
const damage = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.targetActorId === targetId
));
const firePackets = damage.filter((event) => event.school === "fire");
expect(firePackets.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]);
expect(firePackets.every((event) => event.effectiveAmount === 0)).toBe(true);
expect(damage.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
});
it("routes companion pet-targeted auras to that Hunter's live summoned pet", () => {
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
const summons = [
{ id: "hunter-one-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
{ id: "hunter-two-pet", ownerActorId: "hunter-two", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
{ id: "hunter-one-dead-pet", ownerActorId: "hunter-one", health: 0, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
{ id: "hunter-one-expired-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 1_000, kind: "summon" as const, sourceAbilityId: callPet.id },
{ id: "hunter-one-totem", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "totem" as const, sourceAbilityId: callPet.id },
];
expect(partyHunterPetRecipientIds(summons, "hunter-one", 2_000)).toEqual(["hunter-one-pet"]);
});
it("maintains HoTs on separate injured allies without resetting pending ticks", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 8 });
usePartyStore.getState().initializeParty("party-hot-coverage", 8, useCombatStore.getState().health, "damage");
@@ -117,7 +526,7 @@ describe("party combat runtime", () => {
const hot = abilities.find(ability => ability.name === "Rejuvenation")!;
usePartyStore.setState(state => ({ members: state.members.map(member => member.id === healer.id
? { ...member, classId: "druid", resourceType: "mana", resource: 10_000, maxResource: 10_000,
resourcePools: { mana: 10_000 }, talentRanks: {}, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
resourcePools: { mana: 10_000 }, talentRanks: { "talent-830": 3 }, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
: { ...member, health: member.maxHealth * .5 }) }));
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
@@ -125,6 +534,9 @@ describe("party combat runtime", () => {
advanceDungeonPartyCombat(1_000);
const first = useCombatStore.getState().effects.find(effect => effect.kind === "hot" && effect.sourceActorId === healer.id)!;
expect(first).toBeDefined();
const rejuvenationHot = hot.effects.find((effect) => effect.kind === "hot")!;
const baselineAmount = partyAbilityAmount({ ...healer, classId: "druid", talentRanks: {} }, hot, rejuvenationHot);
expect(first.fixedAmount).toBe(Math.round(baselineAmount * 1.2));
advanceDungeonPartyCombat(3_000);
const effects = useCombatStore.getState().effects.filter(effect => effect.kind === "hot" && effect.sourceActorId === healer.id);
expect(new Set(effects.map(effect => effect.targetId)).size).toBe(2);
@@ -294,6 +706,121 @@ describe("party combat runtime", () => {
}));
});
it.each([
{ rank: 0, shouldSlow: false },
{ rank: 2, shouldSlow: true },
])("applies generated Warlock Aftermath to landed companion Conflagrate at rank $rank", ({ rank, shouldSlow }) => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const seed = `party-warlock-aftermath-rank-${rank}`;
const warlock = createConfiguredPartyMember({
name: "Destruction Warlock",
classId: "warlock",
specialization: "Destruction",
specializationId: "warlock:destruction",
talentTreeId: "warlock-destruction",
talentRanks: { "talent-968": 1, "talent-982": rank },
role: "damage",
}, 80, 0, seed);
const conflagrate = abilityAtLevel(
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
80,
);
expect(learnedAbilityForBuild(
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
80,
warlock.talentRanks,
)).toMatchObject({ id: conflagrate.id });
const targetId = `party-warlock-aftermath-target-${rank}`;
const completionAt = 10_000;
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({ members: [{
...warlock,
gearStats: EMPTY_ITEM_STATS,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...warlock.resourcePools, mana: 10_000 },
activeCast: {
abilityId: conflagrate.id,
school: "fire",
mode: "cast",
startedAt: completionAt - conflagrate.castTimeMs,
completesAt: completionAt,
originalDurationMs: conflagrate.castTimeMs,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
},
activeCastTargetId: targetId,
status: "attacking",
}] });
registerPartyRuntimePosition(warlock.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
abilityId: conflagrate.id,
targetId,
});
registerFrostbiteTarget(targetId);
const immolate = abilityAtLevel(
abilitiesForClass("warlock").find((ability) => ability.name === "Immolate")!,
80,
);
const immolateDot = immolate.effects.find((effect) => effect.kind === "dot");
if (!immolateDot || immolateDot.kind !== "dot") throw new Error("Missing generated Immolate DoT.");
expect(useCombatStore.getState().applyTimedActorEffect(
warlock.id, targetId, immolate, immolateDot, 1_000,
)).toBe(true);
expect(partyAbilityTargetIsValid(warlock, conflagrate, targetId, [0, 0, 0], completionAt)).toBe(true);
const immolateEffectId = useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceActorId === warlock.id && effect.targetId === targetId
))!.id;
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
abilityId: conflagrate.id,
targetId,
});
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
const aftermathEvents: StructuredCombatEvent[] = [];
const stopAftermathEvents = subscribeCombatEvents((event) => aftermathEvents.push(event));
const aftermathPresentation: CombatPresentationEvent[] = [];
const stopAftermathPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event") aftermathPresentation.push(message.event);
});
advanceDungeonPartyCombat(completionAt);
stopAftermathEvents();
stopAftermathPresentation();
const mob = useCombatStore.getState().mobs[targetId]!;
expect(aftermathPresentation).toContainEqual(expect.objectContaining({
phase: "impact",
sourceActorId: warlock.id,
abilityId: conflagrate.id,
}));
const conflagrateDamage = aftermathEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === warlock.id && event.targetActorId === targetId
&& event.abilityId === conflagrate.id
));
expect(conflagrateDamage).toMatchObject({
rawAmount: 628,
effectiveAmount: expect.any(Number),
});
expect(mob.health).toBeLessThan(beforeHealth);
expect(useCombatStore.getState().effects.some((effect) => effect.id === immolateEffectId)).toBe(false);
const aftermath = mob.statuses.find((status) => (
status.kind === "slow" && status.sourceAbilityId === conflagrate.id && status.sourceActorId === warlock.id
));
if (shouldSlow) {
expect(aftermath).toMatchObject({
kind: "slow",
sourceAbilityId: conflagrate.id,
sourceActorId: warlock.id,
endsAt: completionAt + 4_000,
magnitude: 0.5,
});
} else {
expect(aftermath).toBeUndefined();
}
});
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";
@@ -708,9 +1235,21 @@ describe("party combat runtime", () => {
registerEngagedMob("pet-runtime-boss", 100_000);
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
let growlConfigured = false;
for (let now = 1_000; now <= 30_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
const hunterPet = useCombatStore.getState().summons.find((summon) => summon.ownerActorId === hunter.id
&& summon.hunterPet);
if (hunterPet && !growlConfigured) {
expect(hunterPet).toMatchObject({
hunterPetCommand: "follow",
hunterPetReactMode: "defensive",
hunterPetGrowlAutocastEnabled: false,
});
expect(useCombatStore.getState().setHunterPetGrowlAutocast(hunterPet.id, true)).toBe(true);
growlConfigured = true;
}
}
stop();
@@ -718,6 +1257,31 @@ describe("party combat runtime", () => {
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
));
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
const baseHunterPetStats = wow335HunterPetBaseStatsForLevel(hunter.level);
expect(pets.find((summon) => summon.ownerActorId === hunter.id)).toMatchObject({
hunterPet: {
baseStats: { strength: 192, attackPower: 364 },
effectiveStats: {
inheritedStamina: expect.any(Number),
inheritedRangedAttackPower: expect.any(Number),
inheritedSpellDamage: expect.any(Number),
},
},
});
const hunterPet = pets.find((summon) => summon.ownerActorId === hunter.id)!;
expect(growlConfigured).toBe(true);
expect(hunterPet.hunterPetGrowlReadyAt).toBeGreaterThan(0);
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.threatByActor[hunterPet.id]).toBeGreaterThanOrEqual(1_234);
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.forcedTarget).toBeNull();
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeGreaterThan(0);
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeCloseTo(
hunterPet.hunterPet!.effectiveStats.inheritedRangedAttackPower / 0.22
* WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
);
expect(hunterPet.maxHealth).toBe(hunterPet.hunterPet!.effectiveStats.maxHealth);
expect(hunterPet.attackDamage).toBe(hunterPet.hunterPet!.effectiveStats.averageAutoAttackDamage);
expect(hunterPet.maxHealth).toBeGreaterThan(baseHunterPetStats.maxHealth);
expect(hunterPet.attackDamage).toBeGreaterThan(baseHunterPetStats.averageAutoAttackDamage);
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
expect(events.filter((event) => event.kind === "damage"
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
+2081 -186
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -69,10 +69,17 @@ type PartyActiveCast = CombatActorCastState & {
readonly backdraftGcdMs?: number;
/** Tidal Waves charges spent at cast start, retained for Lesser Healing Wave's crit roll. */
readonly tidalWavesChargesConsumed?: number;
/** Maelstrom Weapon rank and stacks spent at cast start, retained for its PvP damage rider. */
readonly maelstromWeaponRank?: number;
readonly maelstromWeaponStacksConsumed?: 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;
/** DBC-derived pre-discount base Mana cost retained for Paladin Illumination. */
readonly illuminationBaseManaCost?: number;
/** Prevents later pulses of a channel cast from repeating Holy Candle's cast trigger. */
readonly romPriestHolyCandleCharged?: boolean;
};
export interface PartyMember {
+499 -45
View File
@@ -22,11 +22,63 @@ import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { overrideRomMageEffects } from "./romMageOverrides";
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
import { overrideRomDruidEffects } from "./romDruidOverrides";
import { romPriestDescription } from "./romPriestTooltips";
import { romPriestDescription, romPriestName } from "./romPriestTooltips";
import { overrideRomPriestMageEffects } from "./romPriestMageRuntime";
import { romPriestHolyBlessingEffects } from "./romPriestHolyBlessingRuntime";
import {
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
} from "./romPriestChampionBeaconRuntime";
import { overrideRomPriestWarlockEffects } from "./romPriestWarlockOverrides";
import { overrideRomChampionEffects } from "./romChampionOverrides";
import { ROM_WARDEN_BRIAR_SHIELD_TAG } from "./romWardenOverrides";
import { romWarriorCoaAdditionalAbilities } from "./romWarriorCoaCatalog";
import { romWarriorDruidRageOfCalmnessCatalogOverride } from "./romWarriorDruidRageOfCalmness";
import { romScoutPriestScreenAttackCatalogOverride } from "./romScoutPriestScreenAttack";
import { romScoutPriestDrumsOfWarCatalogOverride } from "./romScoutPriestOverrides";
import { romPriestScoutCuringShotCatalogOverride } from "./romPriestScoutCuringShot";
import { romWarlockWarriorEnragedCoaAdditionalAbilities } from "./romWarlockWarriorEnragedCoaMechanics";
import {
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST,
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION,
} from "./romWarlockMageSoulBrandSting";
import {
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST,
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION,
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS,
isRomWarlockPriestSoulEnlightenment,
} from "./romWarlockPriestSoulEnlightenment";
import {
ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION,
ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION,
ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION,
romDruidRogueSinisterRequestEffects,
isRomDruidRoguePair,
} from "./romDruidRogueCurrentCoa";
import {
isRomDruidMagePair,
ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
} from "./romDruidMageInspirationRuntime";
import {
isRomMageDruidPair,
ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
} from "./romMageDruidGreenGuardianRuntime";
import {
ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION,
ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID,
ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION,
ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
romRogueEnergizedHandsApplyEffect,
} from "./romRogueEnergizedHandsRuntime";
import {
ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION,
ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID,
} from "./romRogueChaosDagger";
import {
ROM_PRIEST_HOLY_CANDLE_COST,
ROM_PRIEST_HOLY_CANDLE_DESCRIPTION,
romPriestHolyCandleEffects,
} from "./romPriestHolyCandleRuntime";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -99,6 +151,13 @@ function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
}
function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "costs">): readonly AbilityCost[] {
if (row.sourceSkillId === 490274) return [];
if (row.sourceSkillId === 490275) return [{ resource: "mana", amount: 120 }];
if (row.sourceSkillId === 490276) return [{
resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana",
}];
if (row.sourceSkillId === 498612) return [{ resource: "rage", amount: 15 }];
if (row.sourceSkillId === 498642) return [{ resource: "rage", amount: 75, percentage: false }];
let resolved = costs(row.costs);
if (row.sourceSkillId === 490136) {
resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
@@ -108,26 +167,81 @@ function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "c
}
function cooldownMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "cooldownMs">): number {
if (row.sourceSkillId === 490274) return 0;
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return 18_000;
if (row.sourceSkillId === 498612) return 12_000;
if (row.sourceSkillId === 498642) return 1_000;
// The local snapshot omitted Prayer of Tribulation's timing. CoA's current
// skill list identifies this same Priest/Warlock spell (498770) as a 1.5s
// channel with a 5s cooldown; its healing and buff payload still come from
// the local RuneWaker tooltip/components below.
if (row.sourceSkillId === 498770) return 5_000;
// CoA's current Knight/Shaman Whirlwind Shield is 5s before Compassion.
if (row.sourceSkillId === 490175) return 5_000;
return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs;
}
function castTimeMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): number {
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return 0;
if (row.sourceSkillId === 498770) return 1_500;
if (row.sourceSkillId === 491164) return 4_000;
return row.castTimeMs;
}
function castModeFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): AbilityDefinition["castMode"] {
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return "instant";
return row.sourceSkillId === 491164 || row.sourceSkillId === 498770
? "channel"
: row.castTimeMs > 0 ? "cast" : "instant";
}
function currentWarlockCoaDescription(sourceSkillId: number, fallback: string): string {
// Current CoA Warrior/Warlock skill list: https://chroniclesofarcadia.com/en/wiki/skills/list/1-9
// These two local passive rows describe obsolete cooldown-reset children;
// the current page instead gives Speed Slash's Rage discount and Excellent
// Combat Training's physical-damage bonus.
const descriptions: Readonly<Record<number, string>> = {
499827: "Passive: Increases Slash damage by an unspecified amount, which is not modeled. Reduces Slash's Rage cost by 10.",
499806: "Passive: Grants an additional 20% Physical Damage.",
498642: "Converts 75 Rage into 6 Psi. 1-second cooldown.",
498634: "Enraged restores additional Focus. Casting Psychic Arrows, Warp Charge, or Weakening Weave Curse can reset Enraged's cooldown. Current CoA does not publish the proc chance or Focus amount, so these riders are not modeled.",
498633: "When Psychic Arrows hits, gain 12% healing power and 12% Wisdom for 20 seconds. Warp Charge heals a friendly target instead of dealing damage. The simulator does not model ally-target redirection to that ally's hostile target.",
498643: "Rising Tide becomes instant with a 5-second cooldown and restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
498648: "Urgent Heal restores 1 Psi. Defense Net's effect time is reduced by 1 second. Ring of Comfort's reset chance is not specified and is not modeled.",
498668: "Passively increases magical damage and healing by 16%.",
499856: "Inflicts [497959], [497960], and [497964] on the target. Increases Magical Attack Power by 18% and Magical Critical Bonus by 25% for 30 seconds, and activates Healing Fortune, increasing healing by 12% for 20 seconds. Current CoA lists a 10% MP cost plus 15 Focus and range 150; RuneWaker supplies 15 Focus and range 22.5. The simulator retains those local cost and range values because RoM Warlock has no modeled Mana pool and no verified range-unit conversion.",
499868: "Passive: magical damage skills call an Ethereal Hammer for 187.5% main-hand weapon DPS against area targets or an Ethereal Sword for 1,050% main-hand weapon DPS against a single target. The source calls this Physical Dark damage, which the combat model cannot represent as a composite damage type, so this rider is not yet applied. The additional main-hand damage for Slash and Consistent Heart Strike is unspecified and is not modeled.",
499870: "Increases Breathing Control's Focus recovery and grants an extra 15% HP. Current CoA does not publish the Focus amount or proc chance, so these riders are not modeled.",
};
return descriptions[sourceSkillId] ?? fallback;
}
const ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION = "Reworks Shadow Step (490344): the cast deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. This replaces Shadow Step's teleport; the simulator does not model the behind-target positioning.";
const ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION = "Instantly moves behind the target. In the Warlock/Rogue build with Shadow Slice (498678), it instead deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. The simulator cannot model the behind-target positioning.";
function currentPriestCoaDescription(sourceSkillId: number, fallback: string): string {
if (sourceSkillId === 490304) {
return ROM_PRIEST_HOLY_CANDLE_DESCRIPTION;
}
if (sourceSkillId === 498620) {
return "Creates a 30-second Beacon on one friendly party member. Each direct healing cast restores 770 HP to that member. CoA publishes 770 at skill level +105 (the maximum); the simulator applies this value at every unlocked rank because lower skill ranks are not modeled separately. HoT ticks and channel pulses are outside the simulator's direct-cast trigger boundary.";
}
if (sourceSkillId === 492933) {
return "Passive: Increases Wisdom by 12%.";
}
if (sourceSkillId === 498612) {
return "Inflicts 975.1% CPS Light Damage to nearby targets around you and reduces Blessing of Humility's cooldown by 15 seconds. Cost: 15 Rage. Radius: 80. Cooldown: 12 seconds.";
}
return romPriestDescription(sourceSkillId, fallback);
}
function currentMageCoaDescription(sourceSkillId: number, fallback: string): string {
if (sourceSkillId === 490248) {
return "Increases Fire resistance by 10 points and reduces received magical damage by 11.5% within radius 100. CoA lists +230 Fire resistance at skill level 105, but the simulator retains RuneWaker's local +10 because it has no source-backed scaling curve or skill-level state. Costs 120 MP; range 150; instant. CoA omits a duration, so this catalog retains RuneWaker's 10-minute duration.";
}
return fallback;
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">
& Partial<Pick<GeneratedSkill, "classId" | "secondaryClassId">>;
@@ -169,6 +283,8 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
}
function abilityTarget(row: Pick<GeneratedSemanticRow, "sourceSkillId" | "target">): GeneratedSemanticRow["target"] {
if (row.sourceSkillId === 490274 || row.sourceSkillId === 498612) return "self";
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return "hostile";
if ([490290, 490491, 492636, 493531, 494021, 494027, 498641].includes(row.sourceSkillId)) return "friendly";
// RuneWaker's tooltip calls this a four-pulse heal on a friendly target;
// sourceTarget 7 is stale/conflicting and was incorrectly making it hostile.
@@ -781,6 +897,85 @@ function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[]
}
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
if (isRomDruidRoguePair(row) && row.sourceSkillId === 494023) {
return romDruidRogueSinisterRequestEffects();
}
if (row.classId === "rom-priest") {
const holyBlessing = romPriestHolyBlessingEffects(row);
if (holyBlessing) return holyBlessing;
if (row.sourceSkillId === 498620) {
return [{
kind: "apply-aura",
aura: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
recipient: "ability-target",
sourceEffectType: 1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 498620,
}];
}
if (row.sourceSkillId === 490275) {
const silence: AbilityEffect = {
kind: "apply-aura",
aura: {
id: "rom-priest-vow-of-silence",
name: "Vow of Silence",
disposition: "debuff",
durationMs: 8_400,
dispelCategory: "magic",
},
recipient: "ability-target",
control: { kind: "silence" },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 490275,
};
return [silence, { kind: "interrupt", lockoutMs: 8_400 }];
}
if (row.sourceSkillId === 490276) {
return [
{
kind: "damage",
coefficient: 0,
basePoints: 1_655,
pointsPerLevel: 0,
sourceLevel: 1,
school: "holy",
},
{
kind: "apply-aura",
aura: {
id: "rom-priest-holy-void",
name: "Voidal Infection",
disposition: "debuff",
durationMs: 20_000,
dispelCategory: "magic",
modifiers: [
{ kind: "stat", stat: "physical-defense", operation: "percent", value: -0.188 },
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 },
],
},
recipient: "ability-target",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 490276,
},
];
}
if (row.sourceSkillId === 498612) {
return [
{ kind: "damage", coefficient: 9.751, school: "holy" },
{
kind: "cooldown-reset",
mode: "reduce",
abilityName: "Blessing of Humility",
amountMs: 15_000,
},
];
}
}
const specialized = specializedEffects(row);
if (specialized) return specialized;
const semantics = translateRomAbilitySemantics(row);
@@ -792,6 +987,8 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
},
} : effect);
const holyCandle = romPriestHolyCandleEffects(row, translated);
if (holyCandle) return holyCandle;
const priestMageOverride = overrideRomPriestMageEffects(row, translated);
if (priestMageOverride) return priestMageOverride;
const championOverride = overrideRomChampionEffects(row, translated);
@@ -952,7 +1149,7 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
if (row.sourceSkillId === 490269) {
return translated.filter((effect) => effect.kind === "hot").slice(0, 1);
}
if (row.sourceSkillId === 490304 || row.sourceSkillId === 490434) {
if (row.sourceSkillId === 490434) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
@@ -1010,8 +1207,8 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
} : effect
));
}
if (row.sourceSkillId === 490347) {
return [{ kind: "status", status: "sleep", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
if (row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID && row.classId === "rom-rogue") {
return [romRogueEnergizedHandsApplyEffect()];
}
if (row.sourceSkillId === 490454) {
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
@@ -1042,11 +1239,16 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] },
} : effect);
}
if (row.sourceSkillId === 490329) {
if (row.sourceSkillId === 490329 && row.classId === "rom-rogue") {
const aura = translated.find((effect) => effect.kind === "apply-aura");
return aura?.kind === "apply-aura" ? [{
...aura,
aura: { ...aura.aura, durationMs: 900_000, utilityTags: ["rom-poisonous"] },
aura: {
...aura.aura,
durationMs: 900_000,
exclusiveGroup: ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
utilityTags: ["rom-poisonous"],
},
}] : translated;
}
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
@@ -1076,14 +1278,28 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
return translated;
}
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
function areaRadius(row: Pick<GeneratedSkill, "sourceSkillId" | "description" | "range">): number | undefined {
// Rune Overload's local tooltip says only "immediate vicinity". Its
// hash-matched source has no numeric radius, so do not apply the generic
// heuristic's 10-yard fallback to this self-targeted ability.
if (row.sourceSkillId === 498572) return undefined;
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
if (!/\b(?:multiple targets|multiple enemies|all targets|all enemies|nearby targets|nearby party members|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) (?:within (?:a )?range|in a range|in range))\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius)\s+(?:of\s+)?(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10));
}
function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] | undefined {
function sourceTargeting(sourceSkillId: number, classId?: string, secondaryClassId?: string): AbilityDefinition["targeting"] | undefined {
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494321) return { shape: "circle", radius: 8 };
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return { shape: "circle", radius: 15 };
if (sourceSkillId === 498612) return { shape: "circle", radius: 80 };
// Current CoA Fire Ward (490248) is a radius-100 raid aura. RoM's local
// runtime uses one tenth of the published distance units, matching range 150
// to 15 for this same catalog row.
if (sourceSkillId === 490248) return { shape: "circle", radius: 10 };
// CoA's current Whirlwind Shield (1493719) is a 100-radius multi-target cast;
// map the exact-name local RuneWaker row to that current shape.
if (sourceSkillId === 490175 || sourceSkillId === 1_493_719) return { shape: "circle", radius: 100 };
if (sourceSkillId === 490311 || sourceSkillId === 490337) return { shape: "unit", requiresBehindTarget: true };
if (sourceSkillId === 490239) return { shape: "line", radius: 12 };
if (sourceSkillId === 498201 || sourceSkillId === 498202) return { shape: "circle", radius: 7.5 };
@@ -1109,28 +1325,116 @@ function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"]
return undefined;
}
function sourceRange(sourceSkillId: number, fallback: number): number {
function sourceRange(sourceSkillId: number, fallback: number, classId?: string, secondaryClassId?: string): number {
if (sourceSkillId === 490274) return 1;
if (classId === "rom-priest" && secondaryClassId === undefined && sourceSkillId === 490304) return 1;
if (sourceSkillId === 490275 || sourceSkillId === 490276 || sourceSkillId === 498612) return 150;
if (isRomDruidRoguePair({ classId, secondaryClassId }) && [494024, 494321].includes(sourceSkillId)) return 15;
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return 5;
// RoM tooltip ranges are expressed in decimeters; Phoenix's source row is
// self-targeted, so use its described 120-unit path length for line casting.
return sourceSkillId === 490239 ? 12 : fallback;
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
const rankCosts = costsFor(row);
const currentDruidRogue = isRomDruidRoguePair(parent);
const currentDruidMageInspiration = isRomDruidMagePair(parent) && row.sourceSkillId === 494322;
const currentMageDruidGreenGuardian = isRomMageDruidPair(parent)
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
const currentCoaHolyCandle = parent.classId === "rom-priest"
&& parent.secondaryClassId === undefined
&& row.sourceSkillId === 490304;
const currentCoaRogueEnergizedHands = parent.classId === "rom-rogue"
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
const currentCoaRoguePoisonousHands = parent.classId === "rom-rogue" && row.sourceSkillId === 490329;
const currentCoaRogueMageChaosDagger = parent.classId === "rom-rogue"
&& parent.secondaryClassId === "rom-mage"
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-mage";
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
sourceSkillId: row.sourceSkillId,
});
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172
&& parent.classId === "rom-knight" && parent.secondaryClassId === undefined;
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
&& parent.classId === "rom-knight" && parent.secondaryClassId === "rom-rogue";
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|| currentDruidMageInspiration;
const passiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger;
const rankCosts = currentMageDruidGreenGuardian ? []
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
: currentCoaRogueEnergizedHands || currentCoaRogueMageChaosDagger ? []
: currentDruidRogue && row.sourceSkillId === 494321
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
: currentDruidRogue && row.sourceSkillId === 494024
? [{ resource: "energy" as const, amount: 25 }]
: currentDruidRogue && row.sourceSkillId === 494023
? []
: currentCoaSoulBrandSting
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
: currentCoaPassive ? [] : costsFor(row);
const target = abilityTarget({ ...row, target: row.target ?? parent.target });
const priestRankName = parent.classId === "rom-priest" ? romPriestName(row.sourceSkillId, row.name) : row.name;
const currentClassDescription = currentMageDruidGreenGuardian
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
: currentDruidMageInspiration
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
: currentCoaRogueEnergizedHands
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
: currentCoaRoguePoisonousHands
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
: currentCoaRogueMageChaosDagger
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
: parent.classId === "rom-priest"
? currentPriestCoaDescription(row.sourceSkillId, row.description)
: parent.classId === "rom-mage"
? currentMageCoaDescription(row.sourceSkillId, row.description)
: currentDruidRogue && row.sourceSkillId === 494321
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494024
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494023
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
: row.description;
const currentWarlockRogueShadowSlice = row.sourceSkillId === 498678
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-rogue";
const rankDescription = currentCoaSoulBrandSting
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
: currentCoaSoulEnlightenment
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
: currentWarlockRogueShadowSlice
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
: row.sourceSkillId === 490344 && parent.classId === "rom-rogue"
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
: currentWarlockCoaDescription(row.sourceSkillId, currentClassDescription);
return {
rank: row.rank,
spellId: row.sourceSkillId,
level: row.level,
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
level: currentMageDruidGreenGuardian ? 25 : row.level,
// AbilityRankDefinition has no name field, so retain the canonical CoA
// name in the rank copy when a local row was renamed.
description: parent.classId === "rom-priest" && priestRankName !== row.name
&& !rankDescription.toLowerCase().startsWith(priestRankName.toLowerCase())
? `${priestRankName}: ${rankDescription}`
: rankDescription,
castMode: passiveMode || currentMageDruidGreenGuardian ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
castTimeMs: passiveMode || currentMageDruidGreenGuardian ? 0 : currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 1_000
: currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
cooldownMs: passiveMode || currentMageDruidGreenGuardian || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 0
: currentDruidRogue && row.sourceSkillId === 494321 ? 0
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
: currentCoaHolyCandle ? 30_000
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
costs: rankCosts,
effects: effects({
effects: currentCoaPassive ? [] : effects({
...row,
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
@@ -1146,44 +1450,179 @@ function ability(row: GeneratedSkill): AbilityDefinition {
const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId)
? row.secondaryClassId as RomClassId
: undefined;
const abilityCosts = costsFor(row);
const currentDruidRogue = isRomDruidRoguePair({ classId, secondaryClassId });
const currentMageDruidGreenGuardian = isRomMageDruidPair({ classId, secondaryClassId })
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
const currentDruidMageInspiration = isRomDruidMagePair({ classId, secondaryClassId })
&& row.sourceSkillId === 494322;
const currentCoaHolyCandle = classId === "rom-priest"
&& secondaryClassId === undefined
&& row.sourceSkillId === 490304;
const currentCoaRogueEnergizedHands = classId === "rom-rogue"
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
const currentCoaRoguePoisonousHands = classId === "rom-rogue" && row.sourceSkillId === 490329;
const currentCoaRogueMageChaosDagger = classId === "rom-rogue"
&& secondaryClassId === "rom-mage"
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
const currentCoaCompassion = row.sourceSkillId === 491_635;
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172 && classId === "rom-knight";
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
&& classId === "rom-knight" && secondaryClassId === "rom-rogue";
const currentCoaPsychicWarrior = row.sourceSkillId === 499868
&& classId === "rom-warlock" && secondaryClassId === "rom-warrior";
const currentCoaShadowSlice = row.sourceSkillId === 498678
&& classId === "rom-warlock" && secondaryClassId === "rom-rogue";
const currentCoaShadowStep = row.sourceSkillId === 490344 && classId === "rom-rogue";
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
&& classId === "rom-warlock" && secondaryClassId === "rom-mage";
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
classId,
secondaryClassId,
sourceSkillId: row.sourceSkillId,
});
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|| currentCoaPsychicWarrior || currentCoaShadowSlice || currentDruidMageInspiration;
const currentCoaPassiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger || currentMageDruidGreenGuardian;
const abilityCosts = currentMageDruidGreenGuardian ? []
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
: currentCoaRogueEnergizedHands ? []
: currentDruidRogue && row.sourceSkillId === 494321
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
: currentDruidRogue && row.sourceSkillId === 494024
? [{ resource: "energy" as const, amount: 25 }]
: currentDruidRogue && row.sourceSkillId === 494023
? []
: currentCoaSoulBrandSting
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
: costsFor(row);
const target = abilityTarget(row);
const currentKnightName = currentCoaRogueEnergizedHands
? "Energized Hands"
: currentCoaRoguePoisonousHands
? "Poisonous Hands"
: currentCoaRogueMageChaosDagger
? "Chaos Dagger"
: row.sourceSkillId === 491_635
? "Compassion of Holy Light"
: currentCoaPsychicWarrior
? "Psychic Warrior"
: currentDruidRogue && row.sourceSkillId === 494023
? "Sinister Request"
: row.name;
const currentAbilityName = currentCoaShadowSlice
? "Shadow Slice"
: classId === "rom-priest"
? romPriestName(row.sourceSkillId, currentKnightName)
: currentKnightName;
const currentKnightDescription = currentMageDruidGreenGuardian
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
: currentDruidMageInspiration
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
: currentCoaRogueEnergizedHands
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
: currentCoaRoguePoisonousHands
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
: currentCoaRogueMageChaosDagger
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
: currentCoaShadowSlice
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
: currentCoaShadowStep
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494321
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494024
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
: currentDruidRogue && row.sourceSkillId === 494023
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
: row.sourceSkillId === 491_639
? "Increases aggro generated by Whirlwind and reduces its Rage cost and cooldown. On successful hits, Whirlwind inflicts Deadly Wound, reducing the target's received healing by 30% for 10 seconds (5 seconds against players)."
: row.sourceSkillId === 491_608
? "Increases Punishment damage by 28.5%. When Punishment hits, the target takes 2% more damage for every Holy Seal stack it has for 10 seconds."
: row.sourceSkillId === 491_635
? "Current CoA lists this reused passive under Knight/Shaman, while the local generated row is Knight/Priest and this simulator has no Shaman class entry. The exact-ID local passive is bridged to Whirlwind Shield (CoA skill 1493719): a successful hit heals nearby allies for 120 + 0.2 x STA and triggers Holy Illumination; cooldown becomes 4 seconds instead of 5. CoA also scales its shield-defense damage term to 5x, but this simulator has no shield-defense stat, so that damage term is not modeled."
: currentCoaHolyPowerExplosion
? "Passive: increases Aggro generated by Knight abilities by 198%."
: currentCoaLeopardInstinct
? "Passive: each hit received adds one stack, up to 10. Each stack grants 3% Dodge and 3% Magic Resistance Rate; each Dodge removes one stack. This simulator applies Dodge only because it has no Magic Resistance Rate consumer."
: currentWarlockCoaDescription(row.sourceSkillId, row.description);
const currentWarriorCoaDescription = row.sourceSkillId === 493248
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue"
? "Passive: gives the Warrior/Rogue CoA Probing Attack (493283) a 30% chance on a landed hit to restore 25 Energy and 50 Rage."
: currentKnightDescription;
const currentDescription = currentCoaSoulBrandSting
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
: currentCoaSoulEnlightenment
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
: classId === "rom-priest"
? currentWarlockCoaDescription(row.sourceSkillId, currentPriestCoaDescription(row.sourceSkillId, currentKnightDescription))
: classId === "rom-mage"
? currentMageCoaDescription(row.sourceSkillId, currentWarriorCoaDescription)
: currentWarriorCoaDescription;
const currentCoaDescriptionOverride = currentMageDruidGreenGuardian || currentCoaPassive || currentCoaShadowStep || currentCoaSoulBrandSting || currentCoaSoulEnlightenment
|| currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands || currentCoaRogueMageChaosDagger
|| (currentDruidRogue && [494321, 494024, 494023].includes(row.sourceSkillId)) || (row.sourceSkillId === 493248
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue")
|| (row.sourceSkillId === 490248 && classId === "rom-mage");
const currentRanks = row.ranks.map((entry) => rank(entry, row)).map((entry) => ({
...entry,
...(currentCoaDescriptionOverride ? { description: currentDescription } : {}),
...(currentDruidRogue && row.sourceSkillId === 494023
? { effects: romDruidRogueSinisterRequestEffects() }
: {}),
}));
return {
id: row.id,
classId,
...(secondaryClassId ? { secondaryClassId } : {}),
dbcSpellId: row.sourceSkillId,
name: row.name,
unlockLevel: row.unlockLevel,
name: currentAbilityName,
unlockLevel: currentMageDruidGreenGuardian ? 25 : row.unlockLevel,
icon: row.icon,
sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
target,
range: { min: 0, max: sourceRange(row.sourceSkillId, row.range) },
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
sigil: currentAbilityName.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: currentDescription,
target: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? "self" : target,
range: { min: 0, max: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands
? 0 : sourceRange(row.sourceSkillId, row.range, classId, secondaryClassId) },
...(currentCoaSoulEnlightenment
? { radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS }
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
...(currentCoaSoulEnlightenment
? { targeting: { shape: "circle" as const, radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS } }
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)
? { targeting: sourceTargeting(row.sourceSkillId, classId, secondaryClassId) } : {}),
castMode: currentCoaPassiveMode ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
castTimeMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
cooldownMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 0
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
: currentCoaHolyCandle ? 30_000
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: abilityCosts,
effects: effects({ ...row, target, classId }),
passive: row.passive,
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
cost: currentCoaPassiveMode ? { resource: "mana", amount: 0 } : abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: currentCoaPassiveMode ? [] : abilityCosts,
effects: currentCoaPassive ? [] : effects({ ...row, target, classId, secondaryClassId }),
passive: currentMageDruidGreenGuardian ? false : currentCoaPassiveMode || row.sourceSkillId === 490274 ? true : row.passive,
source: "runewaker",
sourceUnlockLevel: row.unlockLevel,
ranks: row.ranks.map((entry) => rank(entry, row)),
romSkillGroup: row.group,
ranks: currentRanks,
romSkillGroup: currentMageDruidGreenGuardian ? "elite" : row.group,
};
}
const generatedAbilities = generated.map(ability);
const generatedAbilities = generated.map(ability)
.map(romWarriorDruidRageOfCalmnessCatalogOverride)
.map(romScoutPriestScreenAttackCatalogOverride)
.map(romScoutPriestDrumsOfWarCatalogOverride)
.map(romPriestScoutCuringShotCatalogOverride);
export const ROM_ALL_ABILITIES = Object.freeze([
...generatedAbilities,
...romPriestKnightAdditionalAbilities(generatedAbilities),
...romWarriorCoaAdditionalAbilities(generatedAbilities),
...romWarlockWarriorEnragedCoaAdditionalAbilities(generatedAbilities),
]);
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
@@ -1195,20 +1634,35 @@ for (const parent of generated) {
}
}
// Rune Overload's generated player row contains the hidden MagicCollect child
// ID on component 621241's one-second on-time trigger. The child itself is not
// a learned/player row, but its tooltip-referenced damage MagicObject (621243)
// is retained in the hash-matched parent source payload. Alias the child spell
// ID to that existing payload so the generic triggered-spell resolver can
// expand it without inventing a radius or target cap.
const runeOverloadParent = generated.find((entry) => entry.sourceSkillId === 498572);
const runeOverloadChildPayload = runeOverloadParent?.source?.components?.find((component) => component.id === 621243);
const hiddenTriggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
if (runeOverloadParent && runeOverloadChildPayload) {
hiddenTriggeredComponentOwners.set(498574, { parent: runeOverloadParent, component: runeOverloadChildPayload });
}
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
const owner = triggeredComponentOwners.get(spellId);
const owner = hiddenTriggeredComponentOwners.get(spellId) ?? triggeredComponentOwners.get(spellId);
if (!owner) return null;
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
if (!parentAbility) return null;
const triggeredEffects = effects({
sourceSkillId: spellId,
name: `${owner.parent.name} trigger`,
description: "",
target: owner.parent.target,
passive: false,
source: { componentIds: [spellId], components: [owner.component] },
}).filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported");
const triggeredEffects = spellId === 498574
? [{ kind: "damage", coefficient: 0.6, school: "physical" } satisfies AbilityEffect]
: effects({
sourceSkillId: spellId,
name: `${owner.parent.name} trigger`,
description: "",
target: owner.parent.target,
passive: false,
source: { componentIds: [spellId], components: [owner.component] },
}).filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported");
if (!triggeredEffects.length) return null;
return {
...parentAbility,
@@ -0,0 +1,66 @@
import { beforeAll, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses, triggeredAbilityBySpellId } from "./abilityCatalog";
import { ROM_ALL_ABILITIES, romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import rawCatalog from "./romPlayerCatalog.generated.json";
const generated = rawCatalog.skills as unknown as readonly {
readonly sourceSkillId: number;
readonly target: "hostile" | "friendly" | "self";
readonly source?: {
readonly sourceTarget?: number;
readonly components?: readonly {
readonly id: number;
readonly attack?: { readonly damagePower: number; readonly damagePowerSkillLevelArg: number };
readonly triggers?: { readonly onTimeMagicId?: number; readonly onTimeSeconds?: number };
}[];
};
}[];
describe("RuneWaker Rune Overload hidden trigger catalog", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-champion"]);
});
it("retains the hash-matched one-second child link and supported damage coefficient", () => {
const parentSource = generated.find((entry) => entry.sourceSkillId === 498572);
expect(parentSource?.target).toBe("self");
expect(parentSource?.source?.sourceTarget).toBe(0);
expect(parentSource?.source?.components).toEqual(expect.arrayContaining([
expect.objectContaining({
id: 621241,
triggers: expect.objectContaining({ onTimeMagicId: 498574, onTimeSeconds: 1 }),
}),
expect.objectContaining({
id: 621243,
attack: expect.objectContaining({ damagePower: -0.6 }),
}),
]));
const parent = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498572);
const periodicChildProc = parent?.effects
.filter((effect) => effect.kind === "apply-aura")
.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.procs ?? [] : [])
.find((proc) => proc.action.kind === "custom"
&& proc.action.id === "rom-trigger-skill"
&& proc.action.data?.spellId === 498574);
expect(periodicChildProc).toMatchObject({
triggers: "periodic",
internalCooldownMs: 1_000,
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: 498574 } },
});
const child = romTriggeredAbilityBySpellId(498574);
expect(child).toMatchObject({
dbcSpellId: 498574,
classId: "rom-champion",
target: "self",
effects: [{ kind: "damage", coefficient: 0.6, school: "physical" }],
});
expect(child?.radius).toBeUndefined();
expect(child?.targeting).toBeUndefined();
});
it("is visible through the shared triggered-spell resolver used by RoM player and party dispatch", () => {
expect(triggeredAbilityBySpellId(498574, false)).toEqual(romTriggeredAbilityBySpellId(498574));
});
});
+47 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { abilityById, type AbilityEffect } from "./abilityCatalog";
import { abilityById, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics";
@@ -19,6 +19,26 @@ function kinds(effects: readonly AbilityEffect[]): readonly string[] {
}
describe("RoM primary class conditional combat effects", () => {
it("applies learned Priest/Warrior Condensed Rage only to physical damage", () => {
const source = ROM_ALL_ABILITIES.find((ability) => ability.classId === "rom-priest")!;
const physicalAbility: AbilityDefinition = {
...source,
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "physical" }],
};
const holyAbility: AbilityDefinition = {
...physicalAbility,
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "holy" }],
};
const otherClassAbility: AbilityDefinition = { ...physicalAbility, classId: "rom-warrior" };
const withPassive = { ...empty, learnedPassiveSpellIds: [493038] };
const withoutPassive = { ...empty, learnedPassiveSpellIds: [] };
expect(romEffectsForCast(physicalAbility, withPassive)[0]).toMatchObject({ basePoints: 122, school: "physical" });
expect(romEffectsForCast(physicalAbility, withoutPassive)[0]).toMatchObject({ basePoints: 100 });
expect(romEffectsForCast(holyAbility, withPassive)[0]).toMatchObject({ basePoints: 100, school: "holy" });
expect(romEffectsForCast(otherClassAbility, withPassive)[0]).toMatchObject({ basePoints: 100 });
});
it("recognizes source-required shields and ranged weapons across primary and elite rows", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
for (const spellId of [491333, 491484]) expect(romAbilityNeedsShield(sourceAbility(spellId))).toBe(true);
@@ -73,7 +93,7 @@ describe("RoM primary class conditional combat effects", () => {
expect(cast("rom-knight-general-0")).toEqual([]);
const one = cast("rom-knight-general-0", { sealStacks: 1 });
const four = cast("rom-knight-general-0", { sealStacks: 4 });
expect(kinds(one)).toEqual(["damage", "dot", "combo"]);
expect(kinds(one)).toEqual(["damage", "combo"]);
expect((four[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
.toBe(4 * ((one[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0));
});
@@ -139,7 +159,7 @@ describe("RoM primary class conditional combat effects", () => {
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491]))
.toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 10_000 });
.toEqual({ rageCostReduction: 0, cooldownReductionMs: 0 });
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({
rageCostReduction: 0, cooldownReductionMs: 0,
});
@@ -147,6 +167,28 @@ describe("RoM primary class conditional combat effects", () => {
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 });
});
it("removes only the four retired Holy Seal DoTs and applies CoA Probing Attack's conditional weapon-DPS value", () => {
for (const spellId of [490148, 490149, 490150, 490151]) {
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId)!;
const resolved = romEffectsForCast(ability, spellId === 490151 ? { ...empty, sealStacks: 4 } : empty);
expect(resolved.some((effect) => effect.kind === "dot")).toBe(false);
expect(resolved.some((effect) => effect.kind === "damage")).toBe(true);
if (spellId === 490148 || spellId === 490150) {
expect(resolved.some((effect) => effect.kind === "combo")).toBe(true);
}
}
const probing = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === 493283)!;
const base = romEffectsForCast(probing, empty).find((effect) => effect.kind === "damage");
const bleedEmpowered = romEffectsForCast(probing, { ...empty, bleedCount: 1 })
.find((effect) => effect.kind === "damage");
const hammerEmpowered = romEffectsForCast(probing, { ...empty, mainHandTwoHandHammer: true })
.find((effect) => effect.kind === "damage");
expect(base).toMatchObject({ kind: "damage", weaponDpsCoefficient: 6.643 });
expect(bleedEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
expect(hammerEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
});
it("applies Warrior/Warlock's physical-damage passive and Heroic Surprise Attack's on-hit payload", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
const rageBlow = sourceAbility(498706);
@@ -213,10 +255,11 @@ describe("RoM primary class conditional combat effects", () => {
});
it("uses one breakable control effect for each single-target restraint", () => {
for (const id of ["rom-scout-primary-4", "rom-rogue-primary-5", "rom-mage-primary-56"]) {
for (const id of ["rom-scout-primary-4", "rom-mage-primary-56"]) {
const effects = cast(id);
expect(effects).toHaveLength(1);
expect(effects[0]).toMatchObject({ kind: "status", maxTargets: 1, breakOnDamage: true });
}
expect(kinds(cast("rom-rogue-primary-5"))).toEqual(["apply-aura"]);
});
});
+40 -5
View File
@@ -8,6 +8,10 @@ import { romChampionCastEffects } from "./romChampionOverrides";
import { romWardenCastEffects } from "./romWardenOverrides";
import { romWarlockCastEffects } from "./romWarlockOverrides";
import { romKnightCastEffects } from "./romKnightOverrides";
import { romPriestWarriorCondensedRagePhysicalDamageMultiplier } from "./romPriestChampionOverrides";
import { withoutCurrentCoaHolySealPeriodicDamage } from "./romWarriorCurrentCoaMechanics";
import { romCoaWarriorRogueProbingAttackDpsCoefficient } from "./romWarriorRogueExhaustion";
import { isRomDruidRoguePair, ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG } from "./romDruidRogueCurrentCoa";
const ROGUE_BLEED_ABILITIES = new Set([
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
@@ -21,12 +25,16 @@ export interface RomCastMechanics {
readonly bleedCount: number;
readonly charged: boolean;
readonly mainHandDagger: boolean;
readonly mainHandTwoHandHammer?: boolean;
readonly casterMaxHealth: number;
readonly targetHealthPercent: number;
readonly friendlyTargetHealthPercent?: number;
readonly naturePower?: number;
readonly passiveTags?: readonly string[];
readonly learnedPassiveSpellIds?: readonly number[];
/** True when the ability's default damage school resolves to Physical. */
readonly defaultDamageIsPhysical?: boolean;
readonly selectedTargetRelationship?: "friendly" | "hostile";
readonly eruptionProcRoll?: number;
readonly targetTags?: readonly string[];
readonly slashBleed?: boolean;
@@ -46,7 +54,7 @@ export function romSelfDrainPercent(spellId: number): number {
export function romSingleTargetControl(ability: AbilityDefinition | null): boolean {
return ability?.source === "runewaker"
&& [490347, 490454, 491172].includes(ability.dbcSpellId);
&& [490454, 491172].includes(ability.dbcSpellId);
}
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
@@ -80,6 +88,7 @@ export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
if (ability.source !== "runewaker") return null;
if (ability.dbcSpellId === 494321 && isRomDruidRoguePair(ability)) return ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG;
if (ability.dbcSpellId === 492617) return "rom-weakened";
if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable";
return null;
@@ -102,7 +111,6 @@ export function romWarriorAbilityAdjustment(
491458: { abilitySpellId: 490121, rageCostReduction: 10, cooldownReductionMs: 20_000 }, // Moon Cleave Perfection
493248: { abilitySpellId: 490133, rageCostReduction: 20, cooldownReductionMs: 10_000 }, // Exhaustion
499491: { abilitySpellId: 490133, rageCostReduction: 25, cooldownReductionMs: 15_000 }, // Whirlwind Battle Technique
494377: { abilitySpellId: 490133, rageCostReduction: 10, cooldownReductionMs: 10_000 }, // Recovery of Life
499827: { abilitySpellId: 490053, rageCostReduction: 10, cooldownReductionMs: 0 }, // Speed Slash
};
const applicable = Object.entries(rules)
@@ -195,7 +203,12 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
context.friendlyTargetHealthPercent ?? 1,
context.passiveTags ?? [],
);
const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []);
const knightEffects = romKnightCastEffects(
ability,
priestWarlockEffects,
context.passiveTags ?? [],
context.sealStacks,
);
const mageWarlockEffects = romMageWarlockCastEffects(
ability,
knightEffects,
@@ -216,13 +229,26 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
context.targetTags ?? [],
context.learnedPassiveSpellIds ?? [],
context.targetHealthPercent,
context.selectedTargetRelationship ?? "hostile",
);
const coaEffects = withoutCurrentCoaHolySealPeriodicDamage(ability.dbcSpellId, effects);
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
const condensedRageMultiplier = ability.classId === "rom-priest"
? romPriestWarriorCondensedRagePhysicalDamageMultiplier(
(context.learnedPassiveSpellIds ?? []).includes(493038),
)
: 1;
const condensedRageEffects = condensedRageMultiplier > 1
? coaEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
&& (effect.school ?? (context.defaultDamageIsPhysical ? "physical" : undefined)) === "physical"
? scaleOutput(effect, condensedRageMultiplier)
: effect)
: coaEffects;
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
? effects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
? condensedRageEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
? { ...effect, school: "physical" as const }
: effect)
: effects;
: condensedRageEffects;
const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806)
? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
&& (effect.school === undefined || effect.school === "physical")
@@ -234,6 +260,15 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage");
const seals = Math.max(0, Math.min(4, context.sealStacks));
switch (ability.dbcSpellId) {
case 493283: { // Current CoA Warrior/Rogue Probing Attack has a sourced empowered weapon-DPS branch.
const weaponDpsCoefficient = romCoaWarriorRogueProbingAttackDpsCoefficient({
hasCasterOwnedBleed: context.bleedCount > 0,
equippedTwoHandHammer: context.mainHandTwoHandHammer ?? false,
});
return warriorAdjustedEffects.map((effect) => effect.kind === "damage"
? { ...effect, weaponDpsCoefficient }
: effect);
}
case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target.
return [
...damage,
+2 -2
View File
@@ -668,7 +668,7 @@ describe("RuneWaker combat runtime", () => {
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
});
it("casts Priest/Scout Curing Shot as a five-target friendly heal and HoT", () => {
it("casts Priest/Scout Curing Shot as an uncapped-radius friendly heal and HoT", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
@@ -683,7 +683,7 @@ describe("RuneWaker combat runtime", () => {
usePartyStore.getState().selectMember(member.id);
const curingShot = abilityById("rom-priest-rom-scout-elite-35")!;
expect(curingShot.target).toBe("friendly");
expect(curingShot.targeting).toMatchObject({ shape: "circle", radius: 20, maximumTargets: 5 });
expect(curingShot.targeting).toEqual({ shape: "circle", radius: 10 });
expect(useCombatStore.getState().castAbility(curingShot.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
+16 -15
View File
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityById } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { romCreatureFamilyForServerEntry } from "./romCreatureFamilies";
@@ -41,23 +43,22 @@ describe("RuneWaker creature-family combat rules", () => {
.toMatchObject({ ok: true });
});
it("accepts the Poisonous state without a bag item and rejects Numbing Dagger on Undead", () => {
it("maps Rogue 490347 to the current self-targeted Energized Hands buff", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 50,
classId: "rom-rogue", raceId: "rom-human", level: 50,
});
useCombatStore.setState({ playerPosition: [0, 0, 0] });
for (const [id, entry] of [["undead", 106432], ["living", 103946]] as const) {
useCombatStore.getState().registerMob(id, { name: id, serverEntry: entry, maxHealth: 1_000_000 });
setMobPosition(id, [3, 0, 0]);
}
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
useCombatStore.getState().selectMob("undead");
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().selectMob("living");
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
const energizedHands = abilityById("rom-rogue-primary-5");
expect(energizedHands).toMatchObject({
dbcSpellId: 490347,
name: "Energized Hands",
target: "self",
castTimeMs: 1_000,
});
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(1.1, 2_100);
expect(useCombatStore.getState().maxResourcePools.energy).toBe(150);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("rom-energized-hands"))).toBe(true);
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
ROM_COA_DRUID_ADVANCED_REBIRTH_ID,
ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID,
ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID,
ROM_COA_DRUID_REBIRTH_ID,
ROM_COA_DRUID_SPIRIT_GUIDANCE_ID,
ROM_COA_DRUID_SUPREME_REBIRTH_ID,
romCoaDruidRebirthDebtRule,
romCoaSpiritGuidanceFactionOutcome,
} from "./romDruidCurrentCoaMechanics";
describe("current CoA Druid faction-conversion and Rebirth rules", () => {
it("keeps Spirit Guidance's player-target restriction explicit", () => {
expect(ROM_COA_DRUID_SPIRIT_GUIDANCE_ID).toBe(493536);
expect(romCoaSpiritGuidanceFactionOutcome({
targetKind: "player",
casterReputation: 10,
})).toBe("player-target-prohibited");
});
it("converts an NPC only when caster reputation is nonnegative", () => {
expect(romCoaSpiritGuidanceFactionOutcome({
targetKind: "npc",
casterReputation: 0,
})).toBe("becomes-friendly");
expect(romCoaSpiritGuidanceFactionOutcome({
targetKind: "npc",
casterReputation: -1,
})).toBe("remains-hostile");
});
it("does not guess the conversion result when reputation is unavailable", () => {
expect(romCoaSpiritGuidanceFactionOutcome({
targetKind: "npc",
casterReputation: null,
})).toBe("unknown-reputation");
expect(romCoaSpiritGuidanceFactionOutcome({
targetKind: "npc",
casterReputation: Number.NaN,
})).toBe("unknown-reputation");
});
it("reports basic Rebirth's 30% baseline and leaves its extra amulet bonus unquantified", () => {
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_REBIRTH_ID)).toEqual({
debtReductionPercent: 30,
itemRule: {
kind: "optional-unquantified-bonus",
itemIds: [ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID, ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID],
},
extraDebtReductionPercent: null,
});
});
it("maps Advanced Rebirth to 45% with its required Amulet of Light", () => {
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_ADVANCED_REBIRTH_ID)).toEqual({
debtReductionPercent: 45,
itemRule: { kind: "required", itemId: ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID },
extraDebtReductionPercent: 0,
});
});
it("maps Supreme Rebirth to 75% with its required Golden Amulet", () => {
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_SUPREME_REBIRTH_ID)).toEqual({
debtReductionPercent: 75,
itemRule: { kind: "required", itemId: ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID },
extraDebtReductionPercent: 0,
});
});
it("does not map unrelated or unknown spell IDs", () => {
expect(romCoaDruidRebirthDebtRule(493538)).toBeNull();
expect(romCoaDruidRebirthDebtRule(0)).toBeNull();
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Small source-backed rules for Druid mechanics that differ from the local
* RuneWaker snapshot. These helpers describe outcomes only; the combat model
* does not currently track NPC faction, caster reputation, player death debt,
* or the required amulets at the moment of resurrection.
*
* Current CoA sources:
* - https://chroniclesofarcadia.com/en/wiki/skills/list/8?locale=en
* - https://chroniclesofarcadia.com/en/wiki/skills/list/8-9
*
* Local RuneWaker IDs/pairs:
* - Druid primary Spirit Guidance 493536 (local component 503805)
* - Druid primary Rebirth 493537 (component 503806)
* - Druid primary Advanced Rebirth 493559 (component 504134; item 203606,
* Amulet of Light)
* - Druid primary Supreme Rebirth 493560 (component 504135; item 203605,
* Golden Amulet)
*/
export const ROM_COA_DRUID_SPIRIT_GUIDANCE_ID = 493536;
export const ROM_COA_DRUID_REBIRTH_ID = 493537;
export const ROM_COA_DRUID_ADVANCED_REBIRTH_ID = 493559;
export const ROM_COA_DRUID_SUPREME_REBIRTH_ID = 493560;
export const ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID = 203606;
export const ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID = 203605;
export type RomCoaSpiritGuidanceFactionOutcome =
| "player-target-prohibited"
| "becomes-friendly"
| "remains-hostile"
| "unknown-reputation";
/**
* CoA says Spirit Guidance cannot be used on players. For NPCs, nonnegative
* caster reputation makes the target friendly; negative reputation leaves it
* hostile. Unknown reputation stays unknown instead of assuming conversion.
*
* This intentionally does not supply a duration: the current CoA page lists
* 16.2 seconds for the +55 skill value, but gives no rank-to-duration formula.
* The local RuneWaker component's 3-second value therefore cannot be carried
* over as the current CoA duration for every cast.
*/
export function romCoaSpiritGuidanceFactionOutcome(args: {
readonly targetKind: "npc" | "player";
readonly casterReputation: number | null;
}): RomCoaSpiritGuidanceFactionOutcome {
if (args.targetKind === "player") return "player-target-prohibited";
if (args.casterReputation === null || !Number.isFinite(args.casterReputation)) {
return "unknown-reputation";
}
return args.casterReputation < 0 ? "remains-hostile" : "becomes-friendly";
}
export interface RomCoaDruidRebirthDebtRule {
/** The exact percentage stated by the current CoA skill tooltip. */
readonly debtReductionPercent: 30 | 45 | 75;
/** Amulet conditions for the quantified skill variant. */
readonly itemRule:
| { readonly kind: "optional-unquantified-bonus"; readonly itemIds: readonly [number, number] }
| { readonly kind: "required"; readonly itemId: number };
/**
* Basic Rebirth's optional amulet bonus is stated but not quantified by CoA.
* The advanced and supreme tooltips already include their required amulet
* in the stated 45% and 75% effects.
*/
readonly extraDebtReductionPercent: number | null;
}
const DRUID_REBIRTH_RULES: Readonly<Record<number, RomCoaDruidRebirthDebtRule>> = {
[ROM_COA_DRUID_REBIRTH_ID]: {
debtReductionPercent: 30,
itemRule: {
kind: "optional-unquantified-bonus",
itemIds: [ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID, ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID],
},
extraDebtReductionPercent: null,
},
[ROM_COA_DRUID_ADVANCED_REBIRTH_ID]: {
debtReductionPercent: 45,
itemRule: { kind: "required", itemId: ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID },
extraDebtReductionPercent: 0,
},
[ROM_COA_DRUID_SUPREME_REBIRTH_ID]: {
debtReductionPercent: 75,
itemRule: { kind: "required", itemId: ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID },
extraDebtReductionPercent: 0,
},
};
/**
* Look up the quantified CoA debt-reduction rule by exact local primary-skill
* ID. This reports mechanics metadata without inventing a debt unit, rounding
* rule, inventory state, or the unspecified optional bonus on basic Rebirth.
*/
export function romCoaDruidRebirthDebtRule(
sourceSkillId: number,
): RomCoaDruidRebirthDebtRule | null {
return DRUID_REBIRTH_RULES[sourceSkillId] ?? null;
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
} from "./romDruidMageInspirationRuntime";
import {
ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
} from "./romMageDruidGreenGuardianRuntime";
describe("current CoA Druid/Mage Inspiration catalog entry", () => {
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-mage");
it("updates the existing generated exact-pair passive without adding a skill row", () => {
expect(passive).toBeDefined();
expect(passive).toMatchObject({
classId: "rom-druid",
secondaryClassId: "rom-mage",
dbcSpellId: ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
name: "Inspiration",
unlockLevel: 30,
sourceUnlockLevel: 30,
passive: true,
romSkillGroup: "passive",
target: "self",
castTimeMs: 0,
cooldownMs: 0,
costs: [],
effects: [],
});
expect(passive?.id).toBe("rom-druid-rom-mage-elite-30");
expect(ROM_ALL_ABILITIES.filter((ability) => ability.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID))
.toHaveLength(1);
});
it("uses current CoA spend-trigger values in both the passive and its rank tooltip", () => {
expect(passive?.description).toBe(ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION);
expect(passive?.description).toContain("whenever you spend Nature's Power");
expect(passive?.description).toContain("Physical Attack Speed and Casting Speed by 2% per stack");
expect(passive?.description).toContain("up to 10 stacks for 10 seconds");
expect(passive?.description).toContain("cannot be proven identical to local Fireball 490204");
expect(passive?.description).not.toMatch(/chance.*Fireball|next spell.*1 second/i);
expect(passive?.ranks).toHaveLength(1);
expect(passive?.ranks?.[0]).toMatchObject({
spellId: ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
costs: [],
effects: [],
description: ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
});
});
});
describe("current CoA Mage/Druid Green Guardian catalog entry", () => {
const skill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID
&& ability.classId === "rom-mage" && ability.secondaryClassId === "rom-druid");
it("turns only the existing exact-pair row into the current level-25 instant self skill", () => {
expect(skill).toMatchObject({
id: "rom-mage-rom-druid-elite-30",
classId: "rom-mage",
secondaryClassId: "rom-druid",
dbcSpellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
unlockLevel: 25,
passive: false,
romSkillGroup: "elite",
target: "self",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
costs: [],
effects: [{ kind: "apply-aura" }],
});
expect(skill?.description).toBe(ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION);
expect(skill?.ranks?.[0]).toMatchObject({
level: 25,
spellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
castTimeMs: 0,
cooldownMs: 0,
costs: [],
description: ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
effects: [{ kind: "apply-aura" }],
});
expect(ROM_ALL_ABILITIES.filter((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID))
.toHaveLength(1);
});
});
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it } from "vitest";
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 { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_DRUID_MAGE_INSPIRATION_AURA_TAG,
romDruidMageInspirationApplySpendStack,
romDruidMageInspirationHastePercent,
} from "./romDruidMageInspirationRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const EARTH_CHAIN_ID = "rom-druid-rom-mage-elite-15";
const earthChain = ROM_ALL_ABILITIES.find((ability) => ability.id === EARTH_CHAIN_ID);
function inspirationAura(targetId: string) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.definition.utilityTags?.includes(ROM_DRUID_MAGE_INSPIRATION_AURA_TAG));
}
function priest(name: string, seed: string, index: number) {
return createConfiguredPartyMember({
name,
classId: "rom-priest",
specialization: "Primary",
role: "damage",
}, 45, index, seed);
}
describe("CoA Druid/Mage Inspiration player and companion integration", () => {
beforeEach(() => {
resetPartyRuntimeClock();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 45,
role: "healer",
});
usePartyStore.getState().initializeParty(
"inspiration-party-test", 45, useCombatStore.getState().health, "healer", 4, "rom",
);
});
it("awards one nearby-party stack for a player's completed Nature Power spend and applies cast haste", () => {
if (!earthChain) throw new Error("Missing generated Druid/Mage Earth Chain row");
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
secondaryClassId: "rom-mage",
raceId: "rom-elf",
level: 45,
role: "damage",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const seed = "inspiration-player-spend";
const edge = priest("Range Edge", seed, 0);
const outside = priest("Outside Range", seed, 1);
const fallen = { ...priest("Fallen Ally", seed, 2), health: 0 };
usePartyStore.setState({ members: [edge, outside, fallen] });
registerPartyRuntimePosition(edge.id, [3, 4, 0]);
registerPartyRuntimePosition(outside.id, [5.01, 0, 0]);
registerPartyRuntimePosition(fallen.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, "nature-power": 1 },
maxResourcePools: { ...state.maxResourcePools, "nature-power": 10 },
}));
const targetId = "inspiration-player-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", level: 1, maxHealth: 100_000, xpReward: 0 });
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().selectMob(targetId);
expect(useCombatStore.getState().castAbility(EARTH_CHAIN_ID, {
origin: [0, 0, 0], targetActorId: targetId, now: 1_000,
}).ok).toBe(true);
const unbuffedCastDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
expect(unbuffedCastDuration).toBeGreaterThan(0);
useCombatStore.getState().tick(2, 3_000);
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
expect(inspirationAura(PLAYER_AGGRO_ID)).toMatchObject({ stacks: 1, expiresAt: 13_000 });
expect(inspirationAura(edge.id)?.stacks).toBe(1);
expect(inspirationAura(outside.id)).toBeUndefined();
expect(inspirationAura(fallen.id)).toBeUndefined();
expect(useCombatStore.getState().castAbility(EARTH_CHAIN_ID, {
origin: [0, 0, 0], targetActorId: targetId, now: 7_000,
}).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeLessThan(unbuffedCastDuration);
});
it("awards one caster-centered stack when a generated Druid/Mage companion spends Nature Power", () => {
if (!earthChain) throw new Error("Missing generated Druid/Mage Earth Chain row");
const seed = "inspiration-companion-spend";
const caster = createConfiguredPartyMember({
name: "Druid/Mage",
classId: "rom-druid",
secondaryClassId: "rom-mage",
specialization: "Primary",
role: "damage",
}, 45, 0, seed);
const outside = priest("Outside Range", seed, 1);
usePartyStore.setState({
members: [{
...caster,
resourcePools: { ...caster.resourcePools, "nature-power": 1 },
activeCast: {
abilityId: EARTH_CHAIN_ID,
school: "nature",
mode: "cast",
startedAt: 0,
completesAt: 1_000,
originalDurationMs: 1_000,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: "inspiration-companion-target",
},
activeCastTargetId: "inspiration-companion-target",
status: "attacking",
}, outside],
command: "attack",
activeMobId: "inspiration-companion-target",
});
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(outside.id, [5.01, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("inspiration-companion-target", {
name: "Target", level: 1, maxHealth: 100_000, xpReward: 0,
});
setMobPosition("inspiration-companion-target", [2, 0, 0]);
useCombatStore.getState().damageMob("inspiration-companion-target", 1, 500);
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members[0]?.resourcePools["nature-power"]).toBe(0);
expect(inspirationAura(caster.id)?.stacks).toBe(1);
expect(inspirationAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
expect(inspirationAura(outside.id)).toBeUndefined();
});
it("applies Inspiration spell haste only once to a Mage receiving the party buff", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-mage",
secondaryClassId: "rom-druid",
raceId: "rom-elf",
level: 45,
role: "damage",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "inspiration-mage-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", level: 1, maxHealth: 100_000, xpReward: 0 });
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().selectMob(targetId);
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === "rom-mage-primary-1");
expect(ability?.castTimeMs).toBe(3_000);
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", {
origin: [0, 0, 0], targetActorId: targetId, now: 1_000,
}).ok).toBe(true);
const unbuffedDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
expect(unbuffedDuration).toBeGreaterThan(0);
useCombatStore.getState().tick(3, 4_000);
useCombatStore.setState((state) => ({
auras: [...romDruidMageInspirationApplySpendStack(
state.auras, "druid-mage-source", [PLAYER_AGGRO_ID], 5_000,
)],
}));
expect(inspirationAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
expect(romDruidMageInspirationHastePercent(
useCombatStore.getState().auras, PLAYER_AGGRO_ID, 5_000,
)).toBe(2);
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", {
origin: [0, 0, 0], targetActorId: targetId, now: 5_000,
}).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.startedAt).toBe(5_000);
const buffedDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
const durationReduction = 1 - buffedDuration / unbuffedDuration;
expect(durationReduction).toBeGreaterThan(0.01);
expect(durationReduction).toBeLessThan(0.03);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
isRomDruidMagePair,
ROM_DRUID_MAGE_INSPIRATION_DURATION_MS,
ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS,
ROM_DRUID_MAGE_INSPIRATION_RADIUS,
romDruidMageInspirationApplySpendStack,
romDruidMageInspirationHastePercent,
romDruidMageInspirationRecipientIds,
romDruidMageInspirationBonusPercent,
romDruidMageInspirationStacksAfterSpend,
} from "./romDruidMageInspirationRuntime";
describe("current CoA Druid/Mage Inspiration", () => {
it("gates the current passive to the exact Druid/Mage pair", () => {
expect(isRomDruidMagePair({ classId: "rom-druid", secondaryClassId: "rom-mage" })).toBe(true);
expect(isRomDruidMagePair({ classId: "rom-mage", secondaryClassId: "rom-druid" })).toBe(false);
expect(isRomDruidMagePair({ classId: "rom-druid", secondaryClassId: "rom-rogue" })).toBe(false);
});
it("adds one stack for each positive Nature's Power spend event, regardless of amount", () => {
expect(romDruidMageInspirationStacksAfterSpend(0, 1, true)).toBe(1);
expect(romDruidMageInspirationStacksAfterSpend(3, 5, true)).toBe(4);
expect(romDruidMageInspirationStacksAfterSpend(3, 0, true)).toBe(3);
expect(romDruidMageInspirationStacksAfterSpend(3, -1, true)).toBe(3);
expect(romDruidMageInspirationStacksAfterSpend(3, 1, false)).toBe(3);
});
it("caps at ten stacks for a 20% total attack-speed and cast-speed bonus", () => {
expect(romDruidMageInspirationStacksAfterSpend(9, 1, true)).toBe(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS);
expect(romDruidMageInspirationStacksAfterSpend(10, 1, true)).toBe(10);
expect(romDruidMageInspirationBonusPercent(10)).toBe(20);
expect(romDruidMageInspirationBonusPercent(99)).toBe(20);
expect(ROM_DRUID_MAGE_INSPIRATION_DURATION_MS).toBe(10_000);
expect(ROM_DRUID_MAGE_INSPIRATION_RADIUS).toBe(5);
});
it("adds one party-recipient stack per spend and refreshes the shared 10-second aura", () => {
const first = romDruidMageInspirationApplySpendStack([], "druid", ["player", "ally"], 1_000);
const second = romDruidMageInspirationApplySpendStack(first, "druid", ["player", "player"], 6_000);
const playerAura = second.find((aura) => aura.targetId === "player");
const allyAura = second.find((aura) => aura.targetId === "ally");
expect(second).toHaveLength(2);
expect(playerAura?.stacks).toBe(2);
expect(playerAura?.appliedAt).toBe(6_000);
expect(playerAura?.expiresAt).toBe(16_000);
expect(playerAura?.definition.modifiers).toContainEqual({
kind: "stat", stat: "melee-haste", operation: "flat", value: 2, perStack: true,
});
expect(playerAura?.definition.modifiers).toContainEqual({
kind: "stat", stat: "ranged-haste", operation: "flat", value: 2, perStack: true,
});
expect(playerAura?.definition.modifiers).toContainEqual({
kind: "stat", stat: "spell-haste", operation: "flat", value: 2, perStack: true,
});
expect(romDruidMageInspirationHastePercent(second, "player", 6_000)).toBe(4);
expect(allyAura?.stacks).toBe(1);
expect(allyAura?.expiresAt).toBe(11_000);
});
it("selects only living modeled recipients at or inside the interpreted five-unit radius", () => {
expect(romDruidMageInspirationRecipientIds([0, 0, 0], [
{ id: "caster", position: [0, 0, 0], living: true },
{ id: "edge", position: [3, 4, 0], living: true },
{ id: "outside", position: [5.01, 0, 0], living: true },
{ id: "dead", position: [1, 0, 0], living: false },
{ id: "unplaced", position: null, living: true },
])).toEqual(["caster", "edge"]);
});
it("normalizes malformed stack counts at the documented zero-to-ten bounds", () => {
expect(romDruidMageInspirationStacksAfterSpend(-4, 0, true)).toBe(0);
expect(romDruidMageInspirationStacksAfterSpend(2.9, 0, true)).toBe(2);
expect(romDruidMageInspirationBonusPercent(-2)).toBe(0);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { applyAura, type ActiveAura, type AuraDefinition, type CombatEntityId } from "./combatAuras";
/** Current CoA Druid/Mage Inspiration stack behavior. */
export const ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID = 494322;
export const ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS = 10;
export const ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT = 2;
export const ROM_DRUID_MAGE_INSPIRATION_DURATION_MS = 10_000;
/** Current CoA skill page's `Range: 50`, converted with the project's 10:1 CoA range convention. */
export const ROM_DRUID_MAGE_INSPIRATION_RADIUS = 5;
export const ROM_DRUID_MAGE_INSPIRATION_AURA_TAG = "rom-druid-mage-inspiration";
export const ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION =
"Passive: whenever you spend Nature's Power, nearby party members gain one Inspiration stack, increasing Physical Attack Speed and Casting Speed by 2% per stack, up to 10 stacks for 10 seconds. CoA also describes a Fireball travel-speed and Nature's Power rider, but current CoA Fireball 499943 cannot be proven identical to local Fireball 490204, so those clauses are not implemented.";
export type RomDruidMageInspirationPosition = readonly [number, number, number];
export interface RomDruidMageInspirationRecipient {
readonly id: CombatEntityId;
readonly position: RomDruidMageInspirationPosition | null;
readonly living: boolean;
}
/** Select modeled living party actors inside the interpreted caster-centered CoA Range 50. */
export function romDruidMageInspirationRecipientIds(
sourcePosition: RomDruidMageInspirationPosition,
candidates: readonly RomDruidMageInspirationRecipient[],
): readonly CombatEntityId[] {
const radiusSquared = ROM_DRUID_MAGE_INSPIRATION_RADIUS ** 2;
return [...new Set(candidates.filter((candidate) => {
if (!candidate.living || !candidate.position) return false;
const dx = candidate.position[0] - sourcePosition[0];
const dy = candidate.position[1] - sourcePosition[1];
const dz = candidate.position[2] - sourcePosition[2];
return dx * dx + dy * dy + dz * dz <= radiusSquared;
}).map((candidate) => candidate.id))];
}
export function romDruidMageInspirationAuraDefinition(): AuraDefinition {
return {
id: "rom:druid-mage:inspiration",
name: "Inspiration",
description: "Increases physical attack speed and casting speed by 2% per stack.",
disposition: "buff",
durationMs: ROM_DRUID_MAGE_INSPIRATION_DURATION_MS,
maxStacks: ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS,
stackBehavior: "add",
uniqueBySource: false,
utilityTags: [ROM_DRUID_MAGE_INSPIRATION_AURA_TAG],
modifiers: [
{
kind: "stat",
stat: "melee-haste",
operation: "flat",
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
perStack: true,
},
{
kind: "stat",
stat: "ranged-haste",
operation: "flat",
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
perStack: true,
},
{
kind: "stat",
stat: "spell-haste",
operation: "flat",
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
perStack: true,
},
],
};
}
/** Apply one stack to each recipient selected by the caller; the shared aura refreshes for 10 seconds. */
export function romDruidMageInspirationApplySpendStack(
current: readonly ActiveAura[],
sourceId: CombatEntityId,
recipientIds: readonly CombatEntityId[],
now: number,
): readonly ActiveAura[] {
let auras = current;
const definition = romDruidMageInspirationAuraDefinition();
for (const targetId of new Set(recipientIds)) {
auras = applyAura(auras, { definition, sourceId, targetId, now, stacks: 1 }).auras;
}
return auras;
}
export function romDruidMageInspirationHastePercent(
auras: readonly ActiveAura[],
targetId: string,
now: number,
): number {
return auras
.filter((aura) => aura.targetId === targetId
&& aura.definition.utilityTags?.includes(ROM_DRUID_MAGE_INSPIRATION_AURA_TAG)
&& (aura.expiresAt === null || aura.expiresAt > now))
.reduce((total, aura) => total + romDruidMageInspirationBonusPercent(aura.stacks), 0);
}
export function isRomDruidMagePair(row: {
readonly classId?: string;
readonly secondaryClassId?: string | null;
}): boolean {
return row.classId === "rom-druid" && row.secondaryClassId === "rom-mage";
}
/**
* CoA gives one Inspiration stack when the caster spends Nature's Power.
* This resolves one positive resource-spend event, not each point spent.
*/
export function romDruidMageInspirationStacksAfterSpend(
currentStacks: number,
spentNaturePower: number,
passiveLearned: boolean,
): number {
const stacks = Math.max(0, Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, Math.floor(currentStacks)));
if (!passiveLearned || !(spentNaturePower > 0)) return stacks;
return Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, stacks + 1);
}
export function romDruidMageInspirationBonusPercent(stacks: number): number {
const boundedStacks = Math.max(0, Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, Math.floor(stacks)));
return boundedStacks * ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT;
}
+11 -8
View File
@@ -12,10 +12,15 @@ const empty: RomCastMechanics = {
targetHealthPercent: 1,
};
function cast(spellId: number, naturePower: number, passiveTags: readonly string[] = []): readonly AbilityEffect[] {
function cast(
spellId: number,
naturePower: number,
passiveTags: readonly string[] = [],
targetTags: readonly string[] = [],
): readonly AbilityEffect[] {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId);
expect(ability, `RoM Druid spell ${spellId}`).toBeDefined();
return romEffectsForCast(ability!, { ...empty, naturePower, passiveTags });
return romEffectsForCast(ability!, { ...empty, naturePower, passiveTags, targetTags });
}
function passiveTag(spellId: number): string {
@@ -188,17 +193,15 @@ describe("RoM Druid passive state triggers", () => {
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
});
it("extends Recover by six seconds with Extended Recovery and gates Knee Ulcer's five-second root", () => {
it("extends Recover by six seconds and does not retain the obsolete Druid/Rogue Knee Ulcer root", () => {
const recoverBase = cast(493528, 0);
const recoverExtended = cast(493528, 0, [passiveTag(494200)]);
expect(recoverBase.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 7, intervalMs: 2_000 });
expect(recoverExtended.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 10, intervalMs: 2_000 });
expect(cast(494321, 0).filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
expect(cast(494321, 1, [], ["rom-druid-corrosive-poison"])).toHaveLength(3);
expect(cast(494321, 1, [passiveTag(499944)], ["rom-druid-corrosive-poison"])
.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
.toHaveLength(0);
expect(cast(494321, 0, [passiveTag(499944)]).filter((effect) => effect.kind === "apply-aura"
&& effect.control?.kind === "root")).toEqual([
expect.objectContaining({ control: { kind: "root" }, aura: expect.objectContaining({ durationMs: 5_000 }) }),
]);
});
});
+50 -22
View File
@@ -1,5 +1,14 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
import {
isRomDruidRoguePair,
romDruidRogueCorrosivePoisonEffect,
romDruidRogueCooldownRider,
} from "./romDruidRogueCurrentCoa";
import {
isRomMageDruidPair,
romMageDruidGreenGuardianAuraEffect,
} from "./romMageDruidGreenGuardianRuntime";
export const ROM_DRUID_GREEN_GUARDIAN_TAG = "rom-druid-green-guardian";
export const ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG = "rom-druid-mother-earth-protection";
@@ -85,6 +94,22 @@ export function overrideRomDruidEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (isRomDruidRoguePair(row)) {
if (row.sourceSkillId === 494024) return [romDruidRogueCorrosivePoisonEffect()];
if (row.sourceSkillId === 494321) return [
{ kind: "damage", coefficient: 3.297, school: "shadow" },
{
kind: "utility",
action: "class-script",
effectType: -1,
auraType: -1,
miscValue: 0,
triggerSpellId: 494024,
durationMs: 12_000,
},
];
if (row.sourceSkillId === 494023) return [];
}
switch (row.sourceSkillId) {
case 493533:
case 493558: { // Both Mother Earth's Protection ranks can trigger Green Guardian on expiry.
@@ -101,7 +126,9 @@ export function overrideRomDruidEffects(
};
});
}
case 494331: // Keep source HoT payloads on the learned passive for its expiry trigger.
case 494331:
if (isRomMageDruidPair(row)) return [romMageDruidGreenGuardianAuraEffect()];
// Keep RuneWaker's legacy HoTs only for rows outside the current CoA pair.
return [
passiveState(494331, row.name, ROM_DRUID_GREEN_GUARDIAN_TAG),
...translated.filter((effect) => effect.kind === "hot"),
@@ -143,8 +170,8 @@ export function overrideRomDruidEffects(
...effect,
falloffPerJump: 0.7,
}));
case 494321: // Its root is supplied by the optional Knee Ulcer passive.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.control?.kind !== "root");
case 494321: // The CoA rework replaces RuneWaker's healing reduction and Knee Ulcer root.
return translated.filter((effect) => effect.kind === "damage");
case 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses.
return [spiritOfLifeHot(translated)];
default:
@@ -293,11 +320,23 @@ export function romDruidCastEffects(
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
}
case 499956: { // Briar Technique unlocks this source row, with both DMG branches on distinct components.
const selectedHits = empowered
? [damage[1], damage[3]].filter((effect) => effect !== undefined)
: [damage[0], damage[2]].filter((effect) => effect !== undefined);
if (selectedHits.length !== 2) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage"
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494022: { // Poisonous Widow Embrace selects one damage component and refunds a spent point.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage);
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
const branch = empowered ? spendBeforeNaturePowerRefund(selected) : selected;
return isRomDruidRoguePair(ability)
? [...branch, romDruidRogueCooldownRider()]
: branch;
}
case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent.
if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects;
@@ -305,24 +344,13 @@ export function romDruidCastEffects(
? { ...effect, ticks: effect.ticks + 3 }
: effect);
}
case 494321: { // The passive's root is only emitted when Knee Ulcer is learned.
if (!hasTag(passiveTags, "rom-druid-knee-ulcer")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-druid-knee-ulcer-root",
name: "Knee Ulcer",
disposition: "debuff",
durationMs: 5_000,
dispelCategory: "magic",
},
control: { kind: "root" },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 499944,
}];
case 494321: { // CoA's current poison-gated channel replaces the old healing-reduction/root row.
if (!isRomDruidRoguePair(ability)) return effects;
return [
...effects.filter((effect) => effect.kind === "damage" || (effect.kind === "utility"
&& effect.action === "class-script" && effect.triggerSpellId === 494024)),
romDruidRogueCooldownRider(),
];
}
default:
return effects;
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { romAbilityRequiredTargetStateTag, romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
import { ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG } from "./romDruidRogueCurrentCoa";
const empty: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 10_000,
targetHealthPercent: 1,
};
function ability(spellId: number) {
const found = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId
&& entry.classId === "rom-druid" && entry.secondaryClassId === "rom-rogue");
expect(found, `Druid/Rogue ability ${spellId}`).toBeDefined();
return found!;
}
describe("current CoA Druid/Rogue Necrotic Wound profile", () => {
it("uses the sourced gate, costs, channel, range, radius, and three full per-hit values", () => {
const wound = ability(494321);
expect(wound).toMatchObject({
name: "Necrotic Wound",
target: "hostile",
range: { min: 0, max: 15 },
radius: 8,
targeting: { shape: "circle", radius: 8 },
castMode: "channel",
castTimeMs: 1_500,
channelPulses: 3,
channelTickMs: 500,
cooldownMs: 0,
costs: [
{ resource: "mana", amount: 4, percentage: true },
{ resource: "nature-power", amount: 1 },
],
});
expect(romAbilityRequiredTargetStateTag(wound)).toBe(ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG);
expect(romEffectsForCast(wound, { ...empty, naturePower: 1 })).toEqual([]);
const currentEffects = romEffectsForCast(wound, {
...empty,
naturePower: 1,
targetTags: [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG],
});
expect(currentEffects.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing" && modifier.direction === "taken")))
.toHaveLength(0);
expect(currentEffects.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
.toHaveLength(0);
expect(currentEffects.filter((effect) => effect.kind === "damage"))
.toEqual([{ kind: "damage", coefficient: 3.297, school: "shadow" }]);
expect(currentEffects.filter((effect) => effect.kind === "utility" && effect.action === "class-script"))
.toEqual([expect.objectContaining({ triggerSpellId: 494024, durationMs: 12_000 })]);
expect(currentEffects.filter((effect) => effect.kind === "cooldown-reset"))
.toEqual([{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }]);
const channelAmounts = channelAmountEffects(wound, 3);
expect(channelAmounts).toEqual([{ effect: currentEffects.find((effect) => effect.kind === "damage"), multiplier: 1 }]);
expect(channelPulseCount(wound)).toBe(3);
expect([0, 1, 2].map((pulse) => channelPulseAt(wound, 1_000, 1_500, pulse)))
.toEqual([1_500, 2_000, 2_500]);
});
it("replaces Corrosive Poison with the current +105, one-second Dark DoT and adds the spread prerequisite", () => {
const poison = ability(494024);
const dot = poison.effects.find((effect) => effect.kind === "dot");
expect(poison).toMatchObject({
range: { max: 15 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 1_000,
costs: [{ resource: "energy", amount: 25 }],
});
expect(dot).toMatchObject({
kind: "dot",
school: "shadow",
basePoints: 11_342,
sourceLevel: 105,
ticks: 12,
intervalMs: 1_000,
tag: ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG,
});
});
it("adds the exact one-second cooldown rider to both successful casts", () => {
const cooldownRiders = [494022, 494321].map((spellId) => romEffectsForCast(ability(spellId), {
...empty,
naturePower: 1,
targetTags: spellId === 494321 ? [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG] : [],
}).filter((effect) => effect.kind === "cooldown-reset"));
expect(cooldownRiders).toEqual([
[{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }],
[{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }],
]);
const kneeUlcer = ROM_ELITE_ABILITIES.find((entry) => entry.dbcSpellId === 499944);
expect(kneeUlcer).toBeDefined();
const passiveTag = kneeUlcer!.effects.find((effect) => effect.kind === "apply-aura");
expect(passiveTag?.kind).toBe("apply-aura");
const withObsoletePassive = romEffectsForCast(ability(494321), {
...empty,
naturePower: 1,
targetTags: [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG],
passiveTags: passiveTag?.kind === "apply-aura" ? passiveTag.aura.utilityTags : [],
});
expect(withObsoletePassive.some((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
.toBe(false);
});
it("uses the current Sinister Request identity and does not leak the old Speed Catalysis aura", () => {
const request = ability(494023);
expect(request).toMatchObject({
name: "Sinister Request",
target: "self",
range: { max: 5 },
radius: 15,
cooldownMs: 15_000,
costs: [],
effects: expect.arrayContaining([
{ kind: "resource", amount: 100, resource: "mana", percentage: true, scope: "party" },
{ kind: "resource", amount: 100, resource: "rage", percentage: true, scope: "party" },
{ kind: "resource", amount: 100, resource: "focus", percentage: true, scope: "party" },
{ kind: "resource", amount: 100, resource: "energy", percentage: true, scope: "party" },
{ kind: "resource", amount: 100, resource: "psi", percentage: true, scope: "party" },
{ kind: "resource", amount: 100, resource: "nature-power", percentage: true, scope: "party" },
]),
});
expect(request.description).toContain("restores the MP, Rage, Focus, Energy, Psi, and Nature's Power");
expect(request.description).toContain("within radius 150");
expect(request.description).toContain("Cooldown: 15 seconds");
expect(request.description).not.toContain("attack and casting speed");
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { AbilityEffect } from "./abilityCatalog";
export const ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG = "rom-druid-corrosive-poison";
export function isRomDruidRoguePair(row: {
readonly classId?: string;
readonly secondaryClassId?: string;
}): boolean {
return row.classId === "rom-druid" && row.secondaryClassId === "rom-rogue";
}
/** Current CoA's +105 Corrosive Poison value; lower levels use the simulator's source-level scaling. */
export function romDruidRogueCorrosivePoisonEffect(): Extract<AbilityEffect, { kind: "dot" }> {
return {
kind: "dot",
coefficient: 0,
school: "shadow",
basePoints: 11_342,
sourceLevel: 105,
ticks: 12,
intervalMs: 1_000,
tag: ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG,
};
}
export function romDruidRogueCooldownRider(): AbilityEffect {
return {
kind: "cooldown-reset",
mode: "reduce",
abilityName: "Sinister Request",
amountMs: 1_000,
};
}
export const ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES = Object.freeze([
"mana",
"rage",
"focus",
"energy",
"psi",
"nature-power",
] as const);
export function romDruidRogueSinisterRequestEffects(): readonly AbilityEffect[] {
return ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES.map((resource) => ({
kind: "resource" as const,
amount: 100,
resource,
percentage: true,
scope: "party" as const,
}));
}
export const ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION =
"Costs 4% MP and 1 Nature's Power. Requires the target to have Corrosive Poison. Channels for 1.5 seconds and deals 329.7% Dark damage per hit over 3 hits to enemies within radius 80 around the target, then spreads Corrosive Poison to them. No cooldown. Each successful cast reduces Sinister Request's cooldown by 1 second.";
export const ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION =
"Costs 25 Energy. Instantly applies Corrosive Poison to one target, dealing 11,342 Dark damage every second for 12 seconds. Range 150; 1-second cooldown.";
export const ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION =
"Instantly restores the MP, Rage, Focus, Energy, Psi, and Nature's Power of party members within radius 150 to their maximum values. Cooldown: 15 seconds. Each successful Poisonous Widow Embrace or Necrotic Wound cast reduces this cooldown by 1 second.";
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES } from "./romDruidRogueCurrentCoa";
import { abilityAtLevel, type ResourceType } from "./abilityCatalog";
const RESOURCE_TYPES = ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES as readonly ResourceType[];
const STARTING_VALUES: Readonly<Record<ResourceType, number>> = {
mana: 20,
rage: 10,
focus: 10,
energy: 10,
psi: 1,
"nature-power": 1,
health: 0,
"runic-power": 0,
"holy-power": 0,
chi: 0,
"soul-power": 0,
};
function memberWithPools(seed: string, resources = RESOURCE_TYPES, index = 0): PartyMember {
const member = createConfiguredPartyMember({
name: "Druid/Rogue",
classId: "rom-druid",
secondaryClassId: "rom-rogue",
raceId: "rom-elf",
specialization: "Druid/Rogue",
specializationId: "rom-druid:rom-rogue",
talentTreeId: "rom-druid-rom-rogue",
talentRanks: {},
role: "damage",
}, 80, index, seed);
const resourcePools = Object.fromEntries(
resources.map((resource) => [resource, STARTING_VALUES[resource]]),
) as Partial<Record<ResourceType, number>>;
return {
...member,
resource: resourcePools[member.resourceType] ?? member.resource,
resourcePools,
};
}
function sinisterRequestId(): string {
const request = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494023
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-rogue");
if (!request) throw new Error("Druid/Rogue Sinister Request catalog row is missing.");
return request.id;
}
function assertPoolsAreFull(actorId: string, expectedResources: readonly ResourceType[]): void {
const resources = useCombatStore.getState().actors[actorId]?.resources;
expect(resources).toBeDefined();
for (const resource of expectedResources) {
expect(resources![resource]?.current).toBe(resources![resource]?.maximum);
}
}
describe("current CoA Druid/Rogue Sinister Request runtime", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useDungeonSimulationStore.setState({ sessionMode: "play" });
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
secondaryClassId: "rom-rogue",
level: 80,
xp: 0,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("sinister-request-test", 80,
useCombatStore.getState().health, "damage", 3, "rom");
});
it("fills the player's and nearby companions' existing pools, includes radius edge, and leaves outside/absent pools alone", () => {
const nearby = memberWithPools("sinister-player-nearby", RESOURCE_TYPES, 0);
const edge = memberWithPools("sinister-player-edge", ["mana", "energy"], 1);
const outside = memberWithPools("sinister-player-outside", RESOURCE_TYPES, 2);
usePartyStore.setState({ members: [nearby, edge, outside] });
registerPartyRuntimePosition(nearby.id, [5, 0, 0]);
registerPartyRuntimePosition(edge.id, [15, 0, 0]);
registerPartyRuntimePosition(outside.id, [15.01, 0, 0]);
useCombatStore.setState((state) => {
const maxResourcePools = {
...state.maxResourcePools,
mana: 240,
rage: 100,
focus: 100,
energy: 100,
psi: 6,
"nature-power": 15,
};
const resourcePools = {
...state.resourcePools,
mana: 1,
rage: 1,
focus: 1,
energy: 1,
psi: 1,
"nature-power": 1,
};
return {
maxResourcePools,
resourcePools,
maxResource: 240,
resource: 1,
};
});
useCombatStore.getState().synchronizeActors();
const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources };
const abilityId = sinisterRequestId();
const catalogRow = ROM_ALL_ABILITIES.find((ability) => ability.id === abilityId)!;
expect(abilityAtLevel(catalogRow, 80).effects).toHaveLength(RESOURCE_TYPES.length);
expect(useCombatStore.getState()).toMatchObject({ classId: "rom-druid", secondaryClassId: "rom-rogue" });
const castResult = useCombatStore.getState().castAbility(abilityId, { targetActorId: null, now: 1_000 });
expect(castResult).toMatchObject({ ok: true });
assertPoolsAreFull(PLAYER_AGGRO_ID, RESOURCE_TYPES);
assertPoolsAreFull(nearby.id, RESOURCE_TYPES);
assertPoolsAreFull(edge.id, ["mana", "energy"]);
expect(useCombatStore.getState().actors[edge.id].resources.focus).toBeUndefined();
for (const resource of RESOURCE_TYPES) {
expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]);
}
const events = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === abilityId
));
expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(RESOURCE_TYPES.length);
expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length);
expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2);
expect(events.some((event) => event.targetActorId === outside.id)).toBe(false);
expect(events.every((event) => event.sourceActorId === PLAYER_AGGRO_ID)).toBe(true);
});
it("applies a companion cast to the in-range party, caps each existing pool, and attributes resource events to the caster", () => {
const abilityId = sinisterRequestId();
const casterBase = memberWithPools("sinister-companion-caster", RESOURCE_TYPES, 0);
const caster = {
...casterBase,
activeCast: {
abilityId,
school: "nature" as const,
mode: "cast" as const,
startedAt: 0,
completesAt: 0,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0] as const,
targetId: casterBase.id,
},
activeCastTargetId: casterBase.id,
status: "healing" as const,
};
const nearby = memberWithPools("sinister-companion-nearby", RESOURCE_TYPES.filter((resource) => resource !== "psi"), 1);
const edge = memberWithPools("sinister-companion-edge", ["mana", "rage"], 2);
const outside = memberWithPools("sinister-companion-outside", RESOURCE_TYPES, 3);
usePartyStore.setState({ members: [caster, nearby, edge, outside] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(nearby.id, [5, 0, 0]);
registerPartyRuntimePosition(edge.id, [15, 0, 0]);
registerPartyRuntimePosition(outside.id, [15.01, 0, 0]);
useCombatStore.getState().synchronizeActors();
const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources };
advanceDungeonPartyCombat(1_000);
assertPoolsAreFull(PLAYER_AGGRO_ID, ["mana"]);
assertPoolsAreFull(caster.id, RESOURCE_TYPES);
assertPoolsAreFull(nearby.id, RESOURCE_TYPES.filter((resource) => resource !== "psi"));
expect(useCombatStore.getState().actors[nearby.id].resources.psi).toBeUndefined();
assertPoolsAreFull(edge.id, ["mana", "rage"]);
for (const resource of RESOURCE_TYPES) {
expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]);
}
const events = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === abilityId
));
expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(1);
expect(events.filter((event) => event.targetActorId === caster.id)).toHaveLength(RESOURCE_TYPES.length);
expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length - 1);
expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2);
expect(events.some((event) => event.targetActorId === outside.id)).toBe(false);
expect(events.every((event) => event.sourceActorId === caster.id)).toBe(true);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES } from "./romDruidRogueCurrentCoa";
describe("current CoA Druid/Rogue Sinister Request catalog", () => {
it("restores each listed party resource to its maximum with the current 15-second cooldown", () => {
const request = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494023
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-rogue");
expect(request).toBeDefined();
expect(request).toMatchObject({
name: "Sinister Request",
target: "self",
range: { max: 5 },
radius: 15,
cooldownMs: 15_000,
costs: [],
effects: ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES.map((resource) => ({
kind: "resource",
amount: 100,
resource,
percentage: true,
scope: "party",
})),
});
expect(request!.description).toContain("restores the MP, Rage, Focus, Energy, Psi, and Nature's Power");
expect(request!.description).toContain("within radius 150");
expect(request!.description).toContain("Cooldown: 15 seconds");
expect(request!.description).not.toContain("does not yet apply");
expect(request!.description).not.toContain("attack and casting speed");
});
});
+79 -60
View File
@@ -1,8 +1,8 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { abilityById, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { equipmentAdjustedCombatAmount, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { installCombatClock } from "./combatClock";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { companionResourceMaximum } from "./combatActorAuthority";
@@ -163,6 +163,73 @@ describe("RuneWaker Druid runtime", () => {
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(4);
});
it("casts the current Druid/Rogue Necrotic Wound only with poison and Nature Power, then spreads three hits", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
secondaryClassId: "rom-rogue",
raceId: "rom-elf",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 1_000,
maxResource: 1_000,
resourcePools: { ...state.resourcePools, mana: 1_000, energy: 100, "nature-power": 1 },
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, energy: 100 },
cooldowns: { ...state.cooldowns, [ability(494023).id]: 11_000 },
}));
const primary = "rom-druid-wound-primary";
const nearby = "rom-druid-wound-nearby";
const outside = "rom-druid-wound-outside";
for (const [id, position] of [
[primary, [4, 0, 0]],
[nearby, [11, 0, 0]],
[outside, [13, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().damageMob(id, 1, 100);
}
const wound = ability(494321);
const poison = ability(494024);
const runtimePoison = abilityById(poison.id);
expect(runtimePoison).toMatchObject({
id: poison.id,
dbcSpellId: 494024,
effects: expect.arrayContaining([expect.objectContaining({
kind: "dot",
tag: "rom-druid-corrosive-poison",
})]),
});
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 1_000 }))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility(poison.id, { targetActorId: primary, now: 2_000 }).ok).toBe(true);
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, "nature-power": 0 },
}));
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 3_500 }))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, "nature-power": 1 },
}));
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 5_000 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools).toMatchObject({ mana: 960, "nature-power": 0, energy: 75 });
finishCast(5_000);
const woundHits = useCombatStore.getState().combatEvents.filter((event) => (
event.abilityId === wound.id && event.kind === "damage"
));
expect(woundHits).toHaveLength(6);
const activePoisonTargets = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12
)).map((effect) => effect.targetId).sort();
expect(activePoisonTargets).toEqual([nearby, primary].sort());
expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999);
expect(useCombatStore.getState().cooldowns[ability(494023).id]).toBe(10_000);
expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false);
});
it("applies Grace of the Forest's Nature Power bonus only when a point is held", () => {
prepareDruid("rom-warden");
const grace = ability(494937);
@@ -273,19 +340,13 @@ describe("RuneWaker Druid runtime", () => {
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", () => {
it("suppresses the stale Green Guardian HoTs when the exact-pair active skill'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;
expect(state.abilities.some((entry) => entry.id === passive.id && !entry.passive)).toBe(true);
expect(passive.effects).toEqual([expect.objectContaining({ kind: "apply-aura" })]);
const protectionCast = useCombatStore.getState().castAbility(protection.id, {
targetActorId: null,
now: 1_000,
@@ -301,30 +362,8 @@ describe("RuneWaker Druid runtime", () => {
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);
expect(useCombatStore.getState().health).toBe(startingHealth);
});
it("does not trigger Green Guardian for another pair or another aura caster", () => {
@@ -358,7 +397,7 @@ describe("RuneWaker Druid runtime", () => {
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", () => {
it("suppresses stale Green Guardian HoTs on a friendly protection recipient", () => {
prepareMageDruid();
usePartyStore.getState().initializeParty("green-guardian-recipient", 80, 1, "damage", 5, "rom");
const recipient = usePartyStore.getState().members[0]!;
@@ -370,10 +409,6 @@ describe("RuneWaker Druid runtime", () => {
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,
@@ -386,26 +421,11 @@ describe("RuneWaker Druid runtime", () => {
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);
}
&& effect.targetId === recipient.id)).toHaveLength(0);
expect(usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health).toBe(1);
});
it("uses a party Mage/Druid's Green Guardian when their protection expires", () => {
it("suppresses stale Green Guardian HoTs for a companion's protection expiry", () => {
prepareMageDruid();
const caster = createConfiguredPartyMember({
name: "Mage Druid",
@@ -442,8 +462,7 @@ describe("RuneWaker Druid runtime", () => {
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);
expect(passive.passive).toBe(false);
expect(triggered).toHaveLength(0);
});
});
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { abilitiesForCharacter } from "./abilityCatalog";
const FEARLESS_ABILITY_ID = "rom-knight-rom-warrior-elite-35";
describe("RoM Knight/Warrior Fearless", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 40 });
usePartyStore.getState().initializeParty("rom-knight-fearless", 40, useCombatStore.getState().health, "damage", 2, "rom");
});
it("gives the player source-defined stats and 20 seconds of Fear immunity", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 40, role: "tank",
});
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const cast = useCombatStore.getState().castAbility(FEARLESS_ABILITY_ID, undefined, 1_000);
expect(cast).toMatchObject({ ok: true });
const auras = useCombatStore.getState().auras.filter((active) => (
active.targetId === PLAYER_AURA_ENTITY_ID && active.definition.name.startsWith("Fearless:")
));
expect(auras).toHaveLength(2);
expect(auras.every((aura) => aura.definition.controlImmunities?.includes("fear"))).toBe(true);
expect(auras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
{ kind: "stat", stat: "strength", operation: "percent", value: 0.03 },
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.03 },
]));
expect(auras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
});
it("gives a generated Knight/Warrior companion the same Fear immunity", () => {
const seed = "rom-knight-fearless-companion";
const knight = createConfiguredPartyMember({
name: "Fearless Knight",
classId: "rom-knight",
secondaryClassId: "rom-warrior",
raceId: "rom-human",
specialization: "Knight/Warrior",
role: "tank",
}, 40, 0, seed);
const targetId = "rom-knight-fearless-target";
const activeCooldowns = Object.fromEntries(
abilitiesForCharacter("rom-knight", "rom-warrior")
.filter((ability) => !ability.passive && ability.id !== FEARLESS_ABILITY_ID)
.map((ability) => [ability.id, 60_000]),
);
usePartyStore.getState().initializeParty(
seed, 40, useCombatStore.getState().health, "tank", 2, "rom",
);
usePartyStore.setState({
members: [{
...knight,
gearStats: EMPTY_ITEM_STATS,
resourcePools: { ...knight.resourcePools, rage: 50 },
cooldowns: activeCooldowns,
globalCooldownEndsAt: 0,
nextActionAt: 0,
status: "ready",
}],
command: "attack",
activeMobId: targetId,
});
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob(targetId, { name: "Fearless target", maxHealth: 100_000, xpReward: 0 });
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().damageMob(targetId, 1, 100);
updatePartyRuntimeTargetVisibility(knight.id, targetId, true);
advanceDungeonPartyCombat(1_000);
const auras = useCombatStore.getState().auras.filter((active) => (
active.sourceId === knight.id && active.targetId === knight.id
&& active.definition.name.startsWith("Fearless:")
));
expect(auras).toHaveLength(2);
expect(auras.every((aura) => aura.definition.controlImmunities?.includes("fear"))).toBe(true);
expect(auras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID,
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT,
romCoaKnightRogueGodsSwordBonusLightDamage,
type RomCoaKnightRogueGodsSwordInput,
} from "./romKnightGodsSwordCoaMechanics";
const eligibleInput: RomCoaKnightRogueGodsSwordInput = {
classId: "rom-knight",
secondaryClassId: "rom-rogue",
learnedPassiveSpellIds: [ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID],
landedOffensivePhysicalAbilityDamageEvent: true,
offhandWeaponDps: 100,
};
describe("current CoA Knight/Rogue God's Sword passive", () => {
it("adds 350% of offhand weapon DPS as separate Light damage", () => {
expect(ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT).toBe(3.5);
expect(romCoaKnightRogueGodsSwordBonusLightDamage(eligibleInput)).toBe(350);
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
offhandWeaponDps: 12.5,
})).toBe(43.75);
});
it("requires the Knight/Rogue pairing and learned passive", () => {
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
classId: "rom-warrior",
})).toBe(0);
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
secondaryClassId: "rom-warrior",
})).toBe(0);
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
learnedPassiveSpellIds: [],
})).toBe(0);
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
learnedPassiveSpellIds: [491_620],
})).toBe(0);
});
it("requires a landed offensive physical-ability damage event", () => {
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
landedOffensivePhysicalAbilityDamageEvent: false,
})).toBe(0);
});
it("requires positive finite offhand DPS and returns only finite damage", () => {
for (const offhandWeaponDps of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
offhandWeaponDps,
})).toBe(0);
}
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
...eligibleInput,
offhandWeaponDps: Number.MAX_VALUE,
})).toBe(0);
});
});
@@ -0,0 +1,49 @@
/** Current CoA Knight/Rogue God's Sword passive (skill 491621). */
export const ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID = 491_621;
/** CoA's passive adds 350% of offhand weapon DPS as Light damage. */
export const ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT = 3.5;
export interface RomCoaKnightRogueGodsSwordInput {
readonly classId: string;
readonly secondaryClassId: string | null | undefined;
readonly learnedPassiveSpellIds: readonly number[];
/** True for a landed offensive physical-ability damage event. */
readonly landedOffensivePhysicalAbilityDamageEvent: boolean;
readonly offhandWeaponDps: number;
}
/**
* Return God's Sword's separate Light-damage rider for one qualifying event.
*
* Current CoA describes God's Sword (491621) as a Knight/Rogue passive whose
* offensive physical abilities inflict 350% offhand weapon DPS Light damage
* with high aggro generation. The helper models only that documented damage;
* it does not invent an aggro multiplier or decide how callers divide
* multi-hit, channel, or area actions into damage events.
*
* The explicit patch chain identifies this as the same reworked ability:
* 10.6.1 made it an active offhand-DPS physical rider, 11.1.5 changed its
* damage to 350% Light, and 12.1.0 made it passive.
*
* Sources:
* - Current CoA Knight/Rogue skill list: https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru
* - CoA patch 10.6.1: https://chroniclesofarcadia.com/en/patch/10-6-1-1000
* - CoA patch 11.1.5: https://chroniclesofarcadia.com/tr/patch/11-1-5-3000
* - CoA patch 12.1.0: https://chroniclesofarcadia.com/patch/12-1-0-5000
*/
export function romCoaKnightRogueGodsSwordBonusLightDamage(
input: RomCoaKnightRogueGodsSwordInput,
): number {
if (input.classId !== "rom-knight"
|| input.secondaryClassId !== "rom-rogue"
|| !input.learnedPassiveSpellIds.includes(ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID)
|| !input.landedOffensivePhysicalAbilityDamageEvent
|| !Number.isFinite(input.offhandWeaponDps)
|| input.offhandWeaponDps <= 0) {
return 0;
}
const damage = input.offhandWeaponDps * ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT;
return Number.isFinite(damage) ? damage : 0;
}
@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
import { resolveDamageValue as resolveAuraDamageValue } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS, type WeaponStats } from "./itemStats";
import type { InventoryItem } from "./lootTypes";
const OFFHAND_ID = "coa-gods-sword-test-offhand";
const OFFHAND: WeaponStats = {
damageMin: 100,
damageMax: 300,
damageSchool: "physical",
speedMs: 2_000,
};
function offhandItem(instanceId = OFFHAND_ID): InventoryItem {
return {
instanceId,
itemId: 1,
itemKey: instanceId,
name: "Test Off-hand Dagger",
quality: "uncommon",
inventorySlot: "off-hand",
armorType: null,
weaponType: "mace",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...EMPTY_ITEM_STATS, meleeWeapon: OFFHAND, hitRating: 1_000_000 },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 0,
};
}
function registerTarget(id: string): void {
useCombatStore.getState().registerMob(id, {
name: id,
level: 1,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function expectedLightRiderAmount(actorId: string): number {
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === actorId);
return Math.round(resolveAuraDamageValue(350, auras, {
direction: "dealt",
school: "holy",
attackKind: "spell",
}));
}
describe("current CoA Knight/Rogue God's Sword runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("coa-gods-sword-runtime", 80, useCombatStore.getState().health);
});
it("adds one separate 350% off-hand-DPS Light hit to a landed player physical ability", () => {
const item = offhandItem();
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level: 80,
xp: 0,
inventory: [item],
equipment: { player: { "off-hand": item.instanceId } },
});
registerTarget("coa-gods-sword-player-target");
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 490148)!;
expect(useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 491621))
.toMatchObject({ passive: true, unlockLevel: 15 });
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
const events = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === "coa-gods-sword-player-target"
&& event.abilityId === ability.id
));
expect(events.some((event) => event.school === "holy" && event.rawAmount !== 350
&& event.effectiveAmount > 0)).toBe(true);
expect(events.filter((event) => event.school === "holy"
&& event.rawAmount === expectedLightRiderAmount(PLAYER_AGGRO_ID)))
.toHaveLength(1);
});
it("does not add the CoA rider before the passive is learned or without an off-hand weapon", () => {
const simulate = (level: number, equipOffhand: boolean) => {
const item = offhandItem(`coa-gods-sword-gate-${level}-${equipOffhand}`);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level,
xp: 0,
...(equipOffhand ? {
inventory: [item],
equipment: { player: { "off-hand": item.instanceId } },
} : {}),
});
const targetId = `coa-gods-sword-gate-target-${level}-${equipOffhand}`;
registerTarget(targetId);
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 490148)!;
useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.targetActorId === targetId
&& event.abilityId === ability.id
&& event.school === "holy"
&& event.rawAmount === 350
));
};
expect(simulate(14, true)).toHaveLength(0);
expect(simulate(80, false)).toHaveLength(0);
});
it("adds the same landed-hit rider for a generated Knight/Rogue companion", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80, xp: 0 });
const seed = "coa-gods-sword-party";
const companion = createConfiguredPartyMember({
name: "Knight/Rogue",
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
specialization: "Knight/Rogue",
role: "damage",
}, 80, 0, seed);
const sourceAbility = abilitiesForCharacter("rom-knight", "rom-rogue")
.find((candidate) => candidate.dbcSpellId === 490148)!;
const ability = learnedAbilityForBuild(sourceAbility, 80, companion.talentRanks)!;
const targetId = "coa-gods-sword-companion-target";
const item = {
...offhandItem(`${companion.id}:${OFFHAND_ID}`),
};
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({
members: [{
...companion,
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000 },
globalCooldownEndsAt: 0,
nextActionAt: 0,
activeCast: {
abilityId: ability.id,
school: "physical",
mode: "cast",
startedAt: 1_000,
completesAt: 1_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
},
activeCastTargetId: targetId,
status: "attacking",
}],
command: "attack",
activeMobId: targetId,
});
useCombatStore.setState((state) => ({
inventory: [...state.inventory, item],
equipment: { ...state.equipment, [companion.id]: { "off-hand": item.instanceId } },
}));
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerTarget(targetId);
advanceDungeonPartyCombat(1_000);
const events = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === companion.id
&& event.targetActorId === targetId
&& event.abilityId === ability.id
));
expect(events.some((event) => event.school === "holy" && event.rawAmount !== 350
&& event.effectiveAmount > 0)).toBe(true);
expect(events.filter((event) => event.school === "holy"
&& event.rawAmount === expectedLightRiderAmount(companion.id)))
.toHaveLength(1);
});
});
+51 -2
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID,
ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID,
romKnightCompassionWhirlwindShieldCooldownReductionMs,
romKnightCompassionWhirlwindShieldHealAmount,
romKnightDeadlyWhirlwindAura,
romKnightDivinePunishmentAuraForSealStacks,
romKnightRogueHolyStrikeCriticalRateIncreasePercent,
romKnightWarriorWhirlwindAdjustment,
} from "./romKnightOverrides";
@@ -50,7 +56,7 @@ 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", () => {
it("does not apply the superseded local Holy Strike crit tooltip without a current CoA payload", () => {
expect(passive).toMatchObject({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
@@ -63,7 +69,7 @@ describe("RoM Knight/Rogue God's Sword", () => {
"rom-rogue",
holyStrike,
[491621],
)).toBe(40);
)).toBe(0);
});
it("does not apply God's Sword to other pairings, abilities, unlearned passives, or non-RuneWaker spells", () => {
@@ -83,3 +89,46 @@ describe("RoM Knight/Rogue God's Sword", () => {
expect(bonuses).toEqual([0, 0, 0, 0]);
});
});
describe("current CoA Knight pair effects", () => {
it("applies current Deadly Wound healing reduction and PvP duration", () => {
expect(romKnightDeadlyWhirlwindAura(false).aura).toMatchObject({
durationMs: 10_000,
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: -0.3 }],
});
expect(romKnightDeadlyWhirlwindAura(true).aura.durationMs).toBe(5_000);
});
it("scales Divine Punishment vulnerability by the pre-spend Holy Seal count", () => {
expect(romKnightDivinePunishmentAuraForSealStacks(0)).toBeNull();
expect(romKnightDivinePunishmentAuraForSealStacks(1)?.aura.modifiers).toEqual([
{ kind: "damage", direction: "taken", operation: "percent", value: 0.02 },
]);
expect(romKnightDivinePunishmentAuraForSealStacks(4)?.aura.modifiers).toEqual([
{ kind: "damage", direction: "taken", operation: "percent", value: 0.08 },
]);
expect(romKnightDivinePunishmentAuraForSealStacks(8)?.aura.durationMs).toBe(10_000);
});
it("bridges current CoA Knight/Shaman Compassion to the local Knight/Priest row by exact passive and skill IDs", () => {
const local = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID)!;
const currentAlias = { ...local, dbcSpellId: ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID };
const compassion = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491635)!;
expect(local.name).toBe("Whirlwind Shield");
expect(compassion).toMatchObject({ classId: "rom-knight", secondaryClassId: "rom-priest" });
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
"rom-knight", "rom-priest", local, [491635],
)).toBe(1_000);
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
"rom-knight", "rom-priest", currentAlias, [491635],
)).toBe(1_000);
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
"rom-knight", "rom-shaman", local, [491635],
)).toBe(0);
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
"rom-knight", "rom-warrior", local, [491635],
)).toBe(0);
expect(romKnightCompassionWhirlwindShieldHealAmount(0)).toBe(120);
expect(romKnightCompassionWhirlwindShieldHealAmount(1_000)).toBe(320);
});
});
+232 -78
View File
@@ -5,9 +5,13 @@ 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_COMPASSION_OF_HOLY_LIGHT_TAG = "rom-compassion-of-holy-light";
export const ROM_KNIGHT_HOLY_ILLUMINATION_TAG = "rom-holy-illumination";
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";
export const ROM_KNIGHT_DEADLY_WHIRLWIND_TAG = "rom-deadly-whirlwind";
export const ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID = 1_493_719;
export const ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID = 490_175;
/** Shield of Valor reduces Holy Seal's remaining cooldown only on an active-aura parry. */
export function romKnightShieldOfValorCooldownReductionMs(auraActive: boolean, parried: boolean): number {
@@ -39,22 +43,97 @@ export function romKnightWarriorWhirlwindAdjustment(
return { rageCostReduction: 10, cooldownReductionMs: 20_000 };
}
/** RuneWaker Knight/Rogue passive 491621 boosts only Holy Strike's critical rate. */
/** Current CoA changed God's Sword to passive, but the current passive payload is unpublished. */
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 RuneWaker snapshot's 40% Holy Strike bonus conflicts with current CoA,
// which only says God's Sword became passive and does not publish its payload.
void characterClassId;
void secondaryClassId;
void ability;
void learnedPassiveSpellIds;
return 0;
}
// The populated local tooltip specifies a 40% Holy Strike critical-rate increase.
return 40;
function hasPassiveTag(
passiveTags: readonly string[] | ReadonlySet<string>,
tag: string,
): boolean {
return Array.isArray(passiveTags)
? passiveTags.includes(tag)
: (passiveTags as ReadonlySet<string>).has(tag);
}
/** CoA 6-1: Deadly Wound reduces healing received by 30%; player targets last 5s. */
export function romKnightDeadlyWhirlwindAura(targetIsPlayer: boolean): Extract<AbilityEffect, { kind: "apply-aura" }> {
return {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-knight-deadly-wound",
name: "Deadly Wound",
disposition: "debuff",
durationMs: targetIsPlayer ? 5_000 : 10_000,
dispelCategory: "magic",
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: -0.3 }],
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491_639,
};
}
/** CoA 10.6.1: Divine Punishment makes the target take 2% more damage per Seal for 10s. */
export function romKnightDivinePunishmentAuraForSealStacks(
sealStacks: number,
): Extract<AbilityEffect, { kind: "apply-aura" }> | null {
const stacks = Math.max(0, Math.min(4, Math.trunc(sealStacks)));
if (stacks <= 0) return null;
return {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-knight-divine-punishment-damage-taken",
name: "Divine Punishment",
disposition: "debuff",
durationMs: 10_000,
dispelCategory: "magic",
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: stacks * 0.02 }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: stacks * 2,
triggerSpellId: 491_608,
};
}
/** Current CoA 11.1.5 cooldown rider, bridged by the exact Whirlwind Shield name. */
export function romKnightCompassionWhirlwindShieldCooldownReductionMs(
characterClassId: string,
secondaryClassId: string | null | undefined,
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
learnedPassiveSpellIds: readonly number[],
): number {
return characterClassId === "rom-knight"
&& secondaryClassId === "rom-priest"
&& ability.source === "runewaker"
&& ability.classId === "rom-knight"
&& (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID
|| ability.dbcSpellId === ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID)
&& learnedPassiveSpellIds.includes(491_635)
? 1_000
: 0;
}
/** CoA 11.1.5 Compassion heal formula; called only after a Whirlwind Shield hit lands. */
export function romKnightCompassionWhirlwindShieldHealAmount(stamina: number): number {
return Math.max(0, Math.round(120 + Math.max(0, stamina) * 0.2));
}
const ROM_KNIGHT_MASTER_OF_PARRY_PROC: AuraProcDefinition = {
@@ -83,11 +162,83 @@ function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly Ability
}];
}
function deadWhirlwindPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-491639",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491_639,
}];
}
function compassionOfHolyLightPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-491635",
name: "Compassion of Holy Light",
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491_635,
}];
}
function holyIlluminationPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-491632",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_HOLY_ILLUMINATION_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491_632,
}];
}
/** Source-backed Knight passive and cast adjustments. */
export function overrideRomKnightEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId === "rom-knight"
&& row.secondaryClassId === "rom-warrior"
&& row.sourceSkillId === 490088) {
// Fearless's local description promises Fear immunity, while its source
// component only exposes the +3% Strength and Stamina modifiers. The
// [SC_SKILLTIPS_FEAR] suffix is a tooltip category marker; discard the
// generic parser's false-positive Fear control and attach immunity to its
// actual stat buff rows.
return translated
.filter((effect) => !(effect.kind === "apply-aura" && effect.control?.kind === "fear"))
.map((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0 ? {
...effect,
aura: {
...effect.aura,
controlImmunities: [...new Set([...(effect.aura.controlImmunities ?? []), "fear" as const])],
},
} : effect);
}
if (row.sourceSkillId === 492935) {
const durationMs = Math.max(0, ...(row.source?.components ?? []).map((component) => (
Math.max(0, Number(component.effectTime ?? 0)) * 1_000
@@ -113,6 +264,7 @@ export function overrideRomKnightEffects(
return effect;
});
}
if (row.sourceSkillId === 491639 && row.passive) return deadWhirlwindPassive(row);
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
if (row.sourceSkillId === 499934 && row.passive) return [{
kind: "apply-aura",
@@ -129,21 +281,8 @@ 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 === 491635 && row.passive) return compassionOfHolyLightPassive(row);
if (row.sourceSkillId === 491632 && row.passive) return holyIlluminationPassive(row);
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
@@ -159,61 +298,76 @@ export function romKnightCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[] | ReadonlySet<string> = [],
sealStacks = 0,
): 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 !== 490_133)) return effects;
let adjusted = effects;
if (ability.dbcSpellId === 490_151 && hasPassiveTag(passiveTags, ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)) {
adjusted = adjusted.map((effect) => {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
...effect,
coefficient: effect.coefficient * 1.285,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.285 }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.285 }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.285 }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.285 }),
};
});
const punishmentAura = romKnightDivinePunishmentAuraForSealStacks(sealStacks);
if (punishmentAura) adjusted = [...adjusted, { ...punishmentAura, requiresLandedHit: true }];
}
if (ability.source !== "runewaker"
|| ability.classId !== "rom-knight"
|| ability.dbcSpellId !== 490151
|| !(Array.isArray(passiveTags)
? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects;
if (ability.dbcSpellId === 490_133 && hasPassiveTag(passiveTags, ROM_KNIGHT_DEADLY_WHIRLWIND_TAG)) {
adjusted = [...adjusted, { ...romKnightDeadlyWhirlwindAura(false), requiresLandedHit: true }];
}
const increasedDamage = effects.map((effect) => {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
...effect,
coefficient: effect.coefficient * 1.01,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.01 }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.01 }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.01 }),
...(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,
}];
if (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID) {
adjusted = adjusted.map((effect) => effect.kind === "damage"
? {
...effect,
coefficient: 0,
basePoints: 0,
dieSides: 1,
weaponDpsCoefficient: 7.014,
school: "holy" as const,
}
: effect);
if (hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
adjusted = [...adjusted, {
kind: "heal",
coefficient: 0,
basePoints: 120,
dieSides: 1,
staminaCoefficient: 0.2,
scope: "party",
center: "caster",
radius: 100,
requiresLandedHit: true,
}];
}
if (hasPassiveTag(passiveTags, ROM_KNIGHT_HOLY_ILLUMINATION_TAG)
|| hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
adjusted = [...adjusted, {
kind: "apply-aura",
scope: "party",
radius: 100,
requiresLandedHit: true,
aura: {
id: "rom-knight-holy-illumination-proc",
name: "Holy Illumination",
disposition: "buff",
durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -0.15 }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491_632,
}];
}
}
return adjusted;
}
+25 -40
View File
@@ -8,7 +8,8 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import {
ROM_KNIGHT_DIVINE_PUNISHMENT_TAG,
ROM_KNIGHT_GODS_VENGEANCE_TAG,
ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG,
ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG,
romKnightDivinePunishmentAuraForSealStacks,
ROM_KNIGHT_SHIELD_OF_VALOR_TAG,
} from "./romKnightOverrides";
import { romEffectsForCast } from "./romCombatMechanics";
@@ -118,7 +119,7 @@ describe("RuneWaker Knight periodic skills", () => {
&& event.effectiveAmount > 0)).toBe(true);
});
it("limits Divine Punishment's sourced 1% bonus to Punishment", () => {
it("uses current CoA's 28.5% Divine Punishment damage bonus only on Punishment", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
});
@@ -163,9 +164,9 @@ describe("RuneWaker Knight periodic skills", () => {
expect(damageValues(baseline)).toHaveLength(2);
expect(damageValues(boosted)).toHaveLength(2);
for (const [index, base] of damageValues(baseline).entries()) {
expect(damageValues(boosted)[index]!.coefficient).toBeCloseTo(base.coefficient * 1.01);
expect(damageValues(boosted)[index]!.basePoints).toBeCloseTo(base.basePoints * 1.01);
expect(damageValues(boosted)[index]!.pointsPerLevel).toBeCloseTo(base.pointsPerLevel * 1.01);
expect(damageValues(boosted)[index]!.coefficient).toBeCloseTo(base.coefficient * 1.285);
expect(damageValues(boosted)[index]!.basePoints).toBeCloseTo(base.basePoints * 1.285);
expect(damageValues(boosted)[index]!.pointsPerLevel).toBeCloseTo(base.pointsPerLevel * 1.285);
}
expect(romEffectsForCast(abilityById("rom-knight-primary-1")!, {
...context,
@@ -173,54 +174,38 @@ 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({
it("replaces the old Light-only aura with current CoA's general per-seal vulnerability data", () => {
expect(romKnightDivinePunishmentAuraForSealStacks(3)?.aura).toMatchObject({
durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }],
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.06 }],
});
expect(vulnerability?.expiresAt).toBe(22_000);
expect(romKnightDivinePunishmentAuraForSealStacks(3)?.aura.id)
.toBe("rom-knight-divine-punishment-damage-taken");
});
it("reduces Holy Seal cooldown by three seconds with Holy Light's Fury", () => {
it("uses the local Knight/Priest Compassion row bridged to current CoA behavior and no longer changes Holy Seal cooldown", () => {
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([
const compassionPassive = state.abilities.find((ability) => ability.dbcSpellId === 491635)!;
expect(compassionPassive).toMatchObject({ name: "Compassion of Holy Light", passive: true });
expect(compassionPassive.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG] }),
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_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);
const tags = state.auras.find((aura) => aura.definition.utilityTags?.includes(ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG))
?.definition.utilityTags ?? [];
expect(romEffectsForCast(holySeal, {
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false,
casterMaxHealth: state.maxHealth, targetHealthPercent: 1, passiveTags: tags,
})).toEqual(romEffectsForCast(holySeal, {
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false,
casterMaxHealth: state.maxHealth, targetHealthPercent: 1,
}));
});
it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => {
+118 -1
View File
@@ -16,11 +16,32 @@ function playerWhirlwind(state: ReturnType<typeof useCombatStore.getState>): Abi
return ability;
}
function testShield(instanceId: string) {
return {
instanceId,
itemId: 1,
itemKey: instanceId,
name: "Test Shield",
quality: "uncommon" as const,
inventorySlot: "off-hand" as const,
armorType: "shield" as const,
weaponType: null,
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
}
describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
});
it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => {
@@ -51,7 +72,7 @@ describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
const final = useCombatStore.getState();
expect(final.resourcePools.rage).toBe(0);
expect(final.cooldowns[whirlwind.id]).toBe(11_000);
@@ -101,4 +122,100 @@ describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
&& event.sourceActorId === knight.id && event.abilityId === whirlwind.id
&& event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true);
});
it("applies current Compassion to player Whirlwind Shield only after a landed hit", () => {
const playerShield = testShield("compassion-player-shield");
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
inventory: [playerShield],
equipment: { player: { "off-hand": playerShield.instanceId } },
});
const initial = useCombatStore.getState();
const shield = initial.abilities.find((ability) => ability.dbcSpellId === 490175)!;
expect(shield).toMatchObject({ cooldownMs: 5_000, radius: 100 });
expect(initial.abilities.find((ability) => ability.dbcSpellId === 491635)).toMatchObject({
classId: "rom-knight", secondaryClassId: "rom-priest", passive: true,
});
const allySeed = createConfiguredPartyMember({
name: "Wounded Ally", classId: "priest", specialization: "Holy", role: "healer",
}, 80, 0, "compassion-player-ally");
const ally = { ...allySeed, health: 1 };
usePartyStore.setState({ members: [ally] });
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("compassion-player-target", {
name: "Compassion Target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("compassion-player-target", [8, 0, 0]);
useCombatStore.getState().selectMob("compassion-player-target");
const result = useCombatStore.getState().castAbility(shield.id, undefined, 1_000);
const final = useCombatStore.getState();
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(final.cooldowns[shield.id]).toBe(5_000);
expect(usePartyStore.getState().members.find((member) => member.id === ally.id)!.health).toBeGreaterThan(1);
expect(final.combatEvents.some((event) => event.kind === "damage"
&& event.abilityId === shield.id && event.targetActorId === "compassion-player-target"
&& event.effectiveAmount > 0 && event.school === "holy")).toBe(true);
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
&& aura.targetId === ally.id && aura.expiresAt === 11_000)).toBe(true);
});
it("applies current Compassion heal, Holy Illumination and 4-second cooldown for a generated companion", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const seed = "compassion-party-knight-shaman";
const knightSeed = createConfiguredPartyMember({
name: "Knight Shaman",
classId: "rom-knight",
secondaryClassId: "rom-priest",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
const abilities = abilitiesForCharacter("rom-knight", "rom-priest");
const shield = abilities.find((ability) => ability.dbcSpellId === 490175)!;
const knight = {
...knightSeed,
health: 1,
cooldowns: Object.fromEntries(abilities
.filter((ability) => ability.id !== shield.id)
.map((ability) => [ability.id, 60_000])),
};
const partyShield = testShield("compassion-party-shield");
usePartyStore.setState({ members: [knight], command: "attack" });
const assignments = {
...useCombatStore.getState().equipment,
[knight.id]: { "off-hand": partyShield.instanceId },
};
useCombatStore.setState((state) => ({
equipment: assignments,
inventory: [...state.inventory, partyShield],
}));
usePartyStore.getState().syncEquipment(assignments, useCombatStore.getState().inventory);
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
useCombatStore.getState().registerMob("compassion-party-target", {
name: "Compassion Party Target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("compassion-party-target", [10, 0, 0]);
useCombatStore.getState().damageMob("compassion-party-target", 1, 100);
updatePartyRuntimeTargetVisibility(knight.id, "compassion-party-target", true);
advanceDungeonPartyCombat(1_000);
const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!;
const final = useCombatStore.getState();
expect(final.combatEvents.some((event) => event.kind === "damage"
&& event.sourceActorId === knight.id && event.abilityId === shield.id
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0)).toBe(true);
expect(finalMember.health).toBeGreaterThan(1);
expect(finalMember.cooldowns[shield.id]).toBe(5_000);
expect(final.combatEvents.some((event) => event.kind === "damage"
&& event.sourceActorId === knight.id && event.abilityId === shield.id
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0
&& event.school === "holy")).toBe(true);
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
&& aura.sourceId === knight.id && aura.targetId === knight.id && aura.expiresAt === 11_000)).toBe(true);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
ROM_COA_LEOPARD_INSTINCT_MAX_STACKS,
createRomLeopardInstinctState,
romLeopardInstinctModifiers,
romLeopardInstinctOnDodge,
romLeopardInstinctOnHitReceived,
} from "./romLeopardInstinctOverrides";
describe("current CoA Leopard Instinct passive", () => {
it("starts as a zero-stack passive state without activation or timing fields", () => {
const state = createRomLeopardInstinctState();
expect(state).toEqual({ stacks: 0 });
expect("expiresAt" in state).toBe(false);
expect("cooldownUntil" in state).toBe(false);
expect("absorbs" in state).toBe(false);
expect(romLeopardInstinctModifiers(state)).toEqual({
dodgeRateBonusPercent: 0,
magicResistanceRateBonusPercent: 0,
});
});
it("adds one stack per received hit and keeps both sourced rates separate", () => {
let state = createRomLeopardInstinctState();
state = romLeopardInstinctOnHitReceived(state);
expect(state.stacks).toBe(1);
expect(romLeopardInstinctModifiers(state)).toEqual({
dodgeRateBonusPercent: 3,
magicResistanceRateBonusPercent: 3,
});
state = romLeopardInstinctOnHitReceived(state);
expect(romLeopardInstinctModifiers(state)).toEqual({
dodgeRateBonusPercent: 6,
magicResistanceRateBonusPercent: 6,
});
});
it("caps received-hit stacks at ten", () => {
let state = createRomLeopardInstinctState();
for (let hit = 0; hit < ROM_COA_LEOPARD_INSTINCT_MAX_STACKS + 4; hit += 1) {
state = romLeopardInstinctOnHitReceived(state);
}
expect(state.stacks).toBe(ROM_COA_LEOPARD_INSTINCT_MAX_STACKS);
expect(romLeopardInstinctModifiers(state)).toEqual({
dodgeRateBonusPercent: 30,
magicResistanceRateBonusPercent: 30,
});
});
it("loses exactly one stack on dodge and does not underflow", () => {
let state = createRomLeopardInstinctState(4);
state = romLeopardInstinctOnDodge(state);
expect(state).toEqual({ stacks: 3 });
expect(romLeopardInstinctModifiers(state)).toEqual({
dodgeRateBonusPercent: 9,
magicResistanceRateBonusPercent: 9,
});
const empty = createRomLeopardInstinctState();
expect(romLeopardInstinctOnDodge(empty)).toBe(empty);
});
it("does not model damage absorption", () => {
const state = romLeopardInstinctOnHitReceived(createRomLeopardInstinctState());
const modifiers = romLeopardInstinctModifiers(state);
expect("absorbs" in modifiers).toBe(false);
expect(modifiers).toEqual({
dodgeRateBonusPercent: 3,
magicResistanceRateBonusPercent: 3,
});
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Source-backed current-CoA state for Knight/Rogue Leopard Instinct (495402).
* It is a passive: each hit received adds one stack (up to 10), and
* dodging removes one stack. Each stack grants +3% Dodge and +3% Magic
* Resistance Rate. The old three-hit absorb, activation, duration, and
* cooldown behavior do not apply to this current passive.
*
* Sources:
* - https://chroniclesofarcadia.com/tr/patch/11-1-5-3000
* - https://chroniclesofarcadia.com/fr/patch/12-1-2-1000
*
* The helper exposes Magic Resistance Rate separately. The combat runtime
* currently has no matching stat consumer, so callers must not reinterpret
* it as elemental resistance without a confirmed conversion.
*/
export const ROM_COA_LEOPARD_INSTINCT_ID = 495402;
export const ROM_COA_LEOPARD_INSTINCT_MAX_STACKS = 10;
export const ROM_COA_LEOPARD_INSTINCT_BONUS_PER_STACK_PERCENT = 3;
export interface RomLeopardInstinctState {
readonly stacks: number;
}
export interface RomLeopardInstinctModifiers {
/** Sourced bonus to Dodge rate. */
readonly dodgeRateBonusPercent: number;
/** Sourced bonus to Magic Resistance Rate; kept separate from elemental resistance. */
readonly magicResistanceRateBonusPercent: number;
}
const NO_MODIFIERS: RomLeopardInstinctModifiers = {
dodgeRateBonusPercent: 0,
magicResistanceRateBonusPercent: 0,
};
/** Initial passive state; stacks persist until combat events change them. */
export function createRomLeopardInstinctState(stacks = 0): RomLeopardInstinctState {
return { stacks: clampStacks(stacks) };
}
/** Add one stack for each received hit, capped at ten. */
export function romLeopardInstinctOnHitReceived(
state: RomLeopardInstinctState,
): RomLeopardInstinctState {
const stacks = clampStacks(state.stacks);
if (stacks >= ROM_COA_LEOPARD_INSTINCT_MAX_STACKS) return state;
return { stacks: stacks + 1 };
}
/** A dodge removes one stack; the passive has no absorb charge behavior. */
export function romLeopardInstinctOnDodge(
state: RomLeopardInstinctState,
): RomLeopardInstinctState {
const stacks = clampStacks(state.stacks);
if (stacks <= 0) return state;
return { stacks: stacks - 1 };
}
/** Resolve Dodge and Magic Resistance Rate independently from current stacks. */
export function romLeopardInstinctModifiers(
state: RomLeopardInstinctState | null,
): RomLeopardInstinctModifiers {
if (!state) return NO_MODIFIERS;
const bonus = clampStacks(state.stacks) * ROM_COA_LEOPARD_INSTINCT_BONUS_PER_STACK_PERCENT;
return {
dodgeRateBonusPercent: bonus,
magicResistanceRateBonusPercent: bonus,
};
}
function clampStacks(stacks: number): number {
return Math.max(0, Math.min(ROM_COA_LEOPARD_INSTINCT_MAX_STACKS, Math.trunc(stacks)));
}
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import {
isRomMageDruidPair,
ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG,
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
romMageDruidGreenGuardianMovementSpeedMultiplier,
} from "./romMageDruidGreenGuardianRuntime";
const skill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID
&& ability.classId === "rom-mage" && ability.secondaryClassId === "rom-druid");
function guardianAura(targetId: string) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
}
describe("CoA Mage/Druid Green Guardian runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-mage",
secondaryClassId: "rom-druid",
raceId: "rom-elf",
level: 45,
role: "damage",
});
usePartyStore.getState().initializeParty(
"green-guardian-test", 45, useCombatStore.getState().health, "damage", 2, "rom",
);
});
it("casts as a self skill, accrues only after standing, pauses after movement, and speeds player movement", () => {
if (!skill) throw new Error("Missing generated Mage/Druid Green Guardian row");
expect(isRomMageDruidPair(useCombatStore.getState())).toBe(true);
expect(useCombatStore.getState().castAbility(skill.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(0);
useCombatStore.getState().tick(0.5, 1_500);
useCombatStore.getState().tick(0.5, 2_000);
useCombatStore.getState().tick(0.5, 2_500);
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
expect(romMageDruidGreenGuardianMovementSpeedMultiplier(
useCombatStore.getState().auras, PLAYER_AGGRO_ID, 2_500,
)).toBe(1.01);
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
useCombatStore.getState().tick(0.25, 2_750);
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
useCombatStore.getState().tick(0.5, 3_250);
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
useCombatStore.getState().tick(0.5, 3_750);
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(2);
});
it("starts a generated Mage/Druid companion at zero and accrues against its runtime position", () => {
if (!skill) throw new Error("Missing generated Mage/Druid Green Guardian row");
const seed = "green-guardian-companion-test";
const companion = createConfiguredPartyMember({
name: "Green Guardian Mage",
classId: "rom-mage",
secondaryClassId: "rom-druid",
specialization: "Primary",
role: "damage",
}, 45, 0, seed);
usePartyStore.getState().initializeParty(seed, 45, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({ members: [companion] });
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const effect = skill.effects.find((candidate) => candidate.kind === "apply-aura");
if (!effect || effect.kind !== "apply-aura") throw new Error("Green Guardian has no self aura effect");
expect(useCombatStore.getState().applyActorAura(companion.id, companion.id, skill, effect, 1_000)).toBe(true);
expect(guardianAura(companion.id)?.stacks).toBe(0);
useCombatStore.getState().tick(0.5, 1_500);
useCombatStore.getState().tick(0.5, 2_000);
useCombatStore.getState().tick(0.5, 2_500);
expect(guardianAura(companion.id)?.stacks).toBe(1);
expect(romMageDruidGreenGuardianMovementSpeedMultiplier(
useCombatStore.getState().auras, companion.id, 2_500,
)).toBe(1.01);
updatePartyRuntimePosition(companion.id, 1, 0, 0);
useCombatStore.getState().tick(0.25, 2_750);
expect(guardianAura(companion.id)?.stacks).toBe(1);
useCombatStore.getState().tick(1, 3_750);
expect(guardianAura(companion.id)?.stacks).toBe(2);
});
it("does not grant movement speed to the reverse Druid/Mage pair", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
secondaryClassId: "rom-mage",
raceId: "rom-elf",
level: 45,
role: "damage",
});
expect(isRomMageDruidPair(useCombatStore.getState())).toBe(false);
expect(romMageDruidGreenGuardianMovementSpeedMultiplier([], PLAYER_AGGRO_ID, 2_500)).toBe(1);
});
});
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from "vitest";
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_DRUID_GREEN_GUARDIAN_AURA_TAG, ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID } from "./romMageDruidGreenGuardianRuntime";
describe("CoA Mage/Druid Green Guardian companion cast selection", () => {
beforeEach(() => {
resetPartyRuntimeClock();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-elf",
level: 45,
role: "healer",
});
usePartyStore.getState().initializeParty(
"green-guardian-party-test", 45, useCombatStore.getState().health, "damage", 2, "rom",
);
});
it("casts the learned zero-cost self skill through party combat and starts at zero stacks", () => {
const seed = "green-guardian-party-cast";
const companion = createConfiguredPartyMember({
name: "Green Guardian Mage",
classId: "rom-mage",
secondaryClassId: "rom-druid",
specialization: "Primary",
role: "damage",
}, 45, 0, seed);
usePartyStore.setState({
members: [companion],
command: "attack",
activeMobId: "guardian-target",
});
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("guardian-target", {
name: "Guardian target",
level: 1,
maxHealth: 1_000,
xpReward: 0,
});
setMobPosition("guardian-target", [2, 0, 0]);
useCombatStore.getState().damageMob("guardian-target", 1, 500);
updatePartyRuntimeTargetVisibility(companion.id, "guardian-target", true);
advanceDungeonPartyCombat(1_000);
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === companion.id
&& candidate.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
expect(aura?.stacks).toBe(0);
expect(aura?.sourceId).toBe(companion.id);
const committed = usePartyStore.getState().members[0];
expect(committed.globalCooldownEndsAt).toBe(2_500);
expect(committed.cooldowns).not.toHaveProperty(String(ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID));
});
it("does not auto-cast Green Guardian for the reverse Druid/Mage pair", () => {
const seed = "green-guardian-party-reverse";
const companion = createConfiguredPartyMember({
name: "Reverse Pair Druid",
classId: "rom-druid",
secondaryClassId: "rom-mage",
specialization: "Primary",
role: "damage",
}, 45, 0, seed);
usePartyStore.setState({
members: [companion],
command: "attack",
activeMobId: "guardian-target",
});
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("guardian-target", {
name: "Guardian target",
level: 1,
maxHealth: 1_000,
xpReward: 0,
});
setMobPosition("guardian-target", [2, 0, 0]);
useCombatStore.getState().damageMob("guardian-target", 1, 500);
updatePartyRuntimeTargetVisibility(companion.id, "guardian-target", true);
advanceDungeonPartyCombat(1_000);
expect(useCombatStore.getState().auras.some((candidate) => candidate.targetId === companion.id
&& candidate.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG))).toBe(false);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
isRomMageDruidPair,
ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS,
ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS,
ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK,
romMageDruidGreenGuardianAdvanceStationaryProgress,
romMageDruidGreenGuardianMovementSpeedPercent,
romMageDruidGreenGuardianMovementSpeedMultiplier,
romMageDruidGreenGuardianStacksAfterStationarySecond,
romMageDruidGreenGuardianAuraDefinition,
} from "./romMageDruidGreenGuardianRuntime";
describe("current CoA Mage/Druid Green Guardian stacks", () => {
it("applies only to the exact Mage/Druid pair", () => {
expect(isRomMageDruidPair({ classId: "rom-mage", secondaryClassId: "rom-druid" })).toBe(true);
expect(isRomMageDruidPair({ classId: "rom-druid", secondaryClassId: "rom-mage" })).toBe(false);
expect(isRomMageDruidPair({ classId: "rom-mage", secondaryClassId: "rom-priest" })).toBe(false);
});
it("gains one stack for an eligible still second, up to the sourced cap of twenty", () => {
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS).toBe(1_000);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(0)).toBe(1);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(14)).toBe(15);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(19)).toBe(20);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(20)).toBe(20);
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS).toBe(20);
});
it("grants one percentage point of movement speed per stack", () => {
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK).toBe(1);
expect(romMageDruidGreenGuardianMovementSpeedPercent(0)).toBe(0);
expect(romMageDruidGreenGuardianMovementSpeedPercent(7)).toBe(7);
expect(romMageDruidGreenGuardianMovementSpeedPercent(20)).toBe(20);
expect(romMageDruidGreenGuardianMovementSpeedPercent(99)).toBe(20);
});
it("pauses gains for one full stationary second after observed movement without removing stacks", () => {
const first = romMageDruidGreenGuardianAdvanceStationaryProgress(undefined, [0, 0, 0], 1_000);
expect(first).toEqual({ progress: { position: [0, 0, 0], stationaryMs: 0 }, stacksGained: 0 });
const moved = romMageDruidGreenGuardianAdvanceStationaryProgress(first.progress, [1, 0, 0], 500);
expect(moved.stacksGained).toBe(0);
expect(moved.progress.stationaryMs).toBe(0);
const half = romMageDruidGreenGuardianAdvanceStationaryProgress(moved.progress, [1, 0, 0], 500);
expect(half.stacksGained).toBe(0);
const full = romMageDruidGreenGuardianAdvanceStationaryProgress(half.progress, [1, 0, 0], 500);
expect(full.stacksGained).toBe(1);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(4)).toBe(5);
});
it("uses an indefinite, initially zero-stack aura and a 1% movement bonus per stack", () => {
const definition = romMageDruidGreenGuardianAuraDefinition();
expect(definition.durationMs).toBeNull();
expect(definition.maxStacks).toBe(20);
const aura = {
instanceId: "target:green-guardian:source",
definition,
sourceId: "source",
targetId: "target",
appliedAt: 0,
expiresAt: null,
stacks: 7,
absorbs: [],
procStates: [],
};
expect(romMageDruidGreenGuardianMovementSpeedMultiplier([aura], "target", 1)).toBe(1.07);
});
it("normalizes malformed stack counts to the sourced zero-to-twenty bounds", () => {
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(-5)).toBe(1);
expect(romMageDruidGreenGuardianMovementSpeedPercent(4.9)).toBe(4);
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(Number.NaN)).toBe(1);
});
});
@@ -0,0 +1,110 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { ActiveAura, AuraDefinition } from "./combatAuras";
/** Current CoA Mage/Druid Green Guardian stack behavior (skill 494331). */
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID = 494331;
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS = 20;
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS = 1_000;
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK = 1;
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG = "rom-mage-druid-green-guardian";
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION = "While standing still, gain one Green Guardian stack each second, up to 20. Each stack grants 1% movement speed. Moving pauses stack generation. Current CoA also describes an MP restore when a stack is lost, but the loss trigger and percentage basis are unspecified, so that clause is not implemented.";
export function romMageDruidGreenGuardianAuraDefinition(): AuraDefinition {
return {
id: "rom:mage-druid:green-guardian",
name: "Green Guardian",
description: ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
disposition: "buff",
durationMs: null,
maxStacks: ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS,
stackBehavior: "replace",
utilityTags: [ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG],
modifiers: [{
kind: "stat",
stat: "movement-speed",
operation: "percent",
value: ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK / 100,
perStack: true,
}],
};
}
export function romMageDruidGreenGuardianAuraEffect(): Extract<AbilityEffect, { kind: "apply-aura" }> {
return {
kind: "apply-aura",
recipient: "caster",
aura: romMageDruidGreenGuardianAuraDefinition(),
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
};
}
export function isRomMageDruidPair(row: {
readonly classId?: string;
readonly secondaryClassId?: string | null;
}): boolean {
return row.classId === "rom-mage" && row.secondaryClassId === "rom-druid";
}
function boundedStacks(stacks: number): number {
if (!Number.isFinite(stacks)) return 0;
return Math.max(0, Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, Math.floor(stacks)));
}
/** Apply one source-described stack gain after an eligible second spent standing still. */
export function romMageDruidGreenGuardianStacksAfterStationarySecond(stacks: number): number {
return Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, boundedStacks(stacks) + 1);
}
/** Percentage points of movement speed granted by the current stack count. */
export function romMageDruidGreenGuardianMovementSpeedPercent(stacks: number): number {
return boundedStacks(stacks) * ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK;
}
export function romMageDruidGreenGuardianMovementSpeedMultiplier(
auras: readonly ActiveAura[],
targetId: string,
now: number,
): number {
const stacks = auras
.filter((aura) => aura.targetId === targetId
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG)
&& (aura.expiresAt === null || aura.expiresAt > now))
.reduce((total, aura) => total + boundedStacks(aura.stacks), 0);
return 1 + romMageDruidGreenGuardianMovementSpeedPercent(stacks) / 100;
}
export interface RomMageDruidGreenGuardianStationaryProgress {
readonly position: readonly [number, number, number];
readonly stationaryMs: number;
}
export interface RomMageDruidGreenGuardianStationaryAdvance {
readonly progress: RomMageDruidGreenGuardianStationaryProgress;
readonly stacksGained: number;
}
/**
* Advance one simulator tick. Any observed position change resets progress;
* this is the documented one-full-stationary-second approximation for CoA's
* unspecific “brief” movement pause. It does not remove existing stacks.
*/
export function romMageDruidGreenGuardianAdvanceStationaryProgress(
previous: RomMageDruidGreenGuardianStationaryProgress | undefined,
position: readonly [number, number, number],
deltaMs: number,
): RomMageDruidGreenGuardianStationaryAdvance {
const safeDelta = Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0);
if (!previous) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 };
const moved = (position[0] - previous.position[0]) ** 2
+ (position[1] - previous.position[1]) ** 2
+ (position[2] - previous.position[2]) ** 2 > 1e-8;
if (moved) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 };
const total = Math.max(0, previous.stationaryMs) + safeDelta;
return {
progress: { position, stationaryMs: total % ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS },
stacksGained: Math.floor(total / ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS),
};
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { applyAura, resolveDamageValue } from "./combatAuras";
describe("RoM Mage Fire Ward catalog behavior", () => {
it("retains RuneWaker's +10 Fire resistance and applies current CoA's all-magic reduction", () => {
const fireWard = abilityById("rom-mage-general-5");
expect(fireWard).toMatchObject({
dbcSpellId: 490_248,
target: "self",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
range: { min: 0, max: 15 },
radius: 10,
targeting: { shape: "circle", radius: 10 },
costs: [{ resource: "mana", amount: 120 }],
description: expect.stringContaining("+230 Fire resistance at skill level 105"),
});
expect(fireWard?.description).toContain("Fire resistance by 10 points");
expect(fireWard?.description).toContain("11.5%");
const auraEffect = fireWard?.effects.find((effect) => effect.kind === "apply-aura");
expect(auraEffect).toMatchObject({
kind: "apply-aura",
scope: "party",
radius: 10,
aura: { name: "Fire Ward: fire-resistance" },
});
if (!auraEffect || auraEffect.kind !== "apply-aura") throw new Error("Fire Ward aura missing");
expect(auraEffect.aura.durationMs).toBe(600_000);
expect(auraEffect.aura.modifiers).toContainEqual(expect.objectContaining({
kind: "stat",
stat: "resistance:fire",
operation: "flat",
value: 10,
}));
expect(auraEffect.aura.description).toContain("CoA lists +230 at skill level 105");
const auras = applyAura([], {
definition: auraEffect.aura,
sourceId: "fire-ward-mage",
targetId: "fire-ward-recipient",
now: 1_000,
}).auras;
const magicalSchools = ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const;
for (const school of magicalSchools) {
expect(resolveDamageValue(100, auras, {
direction: "taken",
school,
attackKind: "spell",
})).toBeCloseTo(88.5);
}
expect(resolveDamageValue(100, auras, {
direction: "taken",
school: "physical",
attackKind: "melee",
})).toBe(100);
});
});
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const FIRE_WARD_ID = "rom-mage-general-5";
const FIRE_WARD_AURA_NAME = "Fire Ward: fire-resistance";
function fireWardAura(targetId: string, now: number) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.definition.name === FIRE_WARD_AURA_NAME
&& (aura.expiresAt === null || aura.expiresAt > now));
}
function configureParty(seed: string) {
const caster = createConfiguredPartyMember({
name: "Fire Ward Mage",
classId: "rom-mage",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
const nearby = createConfiguredPartyMember({
name: "Nearby Fire Ward Ally",
classId: "rom-priest",
specialization: "Primary",
role: "healer",
}, 80, 1, seed);
const distant = createConfiguredPartyMember({
name: "Distant Fire Ward Ally",
classId: "rom-priest",
specialization: "Primary",
role: "damage",
}, 80, 2, seed);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({ members: [caster, nearby, distant] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(nearby.id, [8, 0, 0]);
registerPartyRuntimePosition(distant.id, [12, 0, 0]);
useCombatStore.getState().synchronizeActors();
return { caster, nearby, distant };
}
describe("RoM Mage Fire Ward current CoA behavior", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useDungeonSimulationStore.setState({ sessionMode: "play" });
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 80 });
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("applies the player's Fire Ward to self and nearby party members only", () => {
const { caster, nearby, distant } = configureParty("player-fire-ward-radius");
expect(useCombatStore.getState().castAbility(FIRE_WARD_ID, undefined, 1_000)).toMatchObject({ ok: true });
expect(fireWardAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
expect(fireWardAura(caster.id, 1_000)).toBeDefined();
expect(fireWardAura(nearby.id, 1_000)).toBeDefined();
expect(fireWardAura(distant.id, 1_000)).toBeUndefined();
});
it("applies a generated companion's Fire Ward to itself and nearby party members only", () => {
const seed = "companion-fire-ward-radius";
const { caster, nearby, distant } = configureParty(seed);
const fireWard = abilityById(FIRE_WARD_ID)!;
usePartyStore.setState({ members: [{
...caster,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...caster.resourcePools, mana: 10_000 },
activeCast: {
abilityId: fireWard.id,
school: "arcane",
mode: "cast",
startedAt: 1_000,
completesAt: 1_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: caster.id,
},
activeCastTargetId: caster.id,
status: "attacking",
}, nearby, distant] });
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
expect(fireWardAura(caster.id, 1_000)).toBeDefined();
expect(fireWardAura(nearby.id, 1_000)).toBeDefined();
expect(fireWardAura(distant.id, 1_000)).toBeUndefined();
expect(fireWardAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
});
});
+4 -4
View File
@@ -252,14 +252,14 @@ describe("RoM Mage mechanics", () => {
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
});
it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => {
it("doubles only Fireball and Flame on the 30% Eruption proc and runs Control Flame once", () => {
const fireball = abilityById("rom-mage-general-0")!;
const tags = new Set([
ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame,
]);
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099);
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1);
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.299);
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.3);
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
? expect.objectContaining({
@@ -292,7 +292,7 @@ describe("RoM Mage mechanics", () => {
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
...tags,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
]), 0.099).some((effect) => effect.kind === "apply-aura"
]), 0.299).some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
});
+27
View File
@@ -5,6 +5,8 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
// 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;
const MODELED_MAGICAL_DAMAGE_SCHOOLS = ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const;
const ROM_MAGE_FIRE_WARD_RADIUS = 10;
export function overrideRomMageEffects(
row: RomGeneratedAbilityLike,
@@ -18,6 +20,31 @@ export function overrideRomMageEffects(
// 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 === 490248) {
return translated.map((effect) => {
if (effect.kind !== "apply-aura") return effect;
const existingModifiers = effect.aura.modifiers ?? [];
return {
...effect,
scope: "party",
radius: ROM_MAGE_FIRE_WARD_RADIUS,
aura: {
...effect.aura,
description: "Current CoA reduces received magical damage by 11.5%. The simulator keeps RuneWaker's +10 Fire resistance and 10-minute duration; CoA lists +230 at skill level 105, but no scaling curve is modeled.",
modifiers: [
...existingModifiers,
...MODELED_MAGICAL_DAMAGE_SCHOOLS.map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value: -0.115,
})),
],
},
};
});
}
if (row.sourceSkillId !== 490247) return null;
return translated.map((effect) => {
+2 -2
View File
@@ -27,7 +27,7 @@ const INDUSTRY_FIRE_HIT_BASE = 140;
const INDUSTRY_FIRE_HIT_PER_LEVEL = 40;
const INDUSTRY_FIRE_DOT_BASE = 23.4;
const INDUSTRY_FIRE_DOT_PER_LEVEL = 10.4;
const MAGE_ERUPTION_PROC_CHANCE = 0.1;
export const ROM_COA_MAGE_ERUPTION_BASE_PROC_CHANCE = 0.3;
function scaledDamage(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage") return effect;
@@ -176,7 +176,7 @@ export function romMageWarlockCastEffects(
if ([490204, 491150].includes(ability.dbcSpellId)
&& passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption)
&& eruptionProcRoll < MAGE_ERUPTION_PROC_CHANCE) {
&& eruptionProcRoll < ROM_COA_MAGE_ERUPTION_BASE_PROC_CHANCE) {
const doubledDamage = effects.map((effect) => scaledDamage(effect, 2));
return passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame)
&& !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown)
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
+59 -11
View File
@@ -38,7 +38,7 @@ describe("generated RuneWaker player catalog", () => {
orderedPairs: 66,
eliteMilestones: 660,
ordinaryFamilies: 269,
iconResolved: 929,
iconResolved: 930,
uniqueNativeIcons: 918,
unresolvedVisible: 0,
});
@@ -46,11 +46,18 @@ describe("generated RuneWaker player catalog", () => {
});
it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => {
expect(ROM_ELITE_ABILITIES).toHaveLength(661);
// The generated snapshot contains the added Druid/Warrior 499956 row;
// the runtime then appends Devotion Halo, two CoA Warrior elite rows, and
// current CoA Warlock/Warrior Enraged 499871.
expect(ROM_ELITE_ABILITIES).toHaveLength(665);
const knownEmptyEffectElites = new Set([
"rom-mage-rom-knight-elite-45",
"rom-mage-rom-warrior-elite-50",
"rom-knight-rom-rogue-elite-40",
"rom-warlock-rom-warrior-elite-30",
"rom-warlock-rom-warrior-elite-70",
"rom-warlock-rom-priest-elite-15",
"rom-warlock-rom-priest-elite-35",
"rom-warlock-rom-priest-elite-50",
]);
const pairGroups = new Map<string, typeof ROM_ELITE_ABILITIES[number][]>();
for (const ability of ROM_ELITE_ABILITIES) {
@@ -59,8 +66,17 @@ describe("generated RuneWaker player catalog", () => {
}
expect(pairGroups.size).toBe(66);
for (const [pair, elites] of pairGroups) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
const expectedLevels = pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70]
: pair === "rom-warlock:rom-warrior"
? [...ELITE_LEVELS, 60]
: pair === "rom-druid:rom-warrior"
? [...ELITE_LEVELS.slice(0, 9), 60, 70]
: pair === "rom-warrior:rom-knight" || pair === "rom-warrior:rom-rogue"
? [...ELITE_LEVELS, 50]
: ELITE_LEVELS;
expect(elites.map((ability) => ability.unlockLevel).sort((left, right) => left - right))
.toEqual([...expectedLevels].sort((left, right) => left - right));
for (const ability of elites) {
expect(ability.source, `${pair}:${ability.id} source`).toBe("runewaker");
if (knownEmptyEffectElites.has(ability.id)) {
@@ -71,11 +87,23 @@ describe("generated RuneWaker player catalog", () => {
}
}
}
expect(ROM_ALL_ABILITIES).toHaveLength(930);
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
/^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
&& ability.icon === generated.skills.find((skill) => skill.id === ability.id)?.icon
))).toBe(true);
// The generated 930-row snapshot is joined with Devotion Halo, current CoA
// Warrior/Knight and Warrior/Rogue rows, and CoA Enraged 499871.
expect(ROM_ALL_ABILITIES).toHaveLength(934);
const runtimeAddedIconSources = new Map([
["rom-warrior-rom-knight-elite-50-coa-493287", 491480],
["rom-warrior-rom-rogue-elite-50-coa-493283", 491133],
["rom-warlock-rom-warrior-general-coa-499871", 490492],
]);
for (const ability of ROM_ALL_ABILITIES) {
expect(ability.icon).toMatch(/^\/assets\/ui\/rom-spells\/\d+\.png$/);
if (ability.id === "rom-priest-rom-knight-elite-70-devotion-halo") continue;
const runtimeSource = runtimeAddedIconSources.get(ability.id);
const expectedIcon = runtimeSource === undefined
? generated.skills.find((skill) => skill.id === ability.id)?.icon
: generated.skills.find((skill) => skill.sourceSkillId === runtimeSource)?.icon;
expect(ability.icon, ability.id).toBe(expectedIcon);
}
expect(ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-knight-elite-70-devotion-halo")?.icon)
.toBe(generated.skills.find((skill) => skill.sourceSkillId === 499957)?.icon);
expect(generated.skills.every((ability) => (
@@ -114,6 +142,24 @@ describe("generated RuneWaker player catalog", () => {
}));
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
const sourceCosts = (sourceSkillId: number, rows: typeof generated.skills[number]["costs"]) => {
// Current CoA represents Holy Power Explosion (490172) as a passive
// marker, so it is no longer a cast with the local ten-Mana cost.
if (sourceSkillId === 490172) return [];
// CoA changed Chaos Dagger (491178) to a passive but still lists 25 MP
// without saying whether that is paid per attack. Do not model the old
// Enchanted Throw activation cost or guess a recurring proc charge.
if (sourceSkillId === 491178) return [];
// CoA Leopard Instinct (495402) is passive, with no cast cost.
if (sourceSkillId === 495402) return [];
if (sourceSkillId === 490274) return [];
// Current CoA Holy Candle (490304) has a 5% Mana activation cost;
// the generated RuneWaker row omits that cost.
if (sourceSkillId === 490304) return [{ resource: "mana", amount: 5, percentage: true }];
if (sourceSkillId === 490276) return [{
resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana",
}];
if (sourceSkillId === 498612) return [{ resource: "rage", amount: 15 }];
if (sourceSkillId === 498642) return [{ resource: "rage", amount: 75, percentage: false }];
let normalized = normalizedCosts(rows);
// The current CoA Shout entry (same ID/name) uses 15 Rage while the
// older local RuneWaker snapshot records 30; the runtime follows CoA.
@@ -124,7 +170,9 @@ describe("generated RuneWaker player catalog", () => {
|| normalized.some((entry) => entry.resource === "nature-power")) return normalized;
return [...normalized, { resource: "nature-power", amount: 1 }];
};
expect(generatedRanks).toBe(934);
// Druid/Warrior Cross of Thorns Attacks (499956) is a separately added
// generated row with one rank in the current source snapshot.
expect(generatedRanks).toBe(935);
expect(Math.max(...generated.skills.flatMap((ability) => (
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
)))).toBe(400);
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID } from "./romPriestChampionBeaconRuntime";
const beacon = abilityById(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID)!;
const heal = abilityById("rom-priest-primary-2")!;
const beaconTag = "rom-priest-champion-beacon-of-regeneration";
function setPlayerMana(amount: number): void {
useCombatStore.setState((state) => ({
resource: amount,
maxResource: amount,
maxResourcePools: { ...state.maxResourcePools, mana: amount },
resourcePools: { ...state.resourcePools, mana: amount },
}));
}
function activeCast(abilityId: string, targetId: string, startedAt: number, completesAt: number) {
return {
abilityId,
school: "holy" as const,
mode: "cast" as const,
startedAt,
completesAt,
originalDurationMs: Math.max(0, completesAt - startedAt),
pushbackHits: 0,
origin: [0, 0, 0] as const,
targetId,
};
}
describe("RoM Priest/Champion Beacon of Regeneration integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-champion",
raceId: "rom-dwarf",
level: 80,
role: "healer",
});
usePartyStore.getState().initializeParty(
"rom-priest-champion-beacon", 80, useCombatStore.getState().health, "healer", 2, "rom",
);
usePartyStore.getState().setCommand("stop");
setPlayerMana(10_000);
});
it("applies a player's Beacon with the caster's healing-done modifiers", () => {
const seed = "rom-priest-champion-beacon-player-target";
const marked = createConfiguredPartyMember({
name: "Marked ally",
classId: "rom-warrior",
raceId: "rom-human",
specialization: "Primary",
role: "tank",
}, 80, 0, seed);
const healTarget = createConfiguredPartyMember({
name: "Heal target",
classId: "rom-warrior",
raceId: "rom-human",
specialization: "Primary",
role: "tank",
}, 80, 1, seed);
usePartyStore.setState({ members: [{ ...marked, health: 1 }, { ...healTarget, health: 1 }] });
registerPartyRuntimePosition(marked.id, [1, 0, 0]);
registerPartyRuntimePosition(healTarget.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(beacon.id, {
targetActorId: marked.id,
now: 1_000,
})).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
sourceId: PLAYER_AGGRO_ID,
targetId: marked.id,
expiresAt: 31_000,
definition: expect.objectContaining({ utilityTags: [beaconTag] }),
}),
]));
expect(useCombatStore.getState().castAbility(heal.id, {
targetActorId: healTarget.id,
now: 3_000,
})).toMatchObject({ ok: true });
useCombatStore.getState().tick(3, 5_000);
const event = useCombatStore.getState().combatEvents.find((candidate) => (
candidate.kind === "healing" && candidate.abilityId === beacon.id
));
const faith = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === PLAYER_AGGRO_ID && aura.definition.name === "Faith: healing-power-percent"
));
expect(faith?.definition.modifiers).toContainEqual({
kind: "healing", direction: "done", operation: "percent", value: 0.05,
});
expect(event).toMatchObject({
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: marked.id,
rawAmount: 809,
effectiveAmount: 809,
overheal: 0,
critical: false,
});
expect(usePartyStore.getState().members.find((member) => member.id === marked.id)?.health).toBe(810);
});
it("applies a generated companion's Beacon and resolves its direct-heal trigger", () => {
const seed = "rom-priest-champion-beacon-companion";
const caster = createConfiguredPartyMember({
name: "Priest/Champion",
classId: "rom-priest",
secondaryClassId: "rom-champion",
raceId: "rom-dwarf",
specialization: "Priest/Champion",
role: "healer",
}, 80, 0, seed);
const marked = createConfiguredPartyMember({
name: "Marked companion ally",
classId: "rom-warrior",
raceId: "rom-human",
specialization: "Primary",
role: "tank",
}, 80, 1, seed);
const healTarget = createConfiguredPartyMember({
name: "Heal target companion",
classId: "rom-warrior",
raceId: "rom-human",
specialization: "Primary",
role: "tank",
}, 80, 2, seed);
usePartyStore.setState({ members: [
{ ...caster, resource: 10_000, maxResource: 10_000, resourcePools: { ...caster.resourcePools, mana: 10_000 },
activeCast: activeCast(beacon.id, marked.id, 1_000, 1_000), activeCastTargetId: marked.id, status: "attacking" },
{ ...marked, health: 1 },
{ ...healTarget, health: 1 },
] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(marked.id, [1, 0, 0]);
registerPartyRuntimePosition(healTarget.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceId: caster.id, targetId: marked.id, definition: expect.objectContaining({ utilityTags: [beaconTag] }) }),
]));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === caster.id ? {
...member,
activeCast: activeCast(heal.id, healTarget.id, 1_000, 3_000),
activeCastTargetId: healTarget.id,
status: "attacking",
} : member),
}));
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(3_000);
const faith = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === caster.id && aura.definition.name === "Faith: healing-power-percent"
));
expect(faith?.definition.modifiers).toContainEqual({
kind: "healing", direction: "done", operation: "percent", value: 0.05,
});
const event = useCombatStore.getState().combatEvents.find((candidate) => (
candidate.kind === "healing" && candidate.sourceActorId === caster.id && candidate.abilityId === beacon.id
));
expect(event).toMatchObject({
targetActorId: marked.id,
// Beacon contributes 770; the companion's ordinary Faith aura applies its +5% healing bonus.
rawAmount: 809,
effectiveAmount: 809,
overheal: 0,
critical: false,
});
expect(usePartyStore.getState().members.find((member) => member.id === marked.id)?.health).toBe(810);
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS,
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_HEAL_AT_SKILL_LEVEL_105,
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SPELL_ID,
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SOURCE_SKILL_LEVEL,
createRomPriestChampionBeaconState,
resolveRomPriestChampionBeaconHeal,
} from "./romPriestChampionBeaconRuntime";
describe("current CoA Priest/Champion Beacon of Regeneration", () => {
it("records the source-described skill, duration, and published +105 healing amount", () => {
expect(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SPELL_ID).toBe(498620);
expect(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS).toBe(30_000);
expect(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SOURCE_SKILL_LEVEL).toBe(105);
expect(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_HEAL_AT_SKILL_LEVEL_105).toBe(770);
});
it("heals the marked party member once for each caster direct-heal cast at sourced skill level 105", () => {
const beacon = createRomPriestChampionBeaconState("priest", "ally", 1_000);
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 1_001,
kind: "direct-cast",
}, 105)).toEqual({ targetId: "ally", amount: 770 });
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 15_000,
kind: "direct-cast",
}, 105)).toEqual({ targetId: "ally", amount: 770 });
});
it("does not extrapolate the +105 amount to unknown or lower skill levels", () => {
const beacon = createRomPriestChampionBeaconState("priest", "ally", 1_000);
const event = { casterId: "priest", occurredAtMs: 2_000, kind: "direct-cast" as const };
expect(resolveRomPriestChampionBeaconHeal(beacon, event, null)).toBeNull();
expect(resolveRomPriestChampionBeaconHeal(beacon, event, 50)).toBeNull();
expect(resolveRomPriestChampionBeaconHeal(beacon, event, 104)).toBeNull();
});
it("requires a direct-heal cast from the Beacon caster and excludes other actors, HoT ticks, and channel pulses", () => {
const beacon = createRomPriestChampionBeaconState("priest", "ally", 1_000);
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "companion",
occurredAtMs: 2_000,
kind: "direct-cast",
}, 105)).toBeNull();
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 2_000,
kind: "hot-tick",
}, 105)).toBeNull();
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 2_000,
kind: "channel-pulse",
}, 105)).toBeNull();
});
it("expires at 30 seconds and ignores events before Beacon creation", () => {
const beacon = createRomPriestChampionBeaconState("priest", "ally", 1_000);
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 30_999,
kind: "direct-cast",
}, 105)).toEqual({ targetId: "ally", amount: 770 });
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 31_000,
kind: "direct-cast",
}, 105)).toBeNull();
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: 999,
kind: "direct-cast",
}, 105)).toBeNull();
});
it("rejects incomplete cast state and invalid event times", () => {
expect(createRomPriestChampionBeaconState("", "ally", 0)).toBeNull();
expect(createRomPriestChampionBeaconState("priest", " ", 0)).toBeNull();
expect(createRomPriestChampionBeaconState("priest", "ally", Number.NaN)).toBeNull();
const beacon = createRomPriestChampionBeaconState("priest", "ally", 0);
expect(resolveRomPriestChampionBeaconHeal(beacon, {
casterId: "priest",
occurredAtMs: Number.NaN,
kind: "direct-cast",
}, 105)).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { ActiveAura } from "./combatAuras";
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID = "rom-priest-rom-champion-elite-50";
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SPELL_ID = 498620;
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS = 30_000;
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG = "rom-priest-champion-beacon-of-regeneration";
// The simulator represents CoA's maximum +105 skill row; lower ranks are not modeled separately.
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_HEAL_AT_SKILL_LEVEL_105 = 770;
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SOURCE_SKILL_LEVEL = 105;
export const ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA = {
id: "rom-priest-champion-beacon-of-regeneration",
name: "Beacon of Regeneration",
description: "Restores 770 HP to this member when the caster uses a direct healing effect. The simulator uses CoA's maximum +105 skill value for every unlocked rank.",
disposition: "buff" as const,
durationMs: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS,
stackBehavior: "refresh" as const,
utilityTags: [ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG],
};
export interface RomPriestChampionBeaconState {
casterId: string;
targetId: string;
appliedAtMs: number;
expiresAtMs: number;
}
export interface RomPriestChampionHealingEffectEvent {
casterId: string;
occurredAtMs: number;
kind: "direct-cast" | "hot-tick" | "channel-pulse";
}
export interface RomPriestChampionBeaconHeal {
targetId: string;
amount: number;
}
/**
* Creates the source-described 30-second Beacon state. The caller is
* responsible for validating that targetId is a friendly party member.
*/
export function createRomPriestChampionBeaconState(
casterId: string,
targetId: string,
appliedAtMs: number,
): RomPriestChampionBeaconState | null {
if (!casterId.trim() || !targetId.trim() || !Number.isFinite(appliedAtMs)) return null;
return {
casterId,
targetId,
appliedAtMs,
expiresAtMs: appliedAtMs + ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS,
};
}
/**
* Current CoA publishes 770 HP at Beacon's maximum +105 skill level. The
* simulator uses that maximum-row value at every unlocked rank because lower
* skill ranks are not modeled separately. The isolated boundary still takes
* the represented skill level explicitly. It counts one direct-heal cast
* once; CoA does not say whether HoT ticks or channel pulses count.
*/
export function resolveRomPriestChampionBeaconHeal(
beacon: RomPriestChampionBeaconState | null,
event: RomPriestChampionHealingEffectEvent,
skillLevel: number | null,
): RomPriestChampionBeaconHeal | null {
if (skillLevel !== ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SOURCE_SKILL_LEVEL
|| !beacon || event.kind !== "direct-cast" || event.casterId !== beacon.casterId
|| !Number.isFinite(event.occurredAtMs) || event.occurredAtMs < 0
|| !Number.isFinite(beacon.appliedAtMs) || event.occurredAtMs < beacon.appliedAtMs
|| !Number.isFinite(beacon.expiresAtMs) || event.occurredAtMs >= beacon.expiresAtMs) {
return null;
}
return {
targetId: beacon.targetId,
amount: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_HEAL_AT_SKILL_LEVEL_105,
};
}
/** Resolve the active Beacon applied by this caster from shared combat aura state. */
export function resolveRomPriestChampionBeaconFromAuras(
auras: readonly ActiveAura[],
casterId: string,
occurredAtMs: number,
kind: RomPriestChampionHealingEffectEvent["kind"],
): RomPriestChampionBeaconHeal | null {
const activeAura = auras.find((aura) => aura.sourceId === casterId
&& aura.definition.utilityTags?.includes(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG)
&& (aura.expiresAt === null || aura.expiresAt > occurredAtMs));
if (!activeAura) return null;
const beacon: RomPriestChampionBeaconState = {
casterId: activeAura.sourceId,
targetId: activeAura.targetId,
appliedAtMs: activeAura.appliedAt,
expiresAtMs: activeAura.expiresAt
?? activeAura.appliedAt + ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_DURATION_MS,
};
return resolveRomPriestChampionBeaconHeal(beacon, { casterId, occurredAtMs, kind },
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_SOURCE_SKILL_LEVEL);
}
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it } from "vitest";
import { targetingRulesForAbility } from "./abilityRules";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { commitPartyAbilityUse } from "./partyAbilityAi";
import { abilitiesForCharacter } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const HEART_RUNE_ENERGY_ID = 498612;
const BLESSING_OF_HUMILITY_ID = 490301;
const TARGET_DISTANCES = [2, 15, 30, 45, 65, 80] as const;
const OUTSIDE_DISTANCE = 80.1;
const TEST_NOW = 1_000;
const heartRuneEnergy = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === HEART_RUNE_ENERGY_ID
&& ability.classId === "rom-priest" && ability.secondaryClassId === "rom-champion")!;
function registerTarget(id: string, distance: number): void {
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 1_000_000,
level: 1,
xpReward: 0,
});
setMobPosition(id, [distance, 0, 0]);
useCombatStore.getState().damageMob(id, 1, 100);
}
function registerHeartRuneTargets(prefix: string): { insideIds: string[]; outsideId: string } {
const insideIds = TARGET_DISTANCES.map((distance, index) => {
const id = `${prefix}-inside-${index}`;
registerTarget(id, distance);
return id;
});
const outsideId = `${prefix}-outside`;
registerTarget(outsideId, OUTSIDE_DISTANCE);
return { insideIds, outsideId };
}
function abilityDamageTargets(abilityId: string, sourceActorId?: string): string[] {
return useCombatStore.getState().combatEvents
.filter((event) => event.kind === "damage"
&& event.abilityId === abilityId
&& (!sourceActorId || event.sourceActorId === sourceActorId)
&& event.school === "holy"
&& event.effectiveAmount > 0)
.map((event) => event.targetActorId)
.sort();
}
describe("RoM Priest/Champion Heart Rune Energy runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-champion",
raceId: "rom-human",
level: 80,
role: "damage",
});
usePartyStore.getState().initializeParty("rom-priest-heart-rune-energy", 80,
useCombatStore.getState().health, "damage", 2, "rom");
});
it("executes the caster-centered 80-radius Light hit for every catalog-eligible target and reduces Blessing cooldown", () => {
const { insideIds, outsideId } = registerHeartRuneTargets("heart-rune-player");
const blessing = useCombatStore.getState().abilities.find((ability) => (
ability.dbcSpellId === BLESSING_OF_HUMILITY_ID
))!;
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, rage: 50 },
cooldowns: { ...state.cooldowns, [blessing.id]: TEST_NOW + 60_000 },
}));
useCombatStore.getState().selectMob(insideIds[0]!);
const result = useCombatStore.getState().castAbility(heartRuneEnergy.id, undefined, TEST_NOW);
expect(result).toMatchObject({ ok: true });
expect(useCombatStore.getState().resourcePools.rage).toBe(35);
expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(TEST_NOW + 12_000);
expect(useCombatStore.getState().cooldowns[blessing.id]).toBe(TEST_NOW + 45_000);
expect(abilityDamageTargets(heartRuneEnergy.id)).toEqual(insideIds.sort());
expect(abilityDamageTargets(heartRuneEnergy.id)).not.toContain(outsideId);
});
it("executes for generated Priest/Champion companions with the same radius, no configured target cap, and cooldown rider", () => {
const { insideIds, outsideId } = registerHeartRuneTargets("heart-rune-companion");
const seed = "rom-priest-heart-rune-energy-companion";
const blessing = abilitiesForCharacter("rom-priest", "rom-champion")
.find((ability) => ability.dbcSpellId === BLESSING_OF_HUMILITY_ID)!;
const member = createConfiguredPartyMember({
name: "Priest Champion",
classId: "rom-priest",
secondaryClassId: "rom-champion",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
const fundedMember = {
...member,
resourcePools: { ...member.resourcePools, rage: 50 },
cooldowns: { ...member.cooldowns, [blessing.id]: TEST_NOW + 60_000 },
};
const committedMember = commitPartyAbilityUse(fundedMember, heartRuneEnergy, TEST_NOW);
expect(committedMember).not.toBeNull();
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.getState().setCommand("attack");
usePartyStore.setState({ members: [{
...committedMember!,
activeCast: {
abilityId: heartRuneEnergy.id,
school: "holy",
mode: "cast",
startedAt: TEST_NOW,
completesAt: TEST_NOW,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: insideIds[0]!,
},
activeCastTargetId: insideIds[0]!,
status: "attacking",
}] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
for (const id of [...insideIds, outsideId]) updatePartyRuntimeTargetVisibility(member.id, id, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(TEST_NOW);
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!;
expect(updatedMember.resourcePools.rage).toBe(35);
expect(updatedMember.cooldowns[heartRuneEnergy.id]).toBe(TEST_NOW + 12_000);
expect(updatedMember.cooldowns[blessing.id]).toBe(TEST_NOW + 45_000);
expect(abilityDamageTargets(heartRuneEnergy.id, member.id)).toEqual(insideIds.sort());
expect(abilityDamageTargets(heartRuneEnergy.id, member.id)).not.toContain(outsideId);
});
it("keeps the current source radius and leaves target count uncapped because CoA provides no cap", () => {
const targeting = targetingRulesForAbility(heartRuneEnergy);
expect(heartRuneEnergy).toMatchObject({
target: "self",
range: { max: 150 },
radius: 80,
castMode: "instant",
castTimeMs: 0,
cooldownMs: 12_000,
cost: { resource: "rage", amount: 15 },
});
expect(targeting).toMatchObject({ shape: "circle", radius: 80 });
expect(targeting.maximumTargets).toBeUndefined();
expect(heartRuneEnergy.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", coefficient: 9.751, school: "holy" }),
expect.objectContaining({
kind: "cooldown-reset",
mode: "reduce",
abilityName: "Blessing of Humility",
amountMs: 15_000,
}),
]));
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { AbilityDefinition } from "./abilityCatalog";
import {
ROM_PRIEST_CHAMPION_RAGE_CONTROL_PER_COMPLETED_CHANNEL_SECOND,
ROM_PRIEST_WARRIOR_CONDENSED_RAGE_PHYSICAL_DAMAGE_BONUS,
romPriestChampionRageControlGain,
romPriestChampionCooldownOverride,
romPriestVowOfSilenceDurationMs,
romPriestWarriorCondensedRagePhysicalDamageMultiplier,
} from "./romPriestChampionOverrides";
describe("current CoA Priest/Warrior Condensed Rage", () => {
it("adds 22% Physical Damage only when the passive is learned", () => {
expect(ROM_PRIEST_WARRIOR_CONDENSED_RAGE_PHYSICAL_DAMAGE_BONUS).toBe(0.22);
expect(romPriestWarriorCondensedRagePhysicalDamageMultiplier(true)).toBe(1.22);
expect(romPriestWarriorCondensedRagePhysicalDamageMultiplier(false)).toBe(1);
});
});
describe("current CoA Priest/Champion Rage Control", () => {
it("grants 20 Rage for each completed second of channeling", () => {
expect(ROM_PRIEST_CHAMPION_RAGE_CONTROL_PER_COMPLETED_CHANNEL_SECOND).toBe(20);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: 1_000 })).toBe(20);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: 2_000 })).toBe(40);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: 2_500 })).toBe(40);
});
it("does not award partial seconds, ordinary cast time, or Rage Control without the passive", () => {
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: 999 })).toBe(0);
expect(romPriestChampionRageControlGain(true, { kind: "ordinary-cast", durationMs: 3_000 })).toBe(0);
expect(romPriestChampionRageControlGain(false, { kind: "channel", durationMs: 3_000 })).toBe(0);
});
it("returns zero for invalid or non-positive channel durations", () => {
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: 0 })).toBe(0);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: -1 })).toBe(0);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: Number.NaN })).toBe(0);
expect(romPriestChampionRageControlGain(true, { kind: "channel", durationMs: Number.POSITIVE_INFINITY })).toBe(0);
});
it("does not retain the local Infused Light Chain cooldown rider for ID 498610", () => {
const chain = {
source: "runewaker",
classId: "rom-priest",
dbcSpellId: 491164,
} as AbilityDefinition;
expect(romPriestChampionCooldownOverride(chain, [498610])).toBeNull();
});
});
describe("current CoA Priest Vow of Silence", () => {
it("uses the 8.4-second NPC and 4.2-second character durations", () => {
expect(romPriestVowOfSilenceDurationMs(false)).toBe(8_400);
expect(romPriestVowOfSilenceDurationMs(true)).toBe(4_200);
});
});
+50 -8
View File
@@ -1,5 +1,48 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export const ROM_PRIEST_WARRIOR_CONDENSED_RAGE_PHYSICAL_DAMAGE_BONUS = 0.22;
export const ROM_PRIEST_CHAMPION_RAGE_CONTROL_PER_COMPLETED_CHANNEL_SECOND = 20;
export const ROM_PRIEST_CHAMPION_RAGE_CONTROL_CHANNEL_SECOND_MS = 1_000;
export const ROM_PRIEST_VOW_OF_SILENCE_NPC_DURATION_MS = 8_400;
export const ROM_PRIEST_VOW_OF_SILENCE_CHARACTER_DURATION_MS = 4_200;
/** Current CoA Vow of Silence is 8.4s on NPCs and 4.2s on player targets. */
export function romPriestVowOfSilenceDurationMs(characterDefender: boolean): number {
return characterDefender
? ROM_PRIEST_VOW_OF_SILENCE_CHARACTER_DURATION_MS
: ROM_PRIEST_VOW_OF_SILENCE_NPC_DURATION_MS;
}
/** Current CoA Condensed Rage (493038) passively increases Physical Damage by 22%. */
export function romPriestWarriorCondensedRagePhysicalDamageMultiplier(
hasCondensedRage: boolean,
): number {
return hasCondensedRage
? 1 + ROM_PRIEST_WARRIOR_CONDENSED_RAGE_PHYSICAL_DAMAGE_BONUS
: 1;
}
export type RomPriestChampionRageControlProgress =
| { kind: "channel"; durationMs: number }
| { kind: "ordinary-cast"; durationMs: number };
/**
* Current CoA Rage Control (498610) grants 20 Rage per second of channeling.
* Only whole completed channel-seconds pay out; an ordinary cast never counts.
*/
export function romPriestChampionRageControlGain(
learnedRageControl: boolean,
progress: RomPriestChampionRageControlProgress,
): number {
if (!learnedRageControl || progress.kind !== "channel"
|| !Number.isFinite(progress.durationMs) || progress.durationMs <= 0) return 0;
const completedSeconds = Math.floor(
progress.durationMs / ROM_PRIEST_CHAMPION_RAGE_CONTROL_CHANNEL_SECOND_MS,
);
return completedSeconds * ROM_PRIEST_CHAMPION_RAGE_CONTROL_PER_COMPLETED_CHANNEL_SECOND;
}
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";
@@ -23,16 +66,15 @@ const PROTECTION_RUNE_SEAL_REGENERATE_PROC_AURA = {
}],
};
/** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */
/**
* The local Infused Light Chain cooldown rider is not present in current CoA:
* ID 498610 is now Rage Control, so it no longer overrides Chain of Light.
*/
export function romPriestChampionCooldownOverride(
ability: AbilityDefinition,
learnedPassiveSourceIds: readonly number[],
_ability: AbilityDefinition,
_learnedPassiveSourceIds: readonly number[],
): number | null {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-priest"
|| ability.dbcSpellId !== 491164
|| !learnedPassiveSourceIds.includes(498610)) return null;
return 0;
return null;
}
/** Righteous Explosion removes every modeled harmful effect from the party. */
+253
View File
@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore, type ActiveCast } from "./combatStore";
import {
advanceDungeonPartyCombat,
resetPartyRuntimeClock,
} from "./partyRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { abilitiesForCharacter } from "./abilityCatalog";
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
function registerTarget(id: string, characterDefender = false): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
setMobPosition(id, [2, 0, 0]);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[id]: { ...state.mobs[id]!, characterDefender: characterDefender ? { level: 80 } : undefined },
},
}));
useCombatStore.getState().damageMob(id, 1, 100);
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, id, true);
}
}
function channelCast(abilityId: string, targetId: string, startedAt: number): ActiveCast & NonNullable<PartyMember["activeCast"]> {
return {
id: 1,
abilityId,
school: "holy",
mode: "channel",
startedAt,
completesAt: startedAt + 4_000,
originalDurationMs: 4_000,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
groundPosition: null,
totalTicks: 4,
ticksCompleted: 0,
nextTickAt: startedAt + 1_000,
canCastWhileMoving: false,
channelTicksCompleted: 0,
};
}
function createPartyPriestChampion(seed: string, cast: NonNullable<PartyMember["activeCast"]>, targetId: string) {
const member = createConfiguredPartyMember({
name: "Priest Champion",
classId: "rom-priest",
secondaryClassId: "rom-champion",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({ members: [{
...member,
activeCast: cast,
activeCastTargetId: targetId,
status: "attacking",
}] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
return member;
}
function expectSilenceDuration(targetId: string, now: number, durationMs: number): void {
const mob = useCombatStore.getState().mobs[targetId]!;
expect(mob.statuses).toContainEqual(expect.objectContaining({ kind: "silence", endsAt: now + durationMs }));
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
targetId,
expiresAt: now + durationMs,
definition: expect.objectContaining({ name: "Vow of Silence" }),
}));
}
describe("RoM Priest/Champion Rage Control runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-champion",
raceId: "rom-dwarf",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("rom-priest-champion-runtime", 80,
useCombatStore.getState().health, "damage", 2, "rom");
});
it("awards Rage for each full second of the player's actual Chain of Light channel", () => {
const targetId = "priest-champion-rage-control-player";
registerTarget(targetId);
const chain = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491164)!;
expect(chain).toBeDefined();
expect(useCombatStore.getState().castAbility(chain.id, {
targetActorId: targetId,
now: 1_000,
}).ok).toBe(true);
const activeCast = useCombatStore.getState().activeCast!;
expect(activeCast).toMatchObject({ mode: "channel", startedAt: 1_000 });
const expectedRage = Math.floor((activeCast.completesAt - activeCast.startedAt) / 1_000) * 20;
useCombatStore.getState().tick(0, activeCast.completesAt);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(expectedRage).toBeGreaterThan(0);
expect(useCombatStore.getState().resourcePools.rage).toBe(expectedRage);
});
it("awards only completed channel seconds on player interruption and skips ordinary casts or the wrong pair", () => {
const initial = useCombatStore.getState();
const channel = channelCast("rom-priest-primary-9", "unused-target", 1_000);
useCombatStore.setState({ activeCast: channel, resourcePools: { ...initial.resourcePools, rage: 0 } });
expect(useCombatStore.getState().cancelCast("Interrupted", 2_999)).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(20);
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 80, xp: 0,
});
const afterReinitialize = useCombatStore.getState();
useCombatStore.setState({
activeCast: { ...channel, mode: "cast" },
resourcePools: { ...afterReinitialize.resourcePools, rage: 0 },
});
useCombatStore.getState().cancelCast("Ordinary cast", 4_000);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-warrior", raceId: "rom-dwarf", level: 80, xp: 0,
});
const otherPair = useCombatStore.getState();
useCombatStore.setState({
activeCast: channel,
resourcePools: { ...otherPair.resourcePools, rage: 0 },
});
useCombatStore.getState().cancelCast("Wrong pair", 4_000);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
});
it("awards a generated Priest/Champion companion for its completed channel", () => {
const targetId = "priest-champion-rage-control-party-complete";
registerTarget(targetId);
const chain = abilitiesForCharacter("rom-priest", "rom-champion")
.find((ability) => ability.dbcSpellId === 491164)!;
const member = createPartyPriestChampion("priest-champion-rage-control-party-complete",
channelCast(chain.id, targetId, 1_000), targetId);
advanceDungeonPartyCombat(1_000);
advanceDungeonPartyCombat(5_000);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.resourcePools.rage)
.toBe(80);
});
it("awards only full elapsed seconds when a generated companion channel is interrupted", () => {
const targetId = "priest-champion-rage-control-party-interrupted";
registerTarget(targetId);
const chain = abilitiesForCharacter("rom-priest", "rom-champion")
.find((ability) => ability.dbcSpellId === 491164)!;
const member = createPartyPriestChampion("priest-champion-rage-control-party-interrupted",
channelCast(chain.id, targetId, 1_000), targetId);
advanceDungeonPartyCombat(1_000);
clearMobRuntimeRegistry();
advanceDungeonPartyCombat(3_500);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.resourcePools.rage)
.toBe(40);
});
});
describe("RoM Vow of Silence PvP duration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 80, xp: 0,
});
usePartyStore.getState().initializeParty("rom-priest-vow-silence-runtime", 80,
useCombatStore.getState().health, "damage", 2, "rom");
});
it("uses 4.2 seconds for a player target in the player cast path", () => {
const targetId = "vow-player-target";
registerTarget(targetId, true);
const vow = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490275)!;
expect(vow).toBeDefined();
const result = useCombatStore.getState().castAbility(vow.id, { targetActorId: targetId, now: 1_000 });
expect(result.ok).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0, cast.completesAt);
expectSilenceDuration(targetId, cast?.completesAt ?? 1_000, 4_200);
});
it("preserves the 8.4-second NPC duration in the player cast path", () => {
const targetId = "vow-npc-target";
registerTarget(targetId);
const vow = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490275)!;
const result = useCombatStore.getState().castAbility(vow.id, { targetActorId: targetId, now: 1_000 });
expect(result.ok).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0, cast.completesAt);
expectSilenceDuration(targetId, cast?.completesAt ?? 1_000, 8_400);
});
it("uses 4.2 seconds for a player target in the generated companion path", () => {
const targetId = "vow-party-target";
registerTarget(targetId, true);
const vow = abilitiesForCharacter("rom-priest", "rom-champion")
.find((ability) => ability.dbcSpellId === 490275)!;
const member = createConfiguredPartyMember({
name: "Priest Champion",
classId: "rom-priest",
secondaryClassId: "rom-champion",
specialization: "Primary",
role: "damage",
}, 80, 0, "rom-priest-vow-silence-runtime");
usePartyStore.setState({ members: [{
...member,
activeCast: {
abilityId: vow.id,
school: "frost",
mode: "cast",
startedAt: 1_000,
completesAt: 1_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
},
activeCastTargetId: targetId,
status: "attacking",
}] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
expectSilenceDuration(targetId, 1_000, 4_200);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { parseTalentModifiers } from "./talentModifiers";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
function priestSkill(sourceSkillId: number) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === sourceSkillId);
if (!ability) throw new Error(`Missing Priest source skill ${sourceSkillId}`);
return ability;
}
describe("current CoA Priest catalog reworks", () => {
it("models Beacon of Regeneration as one 30-second friendly target mark and preserves local costs", () => {
const ability = priestSkill(498620);
expect(ability).toMatchObject({
name: "Beacon of Regeneration",
classId: "rom-priest",
secondaryClassId: "rom-champion",
target: "friendly",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 30_000,
range: { max: 20 },
cost: { resource: "mana", amount: 29 },
});
expect(ability.costs).toEqual([
{ resource: "mana", amount: 29 },
{ resource: "mana", amount: 1, percentage: true },
]);
expect(ability.effects).toHaveLength(1);
expect(ability.effects[0]).toMatchObject({
kind: "apply-aura",
recipient: "ability-target",
triggerSpellId: 498_620,
aura: {
name: "Beacon of Regeneration",
durationMs: 30_000,
utilityTags: ["rom-priest-champion-beacon-of-regeneration"],
},
});
expect(ability.description).toContain("770 at skill level +105 (the maximum)");
expect(ability.description).toContain("applies this value at every unlocked rank");
expect(ability.ranks?.[0]?.effects).toEqual(ability.effects);
});
it("models 490274 as a passive Wisdom and Light-damage aura", () => {
const ability = priestSkill(490274);
expect(ability).toMatchObject({
name: "Holy Blessing",
passive: true,
target: "self",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
range: { max: 1 },
});
expect(ability.effects).toHaveLength(1);
expect(ability.effects[0]).toMatchObject({
kind: "apply-aura",
recipient: "caster",
triggerSpellId: 490_274,
aura: {
durationMs: null,
},
});
const aura = ability.effects[0].kind === "apply-aura" ? ability.effects[0].aura : undefined;
expect(aura?.modifiers).toEqual(expect.arrayContaining([
{ kind: "stat", stat: "wisdom", operation: "flat", value: 115 },
{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.105 },
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", operation: "percent" }),
]));
const lightDamage = aura?.modifiers?.find((modifier) => modifier.kind === "damage");
if (lightDamage?.kind !== "damage") throw new Error("Missing Holy Blessing Light damage modifier");
expect(lightDamage.value).toBeCloseTo(1.009);
expect(ability.ranks?.[0]?.effects).toEqual(ability.effects);
expect(ability.description).toContain("Wisdom bonus: (current Wisdom + 115) × 10.5% + 115");
expect(ability.description).toContain("Light Damage bonus: +100.9%");
expect(parseTalentModifiers(ability.description)).toEqual([]);
});
it("models Vow of Silence's interrupt and NPC silence duration with current spell data", () => {
const ability = priestSkill(490275);
expect(ability).toMatchObject({
name: "Vow of Silence",
target: "hostile",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 18_000,
range: { max: 150 },
cost: { resource: "mana", amount: 120 },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "interrupt", lockoutMs: 8_400 }),
expect.objectContaining({
kind: "apply-aura",
control: { kind: "silence" },
aura: expect.objectContaining({ durationMs: 8_400 }),
}),
]));
expect(ability.description).toContain("8.4 seconds");
expect(ability.description).toContain("4.2 seconds against players");
expect(ability.ranks?.[0]?.description).toContain("Vow of Silence");
});
it("models Holy Void's flat Light hit and 20-second dual-defense reduction", () => {
const ability = priestSkill(490276);
expect(ability).toMatchObject({
name: "Holy Void",
target: "hostile",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 18_000,
range: { max: 150 },
cost: { resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana" },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", basePoints: 1_655, school: "holy" }),
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
name: "Voidal Infection",
durationMs: 20_000,
modifiers: expect.arrayContaining([
{ kind: "stat", stat: "physical-defense", operation: "percent", value: -0.188 },
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 },
]),
}),
}),
]));
});
it("models Heart Rune Energy's caster-centered damage and cooldown reduction", () => {
const ability = priestSkill(498612);
expect(ability).toMatchObject({
name: "Heart Rune Energy",
target: "self",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 12_000,
range: { max: 150 },
radius: 80,
targeting: { shape: "circle", radius: 80 },
cost: { resource: "rage", amount: 15 },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", coefficient: 9.751, school: "holy" }),
expect.objectContaining({
kind: "cooldown-reset",
mode: "reduce",
abilityName: "Blessing of Humility",
amountMs: 15_000,
}),
]));
expect(ability.description).toContain("975.1% CPS Light Damage");
expect(ability.description).toContain("15 seconds");
expect(ability.ranks?.[0]?.effects).toEqual(ability.effects);
});
});
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore, equipmentAdjustedCombatAmount } from "./combatStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_PRIEST_HOLY_BLESSING_TAG, romPriestHolyBlessingStats } from "./romPriestHolyBlessingRuntime";
import { resolveDamageValue } from "./combatAuras";
import { deriveCharacterStats } from "./wotlkStats";
const spell = abilityById("rom-priest-general-1");
const spellEffect = spell?.effects.find((effect) => effect.kind === "damage");
function damageWithSchool(targetId: string, sourceActorId: string, school: "holy" | "nature", now: number) {
return useCombatStore.getState().damageMob(
targetId,
1_000,
now,
{ actorId: sourceActorId, role: "damage" },
school,
{
attackKind: "spell",
seed: `holy-blessing:${sourceActorId}:${school}:${now}`,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
},
).amount;
}
describe("RoM Priest Holy Blessing combat integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 80,
role: "healer",
});
usePartyStore.getState().initializeParty(
"rom-priest-holy-blessing-integration", 80, useCombatStore.getState().health, "healer", 2, "rom",
);
for (const id of ["holy-blessing-holy-target", "holy-blessing-nature-target"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, level: 80 });
setMobPosition(id, [3, 0, 0]);
}
});
it("adds the Wisdom-derived spell power to a real player spell amount and buffs only Holy damage", () => {
if (!spell || spellEffect?.kind !== "damage") throw new Error("Missing generated Priest spell damage row");
const state = useCombatStore.getState();
const passiveAura = state.auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG));
expect(passiveAura).toBeDefined();
const base = deriveCharacterStats("rom-priest", 80, state.gearStats, state.raceId, {}, state.secondaryClassId);
const bonus = romPriestHolyBlessingStats(base, state.auras).spellPowerBonus;
const expectedAmount = equipmentAdjustedCombatAmount(spellEffect, 80, state.gearStats, "rom-priest", spell, bonus);
const unblessedAmount = equipmentAdjustedCombatAmount(spellEffect, 80, state.gearStats, "rom-priest", spell);
const playerSourceAuras = state.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
const expectedCombatAmount = resolveDamageValue(expectedAmount, playerSourceAuras, {
direction: "dealt", school: "frost", attackKind: "spell",
});
const unblessedCombatAmount = resolveDamageValue(
unblessedAmount,
playerSourceAuras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)),
{ direction: "dealt", school: "frost", attackKind: "spell" },
);
expect(bonus).toBeGreaterThan(0);
expect(expectedCombatAmount).toBeGreaterThan(unblessedCombatAmount);
useCombatStore.getState().selectMob("holy-blessing-nature-target");
expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(3, 5_000);
const damage = useCombatStore.getState().combatEvents.find((event) => event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID && event.abilityId === spell.id);
expect(damage).toBeDefined();
expect(damage?.rawAmount).toBe(Math.round(expectedCombatAmount * (damage?.critical ? 1.5 : 1)));
const holy = damageWithSchool("holy-blessing-holy-target", PLAYER_AGGRO_ID, "holy", 21_000);
const nature = damageWithSchool("holy-blessing-nature-target", PLAYER_AGGRO_ID, "nature", 22_000);
expect(holy).toBe(2_009);
expect(nature).toBe(1_000);
});
it("adds the Wisdom-derived spell power to a generated-companion spell and buffs only Holy damage", () => {
if (!spell || spellEffect?.kind !== "damage") throw new Error("Missing generated Priest spell damage row");
const seed = "rom-priest-holy-blessing-companion";
const priest = createConfiguredPartyMember({
name: "Holy Blessing Priest",
classId: "rom-priest",
specialization: "Primary",
role: "healer",
}, 80, 0, seed);
usePartyStore.setState({ members: [{
...priest,
status: "attacking",
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...priest.resourcePools, mana: 10_000 },
activeCast: {
abilityId: spell.id,
school: "frost",
mode: "cast",
startedAt: 500,
completesAt: 1_000,
originalDurationMs: 500,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: "holy-blessing-nature-target",
},
activeCastTargetId: "holy-blessing-nature-target",
}] });
registerPartyRuntimePosition(priest.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(priest.id, "holy-blessing-nature-target", true);
usePartyStore.getState().setCommand("attack");
useCombatStore.getState().synchronizeActors();
const beforeCastAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === priest.id);
expect(beforeCastAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)))
.toBe(false);
advanceDungeonPartyCombat(1_000);
const state = useCombatStore.getState();
const companionAura = state.auras.filter((aura) => aura.targetId === priest.id);
expect(companionAura.some((aura) => aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)))
.toBe(true);
const base = deriveCharacterStats(
priest.classId, priest.level, priest.gearStats, priest.raceId, {}, priest.secondaryClassId,
);
const bonus = romPriestHolyBlessingStats(base, companionAura).spellPowerBonus;
const expectedAmount = equipmentAdjustedCombatAmount(spellEffect, 80, priest.gearStats, "rom-priest", spell, bonus);
const unblessedAmount = equipmentAdjustedCombatAmount(spellEffect, 80, priest.gearStats, "rom-priest", spell);
expect(bonus).toBeGreaterThan(0);
expect(expectedAmount).toBeGreaterThan(unblessedAmount);
const damage = state.combatEvents.find((event) => event.kind === "damage"
&& event.sourceActorId === priest.id && event.abilityId === spell.id);
expect(damage).toBeDefined();
expect(damage?.rawAmount).toBe(expectedAmount);
const holy = damageWithSchool("holy-blessing-holy-target", priest.id, "holy", 5_000);
const nature = damageWithSchool("holy-blessing-nature-target", priest.id, "nature", 6_000);
expect(holy).toBe(2_009);
expect(nature).toBe(1_000);
});
});
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { applyAura } from "./combatAuras";
import {
ROM_PRIEST_HOLY_BLESSING,
ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT,
ROM_PRIEST_HOLY_BLESSING_TAG,
romPriestHolyBlessingEffects,
romPriestHolyBlessingStats,
romPriestHolyBlessingWisdom,
} from "./romPriestHolyBlessingRuntime";
const holyBlessing = {
classId: "rom-priest",
sourceSkillId: 490_274,
name: "Holy Blessing",
description: "Current CoA Wisdom and Light damage passive.",
passive: true,
};
describe("RoM Priest Holy Blessing", () => {
it("applies the current-Wisdom formula as one flat-then-percent stat stage", () => {
expect(ROM_PRIEST_HOLY_BLESSING).toEqual({
spellId: 490_274,
wisdomFlat: 115,
wisdomPercent: 10.5,
lightDamagePercent: 100.9,
});
expect(romPriestHolyBlessingWisdom(0)).toBeCloseTo((0 + 115) * 1.105);
expect(romPriestHolyBlessingWisdom(320)).toBeCloseTo((320 + 115) * 1.105);
expect(romPriestHolyBlessingWisdom(-20)).toBeCloseTo(115 * 1.105);
expect(romPriestHolyBlessingWisdom(Number.NaN)).toBeCloseTo(115 * 1.105);
});
it("resolves other Wisdom buffs first and returns only the incremental derived spell power", () => {
const blessingAura = ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT.aura;
const existingWisdomAura = {
id: "test:wisdom-buff",
name: "Existing Wisdom buff",
disposition: "buff" as const,
durationMs: null,
modifiers: [{ kind: "stat" as const, stat: "wisdom", operation: "percent" as const, value: 0.12 }],
};
const spellPowerAura = {
id: "test:spell-power-buff",
name: "Existing spell power buff",
disposition: "buff" as const,
durationMs: null,
modifiers: [{ kind: "stat" as const, stat: "spell-power", operation: "percent" as const, value: 0.5 }],
};
const auraFor = (definition: typeof blessingAura | typeof existingWisdomAura | typeof spellPowerAura) => (
applyAura([], { definition, sourceId: "priest", targetId: "priest", now: 0 }).aura
);
const blessing = auraFor(blessingAura);
const wisdomBuff = auraFor(existingWisdomAura);
const spellPowerBuff = auraFor(spellPowerAura);
const withoutSpellPowerAura = romPriestHolyBlessingStats(
{ intellect: 200, spirit: 100 }, [blessing, wisdomBuff],
);
const withSpellPowerAura = romPriestHolyBlessingStats(
{ intellect: 200, spirit: 100 }, [blessing, wisdomBuff, spellPowerBuff],
);
expect(withSpellPowerAura.currentWisdom).toBeCloseTo(100 * 1.12);
expect(withSpellPowerAura.wisdom).toBeCloseTo((100 * 1.12 + 115) * 1.105);
expect(withSpellPowerAura.spellPowerBonus).toBe(
Math.round(200 * 0.35 + withSpellPowerAura.wisdom * 0.15)
- Math.round(200 * 0.35 + withSpellPowerAura.currentWisdom * 0.15),
);
expect(withSpellPowerAura.spellPowerBonus).toBe(withoutSpellPowerAura.spellPowerBonus);
});
it("defines a permanent caster aura with Wisdom and Light-only damage modifiers", () => {
const effect = ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT;
expect(effect).toMatchObject({
kind: "apply-aura",
recipient: "caster",
triggerSpellId: 490_274,
aura: {
id: ROM_PRIEST_HOLY_BLESSING_TAG,
durationMs: null,
maxStacks: 1,
utilityTags: [ROM_PRIEST_HOLY_BLESSING_TAG],
modifiers: [
{ kind: "stat", stat: "wisdom", operation: "flat", value: 115 },
{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.105 },
{ kind: "damage", direction: "dealt", school: "holy", operation: "percent" },
],
},
});
const lightDamage = effect.aura.modifiers?.find((modifier) => modifier.kind === "damage");
if (lightDamage?.kind !== "damage") throw new Error("Missing Holy Blessing Light damage modifier");
expect(lightDamage.value).toBeCloseTo(1.009);
});
it("only replaces the Priest primary spell's generated effects", () => {
expect(romPriestHolyBlessingEffects(holyBlessing)).toEqual([ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT]);
expect(romPriestHolyBlessingEffects({ ...holyBlessing, secondaryClassId: "rom-mage" })).toBeNull();
expect(romPriestHolyBlessingEffects({ ...holyBlessing, classId: "rom-mage" })).toBeNull();
expect(romPriestHolyBlessingEffects({ ...holyBlessing, sourceSkillId: 490_275 })).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { AbilityEffect } from "./abilityCatalog";
import { resolveDamageValue, resolveStatValue, type ActiveAura, type DamageSchool } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_PRIEST_HOLY_BLESSING = Object.freeze({
spellId: 490_274,
wisdomFlat: 115,
wisdomPercent: 10.5,
lightDamagePercent: 100.9,
});
export const ROM_PRIEST_HOLY_BLESSING_TAG = "rom-priest:490274:holy-blessing";
/** Resolve CoA's `(current Wisdom + 115) × 10.5% + 115` stat stage. */
export function romPriestHolyBlessingWisdom(currentWisdom: number): number {
const wisdom = Number.isFinite(currentWisdom) ? Math.max(0, currentWisdom) : 0;
return (wisdom + ROM_PRIEST_HOLY_BLESSING.wisdomFlat)
* (1 + ROM_PRIEST_HOLY_BLESSING.wisdomPercent / 100);
}
export interface RomPriestHolyBlessingStats {
readonly intellect: number;
readonly currentWisdom: number;
readonly wisdom: number;
readonly spellPowerBonus: number;
readonly active: boolean;
}
/**
* Resolve existing primary-stat modifiers first, then apply Holy Blessing to
* that current Wisdom. Its spell-power bonus is only the incremental Spirit
* conversion; generic spell-power aura modifiers remain on their normal path.
*/
export function romPriestHolyBlessingStats(
base: { readonly intellect: number; readonly spirit: number },
auras: readonly ActiveAura[],
): RomPriestHolyBlessingStats {
const active = auras.some((aura) => aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG));
const existingAuras = active
? auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG))
: auras;
const primary = (value: number, ...names: string[]) => names.reduce(
(current, stat) => resolveStatValue(current, existingAuras, stat),
value,
);
const intellect = primary(base.intellect, "intellect", "all-primary", "all-primary-attributes");
const currentWisdom = primary(base.spirit, "spirit", "wisdom", "all-primary", "all-primary-attributes");
const wisdom = active ? romPriestHolyBlessingWisdom(currentWisdom) : currentWisdom;
const spellPowerBonus = active
? Math.round(intellect * 0.35 + wisdom * 0.15)
- Math.round(intellect * 0.35 + currentWisdom * 0.15)
: 0;
return { intellect, currentWisdom, wisdom, spellPowerBonus, active };
}
/** Apply Holy Blessing's source-only Light/Holy damage modifier without widening its school scope. */
export function romPriestHolyBlessingDamage(
amount: number,
auras: readonly ActiveAura[],
school: DamageSchool,
): number {
const blessingAuras = auras.filter((aura) => aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG));
return resolveDamageValue(amount, blessingAuras, { direction: "dealt", school, attackKind: "spell" });
}
export const ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
kind: "apply-aura",
recipient: "caster",
aura: {
id: ROM_PRIEST_HOLY_BLESSING_TAG,
name: "Holy Blessing",
description: "Increases Wisdom by (current Wisdom + 115) × 10.5% + 115 and Light Damage by 100.9%.",
disposition: "buff",
durationMs: null,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [ROM_PRIEST_HOLY_BLESSING_TAG],
modifiers: [
{
kind: "stat",
stat: "wisdom",
operation: "flat",
value: ROM_PRIEST_HOLY_BLESSING.wisdomFlat,
},
{
kind: "stat",
stat: "wisdom",
operation: "percent",
value: ROM_PRIEST_HOLY_BLESSING.wisdomPercent / 100,
},
{
kind: "damage",
direction: "dealt",
school: "holy",
operation: "percent",
value: ROM_PRIEST_HOLY_BLESSING.lightDamagePercent / 100,
},
],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: ROM_PRIEST_HOLY_BLESSING.wisdomPercent,
triggerSpellId: ROM_PRIEST_HOLY_BLESSING.spellId,
};
/** Holy Blessing belongs only to the Priest primary skill set. */
export function romPriestHolyBlessingEffects(
row: RomGeneratedAbilityLike,
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-priest"
|| row.secondaryClassId !== undefined
|| row.sourceSkillId !== ROM_PRIEST_HOLY_BLESSING.spellId) return null;
return [ROM_PRIEST_HOLY_BLESSING_PASSIVE_EFFECT];
}
@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById, type AbilityDefinition } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_PRIEST_HOLY_CANDLE_TAG } from "./romPriestHolyCandleRuntime";
const candle = abilityById("rom-priest-primary-17")!;
const heal = abilityById("rom-priest-primary-2")!;
const groupHeal = abilityById("rom-priest-primary-8")!;
const divineHymn = abilityById("wow335-priest-divine-hymn")!;
function setPlayerMana(amount: number): void {
useCombatStore.setState((state) => ({
resource: amount,
maxResource: amount,
maxResourcePools: { ...state.maxResourcePools, mana: amount },
resourcePools: { ...state.resourcePools, mana: amount },
}));
}
function candleSelfDamageEvents(ownerActorId = PLAYER_AGGRO_ID) {
return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.sourceActorId === ownerActorId && event.targetActorId === ownerActorId
&& [heal.id, groupHeal.id, divineHymn.id, "rom-priest-test-holy-candle-hot"].includes(event.abilityId ?? ""));
}
function partyMember(name: string, classId: "rom-priest" | "rom-warrior", role: "healer" | "tank", index: number) {
return createConfiguredPartyMember({
name,
classId,
raceId: "rom-human",
specialization: "Primary",
role,
}, 80, index, "rom-priest-holy-candle-integration");
}
describe("RoM Priest Holy Candle combat integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 80,
role: "healer",
});
usePartyStore.getState().initializeParty(
"rom-priest-holy-candle-integration", 80, useCombatStore.getState().health, "healer", 2, "rom",
);
usePartyStore.getState().setCommand("stop");
setPlayerMana(10_000);
});
it("charges once for an accepted direct heal even when it overheals its full-health target", () => {
const target = partyMember("Full-health heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [target] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_CANDLE_TAG))).toBe(true);
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState({ health: maxHealth });
const healCast = useCombatStore.getState().castAbility(heal.id, {
targetActorId: target.id,
now: 3_000,
});
expect(healCast).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 5_000);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(candleSelfDamageEvents()[0]).toMatchObject({
rawAmount: Math.round(maxHealth * 0.12),
effectiveAmount: Math.round(maxHealth * 0.12),
});
expect(useCombatStore.getState().health).toBe(maxHealth - Math.round(maxHealth * 0.12));
});
it("charges once before Group Heal fans out to multiple recipients", () => {
const first = partyMember("Candle ally one", "rom-warrior", "tank", 0);
const second = partyMember("Candle ally two", "rom-warrior", "tank", 1);
usePartyStore.setState({ members: [first, second].map((member) => ({
...member,
health: Math.max(1, Math.floor(member.maxHealth * 0.45)),
})) });
registerPartyRuntimePosition(first.id, [3, 0, 0]);
registerPartyRuntimePosition(second.id, [5, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
const before = usePartyStore.getState().members.map((member) => member.health);
expect(useCombatStore.getState().castAbility(groupHeal.id, undefined, 3_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(3, 6_000);
const after = usePartyStore.getState().members.map((member) => member.health);
expect(after[0]).toBeGreaterThan(before[0]!);
expect(after[1]).toBeGreaterThan(before[1]!);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(candleSelfDamageEvents()[0]?.abilityId).toBe(groupHeal.id);
});
it("does not charge while inactive and shares the one-second limit across heal spell IDs per owner", () => {
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, heal.id, 1_000)).toBe(0);
expect(useCombatStore.getState().castAbility(candle.id, undefined, 2_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, heal.id, 3_000)).toBeGreaterThan(0);
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, groupHeal.id, 3_999)).toBe(0);
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, groupHeal.id, 4_000)).toBeGreaterThan(0);
expect(candleSelfDamageEvents()).toHaveLength(2);
const companion = partyMember("Candle Priest", "rom-priest", "healer", 0);
usePartyStore.setState({ members: [companion] });
registerPartyRuntimePosition(companion.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
const aura = candle.effects.find((effect) => effect.kind === "apply-aura");
if (!aura || aura.kind !== "apply-aura") throw new Error("Holy Candle aura effect is missing");
useCombatStore.getState().applyActorAura(companion.id, companion.id, candle, aura, 2_000);
// The throttle is per owner: this Priest can charge at the same timestamp as the player.
expect(useCombatStore.getState().triggerRomPriestHolyCandle(companion.id, heal.id, 4_000)).toBeGreaterThan(0);
expect(candleSelfDamageEvents(companion.id)).toHaveLength(1);
});
it("does not charge an invalid or interrupted heal cast", () => {
const target = partyMember("Interrupted heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [target] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility(heal.id, {
targetActorId: "missing-friendly-target",
now: 3_000,
})).toMatchObject({ ok: false, reason: "target-unavailable" });
expect(candleSelfDamageEvents()).toHaveLength(0);
expect(useCombatStore.getState().castAbility(heal.id, {
targetActorId: target.id,
now: 3_000,
})).toMatchObject({ ok: true });
expect(useCombatStore.getState().cancelCast("test interruption", 3_500)).toBe(true);
useCombatStore.getState().tick(2, 5_000);
expect(candleSelfDamageEvents()).toHaveLength(0);
});
it("charges once when a HoT is applied and never again on its periodic healing ticks", () => {
const target = partyMember("Periodic heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [{ ...target, health: 1 }] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
const hotAbility: AbilityDefinition = {
...heal,
id: "rom-priest-test-holy-candle-hot",
dbcSpellId: 990_304,
name: "Holy Candle HoT regression fixture",
target: "friendly",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
costs: [],
cost: { resource: "mana", amount: 0 },
ranks: undefined,
effects: [{
kind: "hot",
coefficient: 0,
basePoints: 100,
ticks: 3,
intervalMs: 1_000,
}],
};
useCombatStore.setState((state) => ({ abilities: [...state.abilities, hotAbility] }));
expect(useCombatStore.getState().castAbility(hotAbility.id, {
targetActorId: target.id,
now: 3_000,
})).toMatchObject({ ok: true });
expect(candleSelfDamageEvents()).toHaveLength(1);
useCombatStore.getState().tick(1, 4_000);
useCombatStore.getState().tick(1, 5_000);
useCombatStore.getState().tick(1, 6_000);
expect(candleSelfDamageEvents()).toHaveLength(1);
});
it("charges a generated Priest companion once when its accepted heal cast resolves", () => {
const caster = partyMember("Holy Candle Priest", "rom-priest", "healer", 0);
const target = partyMember("Candle heal target", "rom-warrior", "tank", 1);
usePartyStore.setState({ members: [
{
...caster,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...caster.resourcePools, mana: 10_000 },
activeCast: {
abilityId: heal.id,
school: "holy",
mode: "cast",
startedAt: 1_000,
completesAt: 2_000,
originalDurationMs: 1_000,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: target.id,
},
activeCastTargetId: target.id,
status: "healing",
},
{ ...target, health: 1 },
] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
const aura = candle.effects.find((effect) => effect.kind === "apply-aura");
if (!aura || aura.kind !== "apply-aura") throw new Error("Holy Candle aura effect is missing");
useCombatStore.getState().applyActorAura(caster.id, caster.id, candle, aura, 1_000);
const maxHealth = caster.maxHealth;
advanceDungeonPartyCombat(2_000);
expect(candleSelfDamageEvents(caster.id)).toHaveLength(1);
expect(candleSelfDamageEvents(caster.id)[0]).toMatchObject({
abilityId: heal.id,
rawAmount: Math.round(maxHealth * 0.12),
effectiveAmount: Math.round(maxHealth * 0.12),
});
expect(usePartyStore.getState().members.find((member) => member.id === caster.id)?.health)
.toBe(maxHealth - Math.round(maxHealth * 0.12));
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health).toBeGreaterThan(1);
});
it("persists the channel-cast guard across pulses more than one second apart", () => {
const durationMs = divineHymn.castTimeMs;
const startedAt = 10_000;
const totalTicks = channelPulseCount(divineHymn);
const nextTickAt = channelPulseAt(divineHymn, startedAt, durationMs, 0);
expect(divineHymn.castMode).toBe("channel");
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.setState((state) => ({
health: Math.floor(state.maxHealth * 0.5),
activeCast: {
id: 9001,
abilityId: divineHymn.id,
targetId: null,
mode: "channel",
startedAt,
completesAt: startedAt + durationMs,
originalDurationMs: durationMs,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks,
ticksCompleted: 0,
nextTickAt,
},
}));
useCombatStore.getState().tick(0, nextTickAt);
expect(useCombatStore.getState().activeCast?.romPriestHolyCandleCharged).toBe(true);
const secondPulseAt = channelPulseAt(divineHymn, startedAt, durationMs, 1);
expect(secondPulseAt - nextTickAt).toBeGreaterThan(1_000);
useCombatStore.getState().tick(0, secondPulseAt);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(useCombatStore.getState().activeCast?.romPriestHolyCandleCharged).toBe(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_PRIEST_HOLY_CANDLE,
ROM_PRIEST_HOLY_CANDLE_COST,
ROM_PRIEST_HOLY_CANDLE_TAG,
romPriestHolyCandleCanChargeAt,
romPriestHolyCandleEffects,
romPriestHolyCandleIsActive,
romPriestHolyCandleSelfDamageAmount,
} from "./romPriestHolyCandleRuntime";
import { romPriestDescription } from "./romPriestTooltips";
const holyCandle = ROM_ALL_ABILITIES.find((ability) => (
ability.classId === "rom-priest"
&& ability.secondaryClassId === undefined
&& ability.dbcSpellId === ROM_PRIEST_HOLY_CANDLE.spellId
));
describe("current CoA Priest Holy Candle", () => {
it("uses the sourced healing bonus, distinct 5% Mana activation cost, and local 60-second duration", () => {
expect(holyCandle).toBeDefined();
expect(holyCandle?.range.max).toBe(1);
expect(holyCandle?.costs).toEqual([ROM_PRIEST_HOLY_CANDLE_COST]);
expect(holyCandle?.cooldownMs).toBe(ROM_PRIEST_HOLY_CANDLE.cooldownMs);
expect(holyCandle?.costs).toEqual([{
resource: "mana",
amount: 5,
percentage: true,
}]);
expect(holyCandle?.effects).toHaveLength(1);
const auraEffect = holyCandle?.effects[0];
expect(auraEffect?.kind).toBe("apply-aura");
if (auraEffect?.kind !== "apply-aura") throw new Error("Holy Candle aura is missing");
expect(auraEffect.aura).toMatchObject({
id: ROM_PRIEST_HOLY_CANDLE_TAG,
name: "Holy Candle",
durationMs: 60_000,
utilityTags: [ROM_PRIEST_HOLY_CANDLE_TAG],
modifiers: [{
kind: "healing",
direction: "done",
operation: "percent",
value: 0.27,
}],
});
expect(holyCandle?.description).toContain("Costs 5% Mana");
expect(holyCandle?.description).toContain("Cooldown: 30 seconds");
expect(holyCandle?.description).toContain("27%");
expect(holyCandle?.description).toContain("12% of your maximum health");
expect(holyCandle?.description).toContain("once per second");
expect(holyCandle?.ranks?.[0]?.costs).toEqual(holyCandle?.costs);
expect(holyCandle?.ranks?.[0]?.effects).toEqual(holyCandle?.effects);
expect(romPriestDescription(490_304, "old text")).toBe(holyCandle?.description);
});
it("applies only to the Priest-primary row and removes the old periodic drain effect", () => {
const localAura = holyCandle?.effects.find((effect) => effect.kind === "apply-aura");
expect(localAura).toBeDefined();
if (!localAura || localAura.kind !== "apply-aura") throw new Error("Holy Candle aura is missing");
const periodic = { kind: "dot", coefficient: 0, basePoints: 6, ticks: 3, intervalMs: 1_000 } as const;
expect(romPriestHolyCandleEffects({
classId: "rom-priest",
sourceSkillId: 490_304,
name: "Holy Candle",
description: "local row",
}, [localAura, periodic])).toEqual([localAura]);
expect(romPriestHolyCandleEffects({
classId: "rom-priest",
secondaryClassId: "rom-knight",
sourceSkillId: 490_304,
name: "Holy Candle",
description: "different pair",
}, [localAura, periodic])).toBeNull();
expect(romPriestHolyCandleEffects({
classId: "rom-knight",
sourceSkillId: 490_304,
name: "Holy Candle",
description: "different class",
}, [localAura, periodic])).toBeNull();
});
it("replaces only the local healing-done modifier and preserves other aura modifiers", () => {
const localAura = holyCandle?.effects.find((effect) => effect.kind === "apply-aura");
expect(localAura?.kind).toBe("apply-aura");
if (localAura?.kind !== "apply-aura") throw new Error("Holy Candle aura is missing");
const alreadyTranslated = {
...localAura,
aura: {
...localAura.aura,
modifiers: [
{ kind: "healing", direction: "done", operation: "percent", value: 0.05 } as const,
{ kind: "stat", stat: "wisdom", operation: "flat", value: 2 } as const,
],
},
};
const effects = romPriestHolyCandleEffects({
classId: "rom-priest",
sourceSkillId: 490_304,
name: "Holy Candle",
description: "local row",
}, [alreadyTranslated]);
const aura = effects?.[0];
expect(aura?.kind).toBe("apply-aura");
if (aura?.kind !== "apply-aura") throw new Error("Holy Candle aura is missing");
expect(aura.aura.modifiers).toEqual([
{ kind: "healing", direction: "done", operation: "percent", value: 0.27 },
{ kind: "stat", stat: "wisdom", operation: "flat", value: 2 },
]);
});
it("charges 12% of maximum HP only while active and no more than once each second", () => {
expect(romPriestHolyCandleIsActive([{ definition: { utilityTags: [ROM_PRIEST_HOLY_CANDLE_TAG] } }])).toBe(true);
expect(romPriestHolyCandleIsActive([{ definition: { utilityTags: ["unrelated"] } }])).toBe(false);
expect(romPriestHolyCandleIsActive([])).toBe(false);
expect(romPriestHolyCandleCanChargeAt(2_000, null)).toBe(true);
expect(romPriestHolyCandleCanChargeAt(2_999, 2_000)).toBe(false);
expect(romPriestHolyCandleCanChargeAt(3_000, 2_000)).toBe(true);
expect(romPriestHolyCandleCanChargeAt(1_999, 2_000)).toBe(false);
expect(romPriestHolyCandleCanChargeAt(Number.NaN, null)).toBe(false);
expect(romPriestHolyCandleSelfDamageAmount(1_000, true, 3_000, 2_000)).toBe(120);
expect(romPriestHolyCandleSelfDamageAmount(1_000, false, 3_000, 2_000)).toBe(0);
expect(romPriestHolyCandleSelfDamageAmount(1_000, true, 2_999, 2_000)).toBe(0);
expect(romPriestHolyCandleSelfDamageAmount(1_000, true, 3_000, 2_000)).toBe(
1_000 * ROM_PRIEST_HOLY_CANDLE.selfDamagePercent / 100,
);
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { AbilityCost, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_PRIEST_HOLY_CANDLE = Object.freeze({
spellId: 490_304,
healingEffectivenessPercent: 27,
selfDamagePercent: 12,
auraDurationMs: 60_000,
cooldownMs: 30_000,
selfDamageIntervalMs: 1_000,
});
export const ROM_PRIEST_HOLY_CANDLE_TAG = "rom-priest:490304:holy-candle";
/** CoA activation cost; separate from the 12% HP self-damage trigger. */
export const ROM_PRIEST_HOLY_CANDLE_COST: AbilityCost = Object.freeze({
resource: "mana",
amount: 5,
percentage: true,
});
export const ROM_PRIEST_HOLY_CANDLE_DESCRIPTION = "Costs 5% Mana. Cooldown: 30 seconds. Increases healing effectiveness by 27% for 60 seconds. Casting a healing spell consumes 12% of your maximum health, at most once per second.";
type ApplyAuraEffect = Extract<AbilityEffect, { readonly kind: "apply-aura" }>;
/** Replace the old RuneWaker +5% modifier with the exact current CoA bonus. */
export function romPriestHolyCandleAura(effect: ApplyAuraEffect): ApplyAuraEffect {
let replacedHealingModifier = false;
const modifiers: AuraModifier[] = (effect.aura.modifiers ?? []).map((modifier) => {
if (modifier.kind === "healing" && modifier.direction === "done"
&& modifier.operation === "percent") {
replacedHealingModifier = true;
return { ...modifier, value: ROM_PRIEST_HOLY_CANDLE.healingEffectivenessPercent / 100 };
}
return modifier;
});
if (!replacedHealingModifier) {
modifiers.push({
kind: "healing",
direction: "done",
operation: "percent",
value: ROM_PRIEST_HOLY_CANDLE.healingEffectivenessPercent / 100,
});
}
return {
...effect,
aura: {
...effect.aura,
id: ROM_PRIEST_HOLY_CANDLE_TAG,
name: "Holy Candle",
description: ROM_PRIEST_HOLY_CANDLE_DESCRIPTION,
durationMs: ROM_PRIEST_HOLY_CANDLE.auraDurationMs,
utilityTags: [...new Set([
...(effect.aura.utilityTags ?? []).filter((tag) => (
tag !== "rom-self-health-drain" && tag !== ROM_PRIEST_HOLY_CANDLE_TAG
)),
ROM_PRIEST_HOLY_CANDLE_TAG,
])],
modifiers,
},
};
}
/** Current CoA Holy Candle is a Priest-primary skill; same-number pair rows do not inherit it. */
export function romPriestHolyCandleEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-priest" || row.secondaryClassId !== undefined
|| row.sourceSkillId !== ROM_PRIEST_HOLY_CANDLE.spellId) return null;
const aura = translated.find((effect): effect is ApplyAuraEffect => effect.kind === "apply-aura");
return aura ? [romPriestHolyCandleAura(aura)] : null;
}
export function romPriestHolyCandleIsActive(
auras: readonly { readonly definition: Pick<AuraDefinition, "utilityTags"> }[],
): boolean {
return auras.some((aura) => aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_CANDLE_TAG));
}
/** The official one-second self-hit throttle applies to accepted healing casts. */
export function romPriestHolyCandleCanChargeAt(nowMs: number, lastChargeAtMs: number | null): boolean {
if (!Number.isFinite(nowMs)) return false;
if (lastChargeAtMs === null || !Number.isFinite(lastChargeAtMs)) return true;
return nowMs - lastChargeAtMs >= ROM_PRIEST_HOLY_CANDLE.selfDamageIntervalMs;
}
/** Return the max-health-based self-damage amount, or zero when inactive/throttled. */
export function romPriestHolyCandleSelfDamageAmount(
maxHealth: number,
active: boolean,
nowMs: number,
lastChargeAtMs: number | null,
): number {
if (!active || !romPriestHolyCandleCanChargeAt(nowMs, lastChargeAtMs)) return 0;
const health = Number.isFinite(maxHealth) ? Math.max(0, maxHealth) : 0;
return Math.round(health * ROM_PRIEST_HOLY_CANDLE.selfDamagePercent / 100);
}
+173
View File
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { presentationSchoolForAbility } from "./combatPresentation";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
const HOLY_VOID_ID = 490276;
const holyVoid = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === HOLY_VOID_ID
&& ability.classId === "rom-priest" && !ability.secondaryClassId)!;
const targetId = "holy-void-runtime-target";
describe("RoM Priest Holy Void runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", level: 80, role: "damage" });
useCombatStore.getState().registerMob(targetId, {
name: "Voidal Infection target",
maxHealth: 1_000_000,
level: 80,
resistances: { nature: 10_000 },
});
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, armor: 5_000 } },
}));
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.getState().selectMob(targetId);
});
it("normalizes only Holy Void's physical-defense alias to armor", () => {
expect(presentationSchoolForAbility(holyVoid)).toBe("holy");
const mapped = romPriestKnightCastEffects(holyVoid, holyVoid.effects, [], 1_000);
const debuff = mapped.find((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-holy-void");
expect(mapped.find((effect) => effect.kind === "damage")).toMatchObject({
kind: "damage", basePoints: 1_655, school: "holy",
});
expect(debuff).toMatchObject({
kind: "apply-aura",
recipient: "ability-target",
aura: {
name: "Voidal Infection",
disposition: "debuff",
durationMs: 20_000,
modifiers: [
{ kind: "stat", stat: "armor", operation: "percent", value: -0.188 },
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 },
],
},
});
const pairedIdentity = romPriestKnightCastEffects(
{ ...holyVoid, secondaryClassId: "rom-knight" }, holyVoid.effects, [], 1_000,
);
const pairedPhysicalModifier = pairedIdentity.find((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-holy-void");
expect(pairedPhysicalModifier?.kind === "apply-aura"
? pairedPhysicalModifier.aura.modifiers?.find((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-defense")
: undefined).toMatchObject({ kind: "stat", stat: "physical-defense" });
});
it("applies the sourced debuff to NPCs: physical armor and modeled magic resistance fall, Light stays armor-independent", () => {
const sampleDamage = (
school: "physical" | "nature" | "holy",
attackKind: "melee" | "spell",
firstNow: number,
count = 1,
) => Array.from({ length: count }, (_, index) => useCombatStore.getState().damageMob(
targetId,
10_000,
firstNow + index,
{ actorId: PLAYER_AGGRO_ID, role: "damage" },
school,
{
attackKind,
seed: `holy-void-${school}-${index}`,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
},
).amount);
const average = (values: readonly number[]) => values.reduce((sum, value) => sum + value, 0) / values.length;
const physicalBefore = average(sampleDamage("physical", "melee", 100));
const natureBefore = average(sampleDamage("nature", "spell", 200, 128));
const lightBefore = average(sampleDamage("holy", "spell", 500));
expect(useCombatStore.getState().castAbility(holyVoid.id, undefined, 1_000)).toMatchObject({ ok: true });
const voidAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === targetId && aura.sourceId === PLAYER_AGGRO_ID
&& aura.definition.id === "rom-priest-holy-void"
));
expect(voidAura).toBeDefined();
expect(voidAura?.expiresAt).toBe(21_000);
expect(voidAura?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "armor", operation: "percent", value: -0.188 }),
expect.objectContaining({ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 }),
]));
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === holyVoid.id && event.targetActorId === targetId
&& event.school === "holy" && event.rawAmount === 1_655 && event.effectiveAmount > 0
))).toBe(true);
const physicalAfter = average(sampleDamage("physical", "melee", 2_000));
const natureAfter = average(sampleDamage("nature", "spell", 2_100, 128));
const lightAfter = average(sampleDamage("holy", "spell", 2_300));
expect(physicalAfter).toBeGreaterThan(physicalBefore);
expect(natureAfter).toBeGreaterThan(natureBefore);
expect(lightAfter).toBe(lightBefore);
});
it("runs through the generated companion cast hook and keeps Voidal Infection on its target", () => {
const seed = "holy-void-priest-companion";
const priest = createConfiguredPartyMember({
name: "Holy Void Priest",
classId: "rom-priest",
specialization: "Primary",
role: "damage",
}, 80, 0, seed);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.getState().setCommand("attack");
usePartyStore.setState({ members: [{
...priest,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...priest.resourcePools, mana: 10_000 },
activeCast: {
abilityId: holyVoid.id,
school: "holy",
mode: "cast",
startedAt: 1_000,
completesAt: 1_000,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId,
},
activeCastTargetId: targetId,
status: "attacking",
}] });
registerPartyRuntimePosition(priest.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(priest.id, targetId, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
const companionAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === targetId && aura.sourceId === priest.id
&& aura.definition.id === "rom-priest-holy-void"
));
expect(companionAura?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "armor", value: -0.188 }),
expect.objectContaining({ kind: "stat", stat: "magical-defense", value: -0.188 }),
]));
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.sourceActorId === priest.id
&& event.abilityId === holyVoid.id && event.targetActorId === targetId
&& event.school === "holy" && event.rawAmount === 1_655 && event.effectiveAmount > 0
))).toBe(true);
});
});
+23
View File
@@ -214,6 +214,29 @@ export function romPriestKnightCastEffects(
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
const priestChampionEffects = romPriestChampionCastEffects(ability, effects, learnedPassiveSpellIds);
if (priestChampionEffects !== effects) return priestChampionEffects;
if (ability.dbcSpellId === 490276
&& ability.classId === "rom-priest"
&& ability.secondaryClassId === undefined) {
// Current CoA keeps Holy Void as Priest-primary and its Voidal Infection
// lowers physical defense. Normalize that local stat alias to armor so the
// generic aura resolver applies it to both character and NPC targets. Keep
// magical-defense intact; NPCs use it for modeled resistances, but the
// simulator has no Light/Holy resistance stat to reduce this spell's hit.
return effects.map((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-holy-void"
? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => (
modifier.kind === "stat" && modifier.stat === "physical-defense"
? { ...modifier, stat: "armor" }
: modifier
)),
},
}
: effect);
}
if (ability.dbcSpellId === 491588
&& ability.classId === "rom-mage"
&& ability.secondaryClassId === "rom-priest"
@@ -74,14 +74,14 @@ describe("RoM Knight/Priest conditional combat payloads", () => {
expect(holySmitePartyHeal).toEqual(["ally"]);
});
it("removes Chain of Light's cooldown only with Priest/Champion Infused Light Chain", () => {
it("does not apply the retired Infused Light Chain cooldown override to Rage Control", () => {
const chain = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491164)!;
expect(romPriestChampionCooldownOverride(chain, [])).toBeNull();
expect(romPriestChampionCooldownOverride(chain, [498610])).toBe(0);
expect(romPriestChampionCooldownOverride(chain, [498610])).toBeNull();
expect(romPriestChampionCooldownOverride({ ...chain, classId: "rom-knight" }, [498610])).toBeNull();
});
it("applies Infused Light Chain to the active Priest/Champion channel", () => {
it("keeps Chain of Light's base cooldown without the unsupported passive rider", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 30,
});
@@ -90,7 +90,7 @@ describe("RoM Knight/Priest conditional combat payloads", () => {
useCombatStore.getState().selectMob("priest-champion-chain");
expect(useCombatStore.getState().castAbility("rom-priest-primary-9", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast?.mode).toBe("channel");
expect(useCombatStore.getState().cooldowns["rom-priest-primary-9"]).toBeUndefined();
expect(useCombatStore.getState().cooldowns["rom-priest-primary-9"]).toBe(7_000);
});
it("consumes Holy Seals only when Holy Smite uses them and heals the caster plus one ally", () => {
@@ -2,13 +2,13 @@ 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 { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_PRIEST_MAGE_ENLIGHTENMENT } from "./romPriestMageRuntime";
import { ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG } from "./romPriestMageRuntime";
const ACTIVE_TAG = "rom-priest-mage-enlightenment-active";
const PASSIVE_TAG = ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG;
function initializePriestMage(): void {
useCombatStore.getState().initializeCharacter({
@@ -37,43 +37,31 @@ describe("RoM Priest/Mage Enlightenment integration", () => {
);
});
it("triggers only on completed Heal and respects the caster's 20-second cooldown", () => {
it("activates as a permanent +12% Wisdom passive for the player", () => {
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();
if (!heal || !passive) throw new Error("Missing local Priest/Mage Heal or Enlightenment rows");
expect([heal.dbcSpellId, passive.dbcSpellId]).toEqual([490_261, 492_933]);
expect(passive.passive).toBe(true);
expect(passive.description).toBe("Passive: Increases Wisdom by 12%.");
expect(passive.effects).toHaveLength(1);
expect(passive.effects[0]).toMatchObject({
kind: "apply-aura",
aura: {
durationMs: null,
modifiers: [{ kind: "stat", stat: "wisdom", operation: "percent", value: 0.12 }],
},
});
expect(aura(PLAYER_AGGRO_ID, PASSIVE_TAG, 1_000)?.expiresAt).toBeNull();
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);
expect(aura(PLAYER_AGGRO_ID, PASSIVE_TAG, 3_100)?.expiresAt).toBeNull();
expect(aura(PLAYER_AGGRO_ID, "rom-priest-mage-enlightenment-cooldown", 3_100)).toBeUndefined();
});
it("triggers after a party Priest/Mage finishes Heal and keeps the aura on that caster", () => {
it("activates as a permanent +12% Wisdom passive for a generated companion", () => {
const seed = "priest-mage-enlightenment-party";
const healer = createConfiguredPartyMember({
name: "Enlightenment Priest",
@@ -107,15 +95,15 @@ describe("RoM Priest/Mage Enlightenment integration", () => {
}] });
registerPartyRuntimePosition(healer.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(passive.effects).toEqual([]);
expect(aura(healer.id, ACTIVE_TAG, 1_000)).toBeUndefined();
expect(passive.effects).toHaveLength(1);
expect(aura(healer.id, PASSIVE_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();
expect(aura(healer.id, PASSIVE_TAG, 2_500)?.expiresAt).toBeNull();
expect(aura(healer.id, "rom-priest-mage-enlightenment-cooldown", 2_500)).toBeUndefined();
expect(aura(PLAYER_AGGRO_ID, PASSIVE_TAG, 2_500)?.expiresAt).toBeNull();
});
});
+31 -79
View File
@@ -1,104 +1,56 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
import {
ROM_PRIEST_MAGE_ENLIGHTENMENT,
ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT,
romPriestMageEnlightenmentProc,
romPriestMageEnlightenmentReadyAt,
ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT,
ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG,
overrideRomPriestMageEffects,
romPriestMageEnlightenmentPassiveEffects,
} from "./romPriestMageRuntime";
const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490_261)!;
const learnedPassive = [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId];
const enlightenment: RomGeneratedAbilityLike = {
classId: "rom-priest",
secondaryClassId: "rom-mage",
sourceSkillId: 492_933,
name: "Enlightenment",
description: "RuneWaker's older Heal-trigger effect.",
passive: true,
};
describe("RoM Priest/Mage Enlightenment runtime", () => {
it("grants the source-defined Wisdom aura after completing Heal", () => {
describe("RoM Priest/Mage Enlightenment", () => {
it("replaces the local trigger payload with a permanent +12% Wisdom passive", () => {
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,
wisdomPercent: 12,
});
const result = romPriestMageEnlightenmentProc(
"rom-priest",
"rom-mage",
heal,
learnedPassive,
true,
2_000,
2_000,
);
expect(result.nextReadyAt).toBe(22_000);
expect(result.effect).toMatchObject({
expect(overrideRomPriestMageEffects(enlightenment, [])).toEqual([
ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT,
]);
expect(ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT).toMatchObject({
kind: "apply-aura",
recipient: "caster",
triggerSpellId: 492_933,
aura: {
id: "rom-priest-mage-enlightenment",
durationMs: 8_000,
maxStacks: 1,
stackBehavior: "refresh",
id: ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG,
name: "Enlightenment",
description: "Increases Wisdom by 12%.",
durationMs: null,
modifiers: [{
kind: "stat",
stat: "wisdom",
operation: "percent",
value: 0.01,
value: 0.12,
}],
},
});
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],
},
});
expect(ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT.aura.id).toContain(":492933:");
expect(romPriestMageEnlightenmentPassiveEffects(false)).toEqual([]);
});
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("does not apply the rework to a different class pair or spell identity", () => {
expect(overrideRomPriestMageEffects({ ...enlightenment, secondaryClassId: "rom-warrior" }, [])).toBeNull();
expect(overrideRomPriestMageEffects({ ...enlightenment, classId: "rom-mage" }, [])).toBeNull();
expect(overrideRomPriestMageEffects({ ...enlightenment, sourceSkillId: 492_638 }, [])).toBeNull();
});
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" });
});
});
+31 -93
View File
@@ -1,114 +1,52 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { ActiveAura } from "./combatAuras";
import type { AbilityEffect } from "./abilityCatalog";
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",
wisdomPercent: 12,
});
export const ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
export const ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG = "rom-priest-mage:492933:enlightenment-passive";
export const ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT: Extract<AbilityEffect, { kind: "apply-aura" }> = {
kind: "apply-aura",
recipient: "caster",
aura: {
id: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag,
name: "Enlightenment cooldown",
id: ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG,
name: "Enlightenment",
description: "Increases Wisdom by 12%.",
disposition: "buff",
durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs,
hideFromAuraStrip: true,
utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag],
durationMs: null,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_TAG],
modifiers: [{
kind: "stat",
stat: "wisdom",
operation: "percent",
value: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent / 100,
}],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
miscValue: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent,
triggerSpellId: ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId,
};
/** Enlightenment is triggered by the Priest/Mage Heal cast, not a permanent passive aura. */
/** Current CoA Enlightenment is a permanent +12% Wisdom passive. */
export function romPriestMageEnlightenmentPassiveEffects(
learned: boolean,
): readonly AbilityEffect[] {
return learned ? [ROM_PRIEST_MAGE_ENLIGHTENMENT_PASSIVE_EFFECT] : [];
}
/** Replace RuneWaker's conditional Enlightenment components with the current CoA passive. */
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,
};
if (row.classId !== "rom-priest"
|| row.secondaryClassId !== "rom-mage"
|| row.sourceSkillId !== ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId) return null;
return romPriestMageEnlightenmentPassiveEffects(true);
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS,
ROM_PRIEST_SCOUT_CURING_SHOT_ID,
ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS,
} from "./romPriestScoutCuringShot";
describe("CoA Priest/Scout Curing Shot generated catalog row", () => {
const ability = ROM_ALL_ABILITIES.find((candidate) => (
candidate.source === "runewaker" && candidate.dbcSpellId === ROM_PRIEST_SCOUT_CURING_SHOT_ID
));
it("applies current CoA behavior to the exact generated Priest/Scout row", () => {
if (!ability) throw new Error("Missing Curing Shot catalog row");
expect(ability).toMatchObject({
classId: "rom-priest",
secondaryClassId: "rom-scout",
name: "Curing Shot",
passive: false,
target: "friendly",
range: { min: 0, max: 15 },
radius: 10,
targeting: { shape: "circle", radius: 10 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
cost: { resource: "mana", amount: 120 },
costs: [{ resource: "mana", amount: 120 }],
effects: [
{ kind: "heal", basePoints: 125.6, scope: "party", radius: 10 },
{
kind: "hot",
basePoints: 167.5,
ticks: ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS,
intervalMs: ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS,
scope: "party",
radius: 10,
},
{ kind: "apply-aura", recipient: "caster", aura: ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA },
],
});
expect(ability?.targeting).not.toHaveProperty("maximumTargets");
expect(ability?.ranks?.[0]).toMatchObject({
castMode: "instant",
castTimeMs: 0,
cooldownMs: 6_000,
cost: { resource: "mana", amount: 120 },
effects: ability.effects,
});
expect(ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS).toBe(18_000);
});
});
@@ -0,0 +1,302 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { resetCombatSpatialProvider, registerCombatSpatialProvider } from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { setMobPosition, clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { commitPartyAbilityUse } from "./partyAbilityAi";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS,
ROM_PRIEST_SCOUT_CURING_SHOT_ID,
} from "./romPriestScoutCuringShot";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const CURING_SHOT = ROM_ALL_ABILITIES.find((ability) => (
ability.source === "runewaker"
&& ability.classId === "rom-priest"
&& ability.secondaryClassId === "rom-scout"
&& ability.dbcSpellId === ROM_PRIEST_SCOUT_CURING_SHOT_ID
));
const CURING_SHOT_ID = "rom-priest-rom-scout-elite-35";
const TEST_TARGET_ID = "curing-shot-elemental-target";
function priest(name: string, seed: string, index: number, secondaryClassId?: "rom-scout") {
return createConfiguredPartyMember({
name,
classId: "rom-priest",
...(secondaryClassId ? { secondaryClassId } : {}),
specialization: "Primary",
role: "damage",
}, 80, index, seed);
}
function initializePlayerParty(seed: string) {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-scout",
raceId: "rom-human",
level: 80,
role: "healer",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "healer", 5, "rom");
const roster = [
...usePartyStore.getState().members,
priest("Extra modeled ally A", seed, 4),
priest("Extra modeled ally B", seed, 5),
];
usePartyStore.setState({ members: roster });
roster.forEach((member, index) => {
// Five party actors plus the player are intentionally in range so the
// integration catches the old five-recipient fallback cap.
registerPartyRuntimePosition(member.id, index < 5
? [2 + index * 1.5, 0, 0]
: [10.01, 0, 0]);
usePartyStore.getState().damageMember(member.id, 2_000);
});
useCombatStore.getState().damagePlayer(2_000);
useCombatStore.getState().synchronizeActors();
registerCombatSpatialProvider({
actor: () => null,
hasLineOfSight: () => true,
projectGroundPoint: (position) => position,
});
useCombatStore.getState().registerMob(TEST_TARGET_ID, {
name: "Elemental damage test target",
level: 1,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(TEST_TARGET_ID, [3, 0, 0]);
return { roster };
}
function applyCuringShotFromCompanion(casterId: string, now: number): void {
const caster = usePartyStore.getState().members.find((member) => member.id === casterId);
if (!caster || !CURING_SHOT) throw new Error("Missing Priest/Scout Curing Shot companion.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === casterId
? {
...member,
activeCast: {
abilityId: CURING_SHOT_ID,
school: "holy",
mode: "cast",
startedAt: now,
completesAt: now,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: casterId,
},
activeCastTargetId: casterId,
status: "attacking",
}
: member),
}));
useCombatStore.getState().synchronizeActors();
const syncedCaster = usePartyStore.getState().members.find((member) => member.id === casterId);
const committed = syncedCaster ? commitPartyAbilityUse(syncedCaster, CURING_SHOT, now) : null;
if (!committed) throw new Error("Companion could not commit Curing Shot.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === casterId
? { ...committed, activeCast: member.activeCast, activeCastTargetId: member.activeCastTargetId }
: member),
}));
useCombatStore.getState().commitCompanionActors([committed]);
advanceDungeonPartyCombat(now);
}
function schoolDamage(sourceActorId: string, school: "physical" | "holy" | "fire" | "frost" | "nature" | "shadow" | "arcane", now: number) {
return useCombatStore.getState().damageMob(
TEST_TARGET_ID,
1_000,
now,
{ actorId: sourceActorId, role: "damage", abilityId: `curing-shot-school-test:${school}` },
school,
{
attackKind: school === "physical" ? "melee" : "spell",
seed: `curing-shot-school-test:${sourceActorId}:${school}:${now}`,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
},
).amount;
}
function curingShotAura(targetId: string) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.definition.id === ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA.id);
}
describe("CoA Priest/Scout Curing Shot runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
useDungeonSimulationStore.setState({ sessionMode: "play" });
});
afterEach(() => resetCombatSpatialProvider());
it("heals every nearby modeled party actor, schedules six HoT ticks, and buffs only the caster's Water/frost analog", () => {
if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog.");
const { roster } = initializePlayerParty("curing-shot-player-runtime");
const ally = roster[0]!;
usePartyStore.getState().selectMember(ally.id);
const baseline = new Map(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS.map((school) => (
[school, schoolDamage(PLAYER_AGGRO_ID, school, 500)] as const
)));
const baselineExcluded = new Map(([
"fire", "nature", "arcane", "physical", "holy", "shadow",
] as const).map((school) => [school, schoolDamage(PLAYER_AGGRO_ID, school, 700)] as const));
expect(useCombatStore.getState().castAbility(CURING_SHOT.id, undefined, 1_000)).toMatchObject({ ok: true });
const scheduledHots = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(scheduledHots).toHaveLength(6);
expect(scheduledHots.map((effect) => effect.targetId)).toEqual(expect.arrayContaining([
null,
...roster.slice(0, 5).map((member) => member.id),
]));
const directEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt === 1_000
));
expect(directEvents.map((event) => event.targetActorId)).toHaveLength(6);
expect(directEvents.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID,
...roster.slice(0, 5).map((member) => member.id),
]));
expect(directEvents.map((event) => event.targetActorId)).not.toContain(roster[5]!.id);
expect(directEvents.every((event) => event.rawAmount > 0)).toBe(true);
expect(curingShotAura(PLAYER_AGGRO_ID)).toMatchObject({
expiresAt: 19_000,
definition: { modifiers: [
{ kind: "damage", direction: "dealt", school: "frost", value: 0.1 },
] },
});
expect(roster.map((member) => curingShotAura(member.id))).toEqual(Array.from({ length: roster.length }, () => undefined));
expect(useCombatStore.getState().cooldowns[CURING_SHOT.id]).toBe(1_000 + ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS);
const playerHotHealing = [];
const tickTimes = [4_000, 7_000, 10_000, 13_000, 16_000, 19_000];
for (const [tickIndex, tickAt] of tickTimes.slice(0, 5).entries()) {
const playerHealthBeforeTick = useCombatStore.getState().health;
const activeHotsBeforeTick = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(activeHotsBeforeTick).toHaveLength(6);
expect(activeHotsBeforeTick.every((effect) => effect.remainingTicks === 6 - tickIndex)).toBe(true);
useCombatStore.getState().tick(3, tickAt);
playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeTick);
const activeHotsAfterTick = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(activeHotsAfterTick).toHaveLength(6);
expect(activeHotsAfterTick.every((effect) => effect.remainingTicks === 5 - tickIndex)).toBe(true);
}
for (const school of ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS) {
expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999) / baseline.get(school)!).toBeCloseTo(1.1, 1);
}
for (const school of ["fire", "nature", "arcane", "physical", "holy", "shadow"] as const) {
expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999)).toBe(baselineExcluded.get(school));
}
expect(curingShotAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(19_000);
const finalTickAt = tickTimes[5]!;
const finalTickHotEffects = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(finalTickHotEffects).toHaveLength(6);
expect(finalTickHotEffects.every((effect) => effect.remainingTicks === 1)).toBe(true);
const playerHealthBeforeFinalTick = useCombatStore.getState().health;
useCombatStore.getState().tick(3, finalTickAt);
playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeFinalTick);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
))).toBe(false);
const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt > 1_000
));
// Companion HoTs are emitted as healing events; the player's self-HoT
// updates health directly and is verified separately below.
expect(periodicEvents).toHaveLength(30);
expect(periodicEvents.every((event) => tickTimes.includes(event.occurredAt))).toBe(true);
expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set(
roster.slice(0, 5).map((member) => member.id),
));
expect(periodicEvents.every((event) => event.rawAmount > 0)).toBe(true);
expect(playerHotHealing).toHaveLength(6);
expect(playerHotHealing.every((amount) => amount > 0)).toBe(true);
expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined();
expect(schoolDamage(PLAYER_AGGRO_ID, "frost", 19_000)).toBe(baseline.get("frost"));
});
it("lets a Priest/Scout companion heal nearby party actors and own the elemental bonus", () => {
if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog.");
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 80, role: "damage",
});
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
usePartyStore.getState().initializeParty("curing-shot-companion-runtime", 80, useCombatStore.getState().health, "damage", 5, "rom");
const caster = {
...priest("Priest Scout caster", "curing-shot-companion-runtime", 0, "rom-scout"),
resource: 500,
maxResource: 500,
resourcePools: { mana: 500 },
};
const ally = priest("Priest Scout ally", "curing-shot-companion-runtime", 1);
usePartyStore.setState({ members: [caster, ally] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(ally.id, [5, 0, 0]);
usePartyStore.getState().damageMember(caster.id, 2_000);
usePartyStore.getState().damageMember(ally.id, 2_000);
useCombatStore.getState().damagePlayer(2_000);
useCombatStore.getState().synchronizeActors();
registerCombatSpatialProvider({
actor: () => null,
hasLineOfSight: () => true,
projectGroundPoint: (position) => position,
});
useCombatStore.getState().registerMob(TEST_TARGET_ID, {
name: "Elemental damage test target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(TEST_TARGET_ID, [3, 0, 0]);
const companionFrostBaseline = schoolDamage(caster.id, "frost", 900);
const companionFireBaseline = schoolDamage(caster.id, "fire", 950);
applyCuringShotFromCompanion(caster.id, 1_000);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 1_000
)).map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID, caster.id, ally.id,
]));
expect(curingShotAura(caster.id)?.expiresAt).toBe(19_000);
expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined();
expect(curingShotAura(ally.id)).toBeUndefined();
expect(schoolDamage(caster.id, "frost", 1_100) / companionFrostBaseline).toBeCloseTo(1.1, 1);
expect(schoolDamage(caster.id, "fire", 1_150)).toBe(companionFireBaseline);
const playerHealthBeforeHotTick = useCombatStore.getState().health;
useCombatStore.getState().tick(3, 4_000);
const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 4_000
));
expect(periodicEvents).toHaveLength(3);
expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set([
PLAYER_AGGRO_ID, caster.id, ally.id,
]));
expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBeforeHotTick);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import type { AbilityDefinition } from "./abilityCatalog";
import {
isRomPriestScoutCuringShot,
ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_DESCRIPTION,
ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_DAMAGE_BONUS,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS,
ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS,
ROM_PRIEST_SCOUT_CURING_SHOT_ID,
ROM_PRIEST_SCOUT_CURING_SHOT_IMMEDIATE_HEAL,
ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
ROM_PRIEST_SCOUT_CURING_SHOT_RANGE,
ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS,
romPriestScoutCuringShotCatalogOverride,
} from "./romPriestScoutCuringShot";
const localAbility: AbilityDefinition = {
id: "rom-priest-rom-scout-elite-35",
classId: "rom-priest",
secondaryClassId: "rom-scout",
dbcSpellId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
name: "Curing Shot",
unlockLevel: 35,
icon: "/assets/ui/rom-spells/577011.png",
sigil: "CS",
description: "Local two-second heal row.",
target: "friendly",
range: { min: 0, max: 20 },
radius: 20,
targeting: { shape: "circle", radius: 20, maximumTargets: 5 },
castMode: "cast",
castTimeMs: 2_000,
cooldownMs: 12_000,
gcdMs: 1_500,
cost: { resource: "mana", amount: 120 },
costs: [{ resource: "mana", amount: 120 }],
effects: [{ kind: "damage", coefficient: 0.2 }],
source: "runewaker",
romSkillGroup: "elite",
ranks: [{
rank: 1,
spellId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
level: 35,
description: "Local two-second heal row.",
castMode: "cast",
castTimeMs: 2_000,
cooldownMs: 12_000,
gcdMs: 1_500,
cost: { resource: "mana", amount: 120 },
costs: [{ resource: "mana", amount: 120 }],
effects: [{ kind: "damage", coefficient: 0.2 }],
}],
};
describe("current CoA Priest/Scout Curing Shot catalog overlay", () => {
it("uses current CoA cast, cooldown, radius, direct heal, HoT, and elemental-buff metadata", () => {
const ability = romPriestScoutCuringShotCatalogOverride(localAbility);
expect(ability).toMatchObject({
name: "Curing Shot",
target: "friendly",
range: { min: 0, max: ROM_PRIEST_SCOUT_CURING_SHOT_RANGE },
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
targeting: { shape: "circle", radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS },
castMode: "instant",
castTimeMs: 0,
cooldownMs: ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
description: ROM_PRIEST_SCOUT_CURING_SHOT_DESCRIPTION,
passive: false,
});
expect(ability.effects).toEqual([
{
kind: "heal",
coefficient: 0,
basePoints: ROM_PRIEST_SCOUT_CURING_SHOT_IMMEDIATE_HEAL,
recipient: "caster",
scope: "party",
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
},
{
kind: "hot",
coefficient: 0,
basePoints: 167.5,
ticks: ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS,
intervalMs: ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS,
recipient: "caster",
scope: "party",
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
},
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
}),
]);
expect(ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS).toBe(18_000);
expect(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_DAMAGE_BONUS).toBe(0.1);
expect(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS).toEqual([
"frost",
]);
expect(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA).toMatchObject({
durationMs: 18_000,
modifiers: [
{ kind: "damage", direction: "dealt", school: "frost", operation: "percent", value: 0.1 },
],
});
});
it("does not carry over the local five-target cap and preserves the unsupported-to-scale local cost", () => {
const ability = romPriestScoutCuringShotCatalogOverride(localAbility);
expect(ability.targeting).toEqual({ shape: "circle", radius: 10 });
expect(ability.cost).toEqual({ resource: "mana", amount: 120 });
expect(ability.costs).toEqual([{ resource: "mana", amount: 120 }]);
expect(ability.ranks?.[0]).toMatchObject({
castMode: "instant",
castTimeMs: 0,
cooldownMs: 6_000,
cost: { resource: "mana", amount: 120 },
costs: [{ resource: "mana", amount: 120 }],
targeting: { shape: "circle", radius: 10 },
effects: ability.effects,
});
expect(ability.ranks?.[0]?.targeting).not.toHaveProperty("maximumTargets");
});
it("recognizes only the exact Priest-primary/Scout-secondary source identity", () => {
expect(isRomPriestScoutCuringShot({
classId: "rom-priest",
secondaryClassId: "rom-scout",
sourceSkillId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
})).toBe(true);
expect(isRomPriestScoutCuringShot({
classId: "rom-scout",
secondaryClassId: "rom-priest",
sourceSkillId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
})).toBe(false);
expect(isRomPriestScoutCuringShot({
classId: "rom-priest",
secondaryClassId: "rom-mage",
sourceSkillId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
})).toBe(false);
expect(isRomPriestScoutCuringShot({
classId: "rom-priest",
secondaryClassId: "rom-scout",
sourceSkillId: ROM_PRIEST_SCOUT_CURING_SHOT_ID + 1,
})).toBe(false);
});
it("leaves other rows and non-RuneWaker copies unchanged", () => {
const wrongPair = { ...localAbility, secondaryClassId: "rom-mage" as const };
const wrongId = { ...localAbility, dbcSpellId: ROM_PRIEST_SCOUT_CURING_SHOT_ID + 1 };
const wrongSource = { ...localAbility, source: "core" as const };
expect(romPriestScoutCuringShotCatalogOverride(wrongPair)).toBe(wrongPair);
expect(romPriestScoutCuringShotCatalogOverride(wrongId)).toBe(wrongId);
expect(romPriestScoutCuringShotCatalogOverride(wrongSource)).toBe(wrongSource);
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier } from "./combatAuras";
export const ROM_PRIEST_SCOUT_CURING_SHOT_ID = 492636;
export const ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS = 6_000;
export const ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS = 18_000;
export const ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS = 3_000;
export const ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS = 6;
export const ROM_PRIEST_SCOUT_CURING_SHOT_IMMEDIATE_HEAL = 125.6;
export const ROM_PRIEST_SCOUT_CURING_SHOT_HEAL_PER_TICK = 167.5;
export const ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_DAMAGE_BONUS = 0.1;
/** CoA distance 100 is interpreted as 10 project units using the established 10:1 conversion. */
export const ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS = 10;
/** CoA range 150 is interpreted as 15 project units using the established 10:1 conversion. */
export const ROM_PRIEST_SCOUT_CURING_SHOT_RANGE = 15;
export const ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_TAG =
"rom-priest-scout-curing-shot-elemental-damage";
export const ROM_PRIEST_SCOUT_CURING_SHOT_DESCRIPTION =
"Instantly heals you and nearby modeled party members for 125.6 HP and restores 167.5 HP every 3 seconds for 18 seconds. Grants the caster 10% Elemental Damage for 18 seconds. CoA describes raid coverage; this simulator models party members. Range: 150; radius: 100.";
/**
* CoA says Elemental Damage is subclass-defined and the Priest/Scout kit is
* explicitly water-themed. Map that class-specific element to this project's
* frost school, its Water analogue. CoA does not name Good Will's school, so
* this is an explicit inference rather than a source-enumerated mapping.
*/
export const ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS = Object.freeze([
"frost",
] as const);
const CURING_SHOT_ELEMENTAL_MODIFIERS: readonly AuraModifier[] = Object.freeze(
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS.map((school) => ({
kind: "damage" as const,
direction: "dealt" as const,
school,
operation: "percent" as const,
value: ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_DAMAGE_BONUS,
})),
);
export const ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA: AuraDefinition = Object.freeze({
id: "rom-priest-scout:curing-shot-elemental-damage",
name: "Good Will",
description: "Increases Elemental Damage by 10% for 18 seconds.",
disposition: "buff",
durationMs: ROM_PRIEST_SCOUT_CURING_SHOT_DURATION_MS,
stackBehavior: "replace",
utilityTags: [ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_TAG],
modifiers: CURING_SHOT_ELEMENTAL_MODIFIERS,
});
const CURING_SHOT_EFFECTS: readonly AbilityEffect[] = Object.freeze([
{
kind: "heal",
coefficient: 0,
basePoints: ROM_PRIEST_SCOUT_CURING_SHOT_IMMEDIATE_HEAL,
recipient: "caster",
scope: "party",
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
},
{
kind: "hot",
coefficient: 0,
basePoints: ROM_PRIEST_SCOUT_CURING_SHOT_HEAL_PER_TICK,
ticks: ROM_PRIEST_SCOUT_CURING_SHOT_HOT_TICKS,
intervalMs: ROM_PRIEST_SCOUT_CURING_SHOT_TICK_INTERVAL_MS,
recipient: "caster",
scope: "party",
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
},
{
kind: "apply-aura",
aura: ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
recipient: "caster",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: ROM_PRIEST_SCOUT_CURING_SHOT_ID,
},
]);
/** Select the same-ID Priest-primary/Scout-secondary row for current CoA behavior. */
export function isRomPriestScoutCuringShot(row: {
readonly classId?: string;
readonly secondaryClassId?: string;
readonly sourceSkillId?: number;
}): boolean {
return row.classId === "rom-priest"
&& row.secondaryClassId === "rom-scout"
&& row.sourceSkillId === ROM_PRIEST_SCOUT_CURING_SHOT_ID;
}
/**
* Overlay current CoA catalog values on only the exact local RuneWaker row.
* Costs deliberately stay on the local row: CoA's 242 MP is documented at
* skill level +105 and there is no source-backed lower-rank cost curve.
*/
export function romPriestScoutCuringShotCatalogOverride(
ability: AbilityDefinition,
): AbilityDefinition {
if (ability.source !== "runewaker"
|| !isRomPriestScoutCuringShot({
classId: ability.classId,
secondaryClassId: ability.secondaryClassId,
sourceSkillId: ability.dbcSpellId,
})) return ability;
const targeting = { shape: "circle" as const, radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS };
return {
...ability,
name: "Curing Shot",
description: ROM_PRIEST_SCOUT_CURING_SHOT_DESCRIPTION,
target: "friendly",
range: { min: 0, max: ROM_PRIEST_SCOUT_CURING_SHOT_RANGE },
radius: ROM_PRIEST_SCOUT_CURING_SHOT_RADIUS,
targeting,
castMode: "instant",
castTimeMs: 0,
cooldownMs: ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
effects: CURING_SHOT_EFFECTS,
passive: false,
romSkillGroup: "elite",
// Preserve the RuneWaker 120 MP cost and any rank-local costs.
ranks: ability.ranks?.map((rank) => ({
...rank,
description: ROM_PRIEST_SCOUT_CURING_SHOT_DESCRIPTION,
castMode: "instant",
castTimeMs: 0,
cooldownMs: ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
effects: CURING_SHOT_EFFECTS,
targeting,
})),
};
}
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
const ICE_BLADE = abilityById("rom-priest-rom-scout-elite-40");
const BONE_CHILL = abilityById("rom-priest-primary-4");
function registerTarget(id: string, health: number): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0, level: 80 });
setMobPosition(id, [3, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [id]: { ...state.mobs[id], health } },
}));
}
function lastDamageEvent(targetId: string, sourceActorId: string, abilityId: string) {
return useCombatStore.getState().combatEvents
.filter((event) => event.kind === "damage"
&& event.targetActorId === targetId
&& event.sourceActorId === sourceActorId
&& event.abilityId === abilityId)
.slice(-1)[0];
}
function directRolledDamage(targetId: string, sourceActorId: string, abilityId: string, now: number) {
return useCombatStore.getState().damageMob(
targetId,
100,
now,
{ actorId: sourceActorId, role: "damage", abilityId },
"frost",
{
attackKind: "spell",
abilityId,
seed: `saints-blessing:${sourceActorId}:${targetId}:${now}`,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
},
);
}
describe("CoA Priest/Scout Saint's Blessing damage integration", () => {
let companion: PartyMember;
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-scout",
raceId: "rom-human",
level: 80,
role: "damage",
});
usePartyStore.getState().initializeParty(
"saints-blessing-damage-integration", 80, useCombatStore.getState().health, "damage", 2, "rom",
);
companion = createConfiguredPartyMember({
name: "Priest Scout",
classId: "rom-priest",
secondaryClassId: "rom-scout",
specialization: "Primary",
role: "damage",
}, 80, 0, "saints-blessing-damage-companion");
usePartyStore.setState({ members: [companion] });
});
it("applies +28% to player Ice Blade only below 30% current health", () => {
if (!ICE_BLADE) throw new Error("Generated Priest/Scout Ice Blade (490070) is missing.");
expect(ICE_BLADE.dbcSpellId).toBe(490_070);
const rawAmounts = new Map<number, number>();
for (const health of [299, 300, 301]) {
const targetId = `saints-player-${health}`;
registerTarget(targetId, health);
directRolledDamage(targetId, PLAYER_AGGRO_ID, ICE_BLADE.id, health);
rawAmounts.set(health, lastDamageEvent(targetId, PLAYER_AGGRO_ID, ICE_BLADE.id)?.rawAmount ?? 0);
}
const atThreshold = rawAmounts.get(300) ?? 0;
expect(rawAmounts.get(299)).toBe(Math.round(atThreshold * 1.28));
expect(rawAmounts.get(301)).toBe(atThreshold);
});
it("applies +28% to a generated Priest/Scout companion's rolled hit below 30%", () => {
if (!ICE_BLADE) throw new Error("Generated Priest/Scout Ice Blade (490070) is missing.");
expect(companion.classId).toBe("rom-priest");
expect(companion.secondaryClassId).toBe("rom-scout");
const rawAmounts = new Map<number, number>();
for (const health of [299, 300, 301]) {
const targetId = `saints-companion-${health}`;
registerTarget(targetId, health);
directRolledDamage(targetId, companion.id, ICE_BLADE.id, health);
rawAmounts.set(health, lastDamageEvent(targetId, companion.id, ICE_BLADE.id)?.rawAmount ?? 0);
}
const atThreshold = rawAmounts.get(300) ?? 0;
expect(atThreshold).toBe(100);
expect(rawAmounts.get(299)).toBe(Math.round(atThreshold * 1.28));
expect(rawAmounts.get(301)).toBe(atThreshold);
});
it("applies the raw damage path once and excludes a different secondary class", () => {
if (!ICE_BLADE) throw new Error("Generated Priest/Scout Ice Blade (490070) is missing.");
const targetId = "saints-raw-companion";
registerTarget(targetId, 299);
useCombatStore.getState().damageMob(
targetId,
100,
1_000,
{ actorId: companion.id, role: "damage", abilityId: ICE_BLADE.id },
"frost",
);
expect(lastDamageEvent(targetId, companion.id, ICE_BLADE.id)?.rawAmount).toBe(128);
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-mage",
raceId: "rom-human",
level: 80,
role: "damage",
});
const excludedTargetId = "saints-wrong-pair";
registerTarget(excludedTargetId, 299);
useCombatStore.getState().damageMob(
excludedTargetId,
100,
2_000,
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: ICE_BLADE.id },
"frost",
);
expect(lastDamageEvent(excludedTargetId, PLAYER_AGGRO_ID, ICE_BLADE.id)?.rawAmount).toBe(100);
});
it.each([
["player", PLAYER_AGGRO_ID],
["generated companion", "companion"],
] as const)("checks the live health threshold on each %s Bone Chill tick", (_label, actor) => {
if (!BONE_CHILL) throw new Error("Generated Priest Bone Chill (491165) is missing.");
const sourceActorId = actor === "companion" ? companion.id : actor;
const targetId = `saints-periodic-${sourceActorId}`;
registerTarget(targetId, 350);
const effect: TimedCombatEffect = {
id: `saints-periodic-effect-${sourceActorId}`,
kind: "dot",
sourceActorId,
sourceAbilityId: BONE_CHILL.id,
targetId,
coefficient: 0,
fixedAmount: 100,
remainingTicks: 2,
intervalMs: 1_000,
nextTickAt: 1_000,
damageSchool: "frost",
};
useCombatStore.setState((state) => ({ effects: [...state.effects, effect] }));
useCombatStore.getState().tick(0, 1_000);
const firstTickAmount = lastDamageEvent(targetId, sourceActorId, BONE_CHILL.id)?.rawAmount ?? 0;
expect(firstTickAmount).toBeGreaterThan(0);
expect(useCombatStore.getState().mobs[targetId].health).toBe(350 - firstTickAmount);
useCombatStore.getState().tick(0, 2_000);
expect(lastDamageEvent(targetId, sourceActorId, BONE_CHILL.id)?.rawAmount)
.toBe(Math.round(firstTickAmount * 1.28));
expect(useCombatStore.getState().mobs[targetId].health)
.toBe(350 - firstTickAmount - Math.round(firstTickAmount * 1.28));
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { romPriestScoutSaintsBlessingDamageMultiplier } from "./romPriestScoutSaintsBlessingDamage";
const base = {
primaryClassId: "rom-priest",
secondaryClassId: "rom-scout",
learnedPassiveSpellIds: [493_039],
};
describe("CoA Priest/Scout Saint's Blessing execute damage rider", () => {
it("grants +28% damage below, but not at, the 30% target-health threshold", () => {
expect(romPriestScoutSaintsBlessingDamageMultiplier({ ...base, targetHealthPercent: 0.299 }))
.toBeCloseTo(1.28);
expect(romPriestScoutSaintsBlessingDamageMultiplier({ ...base, targetHealthPercent: 0.3 }))
.toBe(1);
});
it("requires the exact Priest-primary/Scout-secondary pair and learned passive", () => {
expect(romPriestScoutSaintsBlessingDamageMultiplier({
...base,
primaryClassId: "rom-scout",
secondaryClassId: "rom-priest",
targetHealthPercent: 0.1,
})).toBe(1);
expect(romPriestScoutSaintsBlessingDamageMultiplier({
...base,
learnedPassiveSpellIds: [],
targetHealthPercent: 0.1,
})).toBe(1);
});
it("does not apply to dead, invalid, or full-health targets", () => {
for (const targetHealthPercent of [0, 0.3, 1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(romPriestScoutSaintsBlessingDamageMultiplier({ ...base, targetHealthPercent })).toBe(1);
}
});
});
@@ -0,0 +1,34 @@
/** Current CoA Priest/Scout Saint's Blessing's conditional damage rider. */
export const ROM_PRIEST_SCOUT_SAINTS_BLESSING_DAMAGE = Object.freeze({
passiveSpellId: 493_039,
targetHealthThreshold: 0.3,
damageBonus: 0.28,
});
export interface RomPriestScoutSaintsBlessingDamageContext {
readonly primaryClassId: string;
readonly secondaryClassId?: string;
readonly learnedPassiveSpellIds: readonly number[];
/** Target's current health divided by its maximum health at damage resolution. */
readonly targetHealthPercent: number;
}
/**
* Return the current CoA multiplier for a damage packet resolved against a
* living target below 30% maximum health. The caller should evaluate this at
* each damage event so the threshold follows the target's live health.
*/
export function romPriestScoutSaintsBlessingDamageMultiplier(
context: RomPriestScoutSaintsBlessingDamageContext,
): number {
if (context.primaryClassId !== "rom-priest"
|| context.secondaryClassId !== "rom-scout"
|| !context.learnedPassiveSpellIds.includes(ROM_PRIEST_SCOUT_SAINTS_BLESSING_DAMAGE.passiveSpellId)
|| !Number.isFinite(context.targetHealthPercent)
|| context.targetHealthPercent <= 0
|| context.targetHealthPercent >= ROM_PRIEST_SCOUT_SAINTS_BLESSING_DAMAGE.targetHealthThreshold) {
return 1;
}
return 1 + ROM_PRIEST_SCOUT_SAINTS_BLESSING_DAMAGE.damageBonus;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { romPriestDescription, romPriestName } from "./romPriestTooltips";
describe("current CoA Priest skill labels and descriptions", () => {
it("maps the reused Ice Fog IDs to their current CoA names", () => {
expect(romPriestName(490274, "Ice Fog")).toBe("Holy Blessing");
expect(romPriestName(490275, "Ice Fog")).toBe("Vow of Silence");
expect(romPriestName(490276, "Ice Fog")).toBe("Holy Void");
expect(romPriestName(490273, "Wave Armor")).toBe("Wave Armor");
});
it("describes Holy Blessing's current Wisdom formula and Light damage bonus", () => {
const description = romPriestDescription(490274, "old Ice Fog tooltip");
expect(description).toContain("passive");
expect(description).toContain("Wisdom + 115");
expect(description).toContain("10.5%");
expect(description).toContain("Light Damage bonus: +100.9%");
});
it("documents Vow of Silence interruption and PvP duration", () => {
const description = romPriestDescription(490275, "old Ice Fog tooltip");
expect(description).toContain("interrupts");
expect(description).toContain("8.4 seconds");
expect(description).toContain("4.2 seconds against players");
});
it("documents Holy Void damage and defense reduction", () => {
const description = romPriestDescription(490276, "old Ice Fog tooltip");
expect(description).toContain("1655 Light Damage");
expect(description).toContain("18.8%");
expect(description).toContain("20 seconds");
});
});
+16 -4
View File
@@ -1,9 +1,11 @@
import { ROM_PRIEST_HOLY_CANDLE_DESCRIPTION } from "./romPriestHolyCandleRuntime";
/** Player-facing copy for the active Priest / Knight kit. Source string tokens
* such as Buff0-Time refer to client tables that are not present in the UI. */
const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
490273: "Shields a friendly target from damage for 60 seconds and reduces physical and magical damage taken while the shield lasts.",
490261: "Restores health to a friendly target.",
490274: "Surrounds the target with icy fog, slowing it for a short time.",
490274: "Holy Blessing (passive). Wisdom bonus: (current Wisdom + 115) × 10.5% + 115. Light Damage bonus: +100.9%.",
491165: "Chills the target with Water damage every 2 seconds for 12 seconds.",
490297: "Bonds with a friendly target for 30 minutes, allowing it to revive once at the place where it falls.",
490290: "Increases a friendly target's maximum health for 30 minutes.",
@@ -12,11 +14,11 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
490491: "Increases a friendly target's physical attack power for 30 minutes. Does not stack with similar effects.",
490305: "Passive: increases Water resistance and Water spell damage.",
490295: "Passive: increases the healing done by your spells.",
490275: "Surrounds the target with icy fog, slowing it for a short time.",
490275: "Vow of Silence interrupts the target's spell casting and silences it for 8.4 seconds (4.2 seconds against players).",
490294: "Increases healing received by a friendly target for 60 seconds.",
490301: "Temporarily reduces your threat against enemies.",
490276: "Surrounds the target with icy fog, slowing it for a short time.",
490304: "Consumes some of your health each second to increase the healing you do.",
490276: "Holy Void inflicts 1655 Light Damage and applies Voidal Infection, reducing the target's defenses by 18.8% for 20 seconds.",
490304: ROM_PRIEST_HOLY_CANDLE_DESCRIPTION,
491176: "Revives a dead friendly target outside combat with reduced death debt.",
491177: "Revives a dead friendly target outside combat with greatly reduced death debt.",
491147: "Quickly restores health to a friendly target.",
@@ -38,6 +40,16 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
499957: "Passive: while Devotion Halo is active, increases your healing done.",
};
const PRIEST_NAMES: Readonly<Record<number, string>> = {
490274: "Holy Blessing",
490275: "Vow of Silence",
490276: "Holy Void",
};
export function romPriestName(sourceSkillId: number, original: string): string {
return PRIEST_NAMES[sourceSkillId] ?? original;
}
export function romPriestDescription(sourceSkillId: number, original: string): string {
return PRIEST_DESCRIPTIONS[sourceSkillId] ?? original;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
import { romRogueChaosDaggerDamage } from "./romRogueChaosDagger";
import { ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG } from "./romRogueChaosDagger";
const baseInput = {
classId: "rom-rogue",
secondaryClassId: "rom-mage",
passiveLearned: true,
isAutoAttack: true,
attackLanded: true,
weaponHand: "main-hand" as const,
weaponDps: 100,
};
describe("current CoA Rogue/Mage Chaos Dagger", () => {
it("replaces the same-ID RuneWaker skill with the learned CoA passive and tooltip", () => {
const abilities = abilitiesForCharacter("rom-rogue", "rom-mage");
const chaosDagger = abilities.find((ability) => ability.dbcSpellId === 491178);
expect(chaosDagger).toMatchObject({
name: "Chaos Dagger",
passive: true,
target: "self",
castTimeMs: 0,
cooldownMs: 0,
cost: { resource: "mana", amount: 0 },
costs: [],
});
expect(chaosDagger?.description).toContain("169.4% of the attacking hand's weapon DPS");
expect(chaosDagger?.description).toContain("amount or duration");
expect(chaosDagger?.effects).toContainEqual(expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
name: "Chaos Dagger",
utilityTags: [ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG],
}),
}));
expect(learnedAbilityForBuild(chaosDagger!, 14, {})).toBeNull();
expect(learnedAbilityForBuild(chaosDagger!, 15, {})).not.toBeNull();
expect(abilitiesForCharacter("rom-rogue", "rom-priest")
.some((ability) => ability.dbcSpellId === 491178)).toBe(false);
});
it("uses 169.4% of the weapon DPS for the hand that made the landed normal attack", () => {
expect(romRogueChaosDaggerDamage(baseInput)).toEqual({
amount: 169.4,
school: "fire",
weaponHand: "main-hand",
});
expect(romRogueChaosDaggerDamage({
...baseInput,
weaponHand: "off-hand",
weaponDps: 64.25,
})).toEqual({
amount: 108.8395,
school: "fire",
weaponHand: "off-hand",
});
});
it.each([
["wrong primary class", { classId: "rom-scout" }],
["wrong secondary class", { secondaryClassId: "rom-priest" }],
["passive not learned", { passiveLearned: false }],
["not an autoattack", { isAutoAttack: false }],
["attack missed", { attackLanded: false }],
["zero weapon DPS", { weaponDps: 0 }],
["invalid weapon DPS", { weaponDps: Number.NaN }],
] as const)("does not add a packet for %s", (_label, override) => {
expect(romRogueChaosDaggerDamage({ ...baseInput, ...override })).toBeNull();
});
});
+41
View File
@@ -0,0 +1,41 @@
export const ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID = 491178;
export const ROM_ROGUE_MAGE_CHAOS_DAGGER_DAMAGE_COEFFICIENT = 1.694;
export const ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG = "rom-491178-chaos-dagger";
export const ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION = "Passive: Each normal attack adds Fire damage equal to 169.4% of the attacking hand's weapon DPS. The CoA page also lists 25 MP while labeling this passive, but does not say whether Mana is spent per attack; no Mana charge is modeled pending clarification. The source describes a slight temporary Physical Attack Power increase without an amount or duration, so that rider is not modeled.";
export type RomRogueChaosDaggerWeaponHand = "main-hand" | "off-hand";
export interface RomRogueChaosDaggerDamageInput {
readonly classId: string;
readonly secondaryClassId: string | null | undefined;
readonly passiveLearned: boolean;
readonly isAutoAttack: boolean;
readonly attackLanded: boolean;
readonly weaponHand: RomRogueChaosDaggerWeaponHand;
readonly weaponDps: number;
}
export interface RomRogueChaosDaggerDamage {
readonly amount: number;
readonly school: "fire";
readonly weaponHand: RomRogueChaosDaggerWeaponHand;
}
/** Current CoA Chaos Dagger adds 169.4% of the attacking hand's weapon DPS to each landed autoattack. */
export function romRogueChaosDaggerDamage(
input: RomRogueChaosDaggerDamageInput,
): RomRogueChaosDaggerDamage | null {
if (input.classId !== "rom-rogue"
|| input.secondaryClassId !== "rom-mage"
|| !input.passiveLearned
|| !input.isAutoAttack
|| !input.attackLanded
|| !Number.isFinite(input.weaponDps)
|| input.weaponDps <= 0) return null;
return {
amount: input.weaponDps * ROM_ROGUE_MAGE_CHAOS_DAGGER_DAMAGE_COEFFICIENT,
school: "fire",
weaponHand: input.weaponHand,
};
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityById } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import {
advanceDungeonPartyCombat,
resetPartyRuntimeClock,
} from "./partyRuntime";
import { registerPartyRuntimePosition, clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { partyAbilityCosts } from "./partyAbilityAi";
import {
ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG,
ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS,
ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID,
ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
} from "./romRogueEnergizedHandsRuntime";
import { ROM_ROGUE_POISON_AURA_TAG, ROM_ROGUE_POISON_SKILL_ID } from "./romRoguePoisonRuntime";
describe("RoM Rogue current CoA Hands skills", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50, xp: 0 });
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("replaces the local 490347 Numbing Dagger row with Energized Hands and applies its player mechanics", () => {
const energized = abilityById("rom-rogue-primary-5")!;
const poisoned = abilityById("rom-rogue-primary-6")!;
expect(energized).toMatchObject({
dbcSpellId: ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID,
name: "Energized Hands",
target: "self",
castTimeMs: 1_000,
cooldownMs: 0,
});
expect(energized.description).toContain("maximum Energy by 50%");
expect(poisoned).toMatchObject({ dbcSpellId: ROM_ROGUE_POISON_SKILL_ID, name: "Poisonous Hands", target: "self" });
const poisonAura = poisoned.effects.find((effect) => effect.kind === "apply-aura");
expect(poisonAura?.kind === "apply-aura" && poisonAura.aura.exclusiveGroup)
.toBe(ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP);
expect(energized.description).toContain("0.1 seconds");
expect(useCombatStore.getState().castAbility(energized.id, undefined, 1_000).ok).toBe(true);
useCombatStore.getState().tick(1.1, 2_100);
expect(useCombatStore.getState().maxResourcePools.energy).toBe(150);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-14", undefined, 3_600).ok).toBe(true);
expect(useCombatStore.getState().cooldowns["rom-rogue-primary-14"])
.toBe(3_600 + 120_000 - ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS);
});
it("replaces Energized Hands with Poisonous Hands and restores the base Energy maximum", () => {
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 1_000).ok).toBe(true);
useCombatStore.getState().tick(1.1, 2_100);
expect(useCombatStore.getState().maxResourcePools.energy).toBe(150);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 3_600).ok).toBe(true);
useCombatStore.getState().tick(1.1, 4_700);
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP);
expect(auras).toHaveLength(1);
expect(auras[0]?.definition.utilityTags).toContain(ROM_ROGUE_POISON_AURA_TAG);
expect(useCombatStore.getState().maxResourcePools.energy).toBe(100);
});
it("applies Energized Hands Energy capacity, cost, and regeneration to a generated companion", () => {
const member = createConfiguredPartyMember({
name: "Rogue companion",
classId: "rom-rogue",
specialization: "Primary",
role: "damage",
}, 50, 0, "rogue-energized-hands");
const energized = abilityById("rom-rogue-primary-5")!;
const effect = energized.effects.find((candidate) => candidate.kind === "apply-aura");
if (effect?.kind !== "apply-aura") throw new Error("Missing Energized Hands aura effect.");
usePartyStore.getState().initializeParty("rogue-energized-hands", 50, 1_000, "damage", 1, "rom");
usePartyStore.setState({ command: "stop", members: [{ ...member, nextActionAt: 100_000 }] });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().applyActorAura(member.id, member.id, energized, effect, 1_000)).toBe(true);
let current = usePartyStore.getState().members[0]!;
expect(current.maxResource).toBe(150);
expect(useCombatStore.getState().actors[member.id]?.resources.energy?.maximum).toBe(150);
const costProbe = {
...abilityById("rom-rogue-primary-14")!,
cost: { resource: "energy" as const, amount: 25 },
costs: [{ resource: "energy" as const, amount: 25 }],
};
expect(partyAbilityCosts(current, costProbe, 1_000)).toEqual([{ resource: "energy", amount: 30 }]);
const reduced = { ...current, resource: 50, resourcePools: { ...current.resourcePools, energy: 50 } };
usePartyStore.setState({ members: [reduced] });
useCombatStore.getState().commitCompanionActors([reduced]);
resetPartyRuntimeClock();
advanceDungeonPartyCombat(2_000);
advanceDungeonPartyCombat(3_000);
current = usePartyStore.getState().members[0]!;
expect(current.resource).toBe(70);
expect(useCombatStore.getState().actors[member.id]?.resources.energy?.maximum).toBe(150);
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { ActiveAura, AuraDefinition, AuraModifier } from "./combatAuras";
import type { AbilityEffect } from "./abilityCatalog";
export const ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID = 490347;
export const ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG = "rom-energized-hands";
export const ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP = "rom-rogue-hands";
export const ROM_ROGUE_ENERGIZED_HANDS_DURATION_MS = 900_000;
export const ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS = 100;
export const ROM_ROGUE_ENERGIZED_HANDS_MAX_ENERGY_PERCENT = 50;
export const ROM_ROGUE_ENERGIZED_HANDS_REGENERATION_PERCENT = 100;
export const ROM_ROGUE_ENERGIZED_HANDS_COST_PERCENT = 20;
export const ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION =
"Increases Energy recovery by 100%, reduces skill cooldowns by 0.1 seconds, and increases maximum Energy by 50%, but increases Energy costs by 20% for 900 seconds. Mutually exclusive with Poisonous Hands.";
export const ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION =
"Uses 1 Poison Bottle. For 900 seconds, regular attacks have a 50% chance to apply Poisonous Hands to the target, reducing damage dealt by the target by 8%. Each successful application stacks up to 10 times and grants you 1% Physical Attack Speed per stack applied to your current target. Attacking a new target resets this attack-speed sequence. Mutually exclusive with Energized Hands.";
export function romRogueEnergizedHandsAuraDefinition(): AuraDefinition {
const modifiers: readonly AuraModifier[] = [
{
kind: "resource",
resource: "energy",
property: "maximum",
operation: "percent",
value: ROM_ROGUE_ENERGIZED_HANDS_MAX_ENERGY_PERCENT / 100,
},
{
kind: "resource",
resource: "energy",
property: "regeneration",
operation: "percent",
value: ROM_ROGUE_ENERGIZED_HANDS_REGENERATION_PERCENT / 100,
},
{
kind: "resource",
resource: "energy",
property: "cost",
operation: "percent",
value: ROM_ROGUE_ENERGIZED_HANDS_COST_PERCENT / 100,
},
];
return {
id: "rom-rogue-energized-hands",
name: "Energized Hands",
description: ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION,
disposition: "buff",
durationMs: ROM_ROGUE_ENERGIZED_HANDS_DURATION_MS,
exclusiveGroup: ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
utilityTags: [ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG],
modifiers,
};
}
export function romRogueEnergizedHandsApplyEffect(): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 0,
miscValue: 0,
triggerSpellId: 0,
aura: romRogueEnergizedHandsAuraDefinition(),
};
}
export function romRogueEnergizedHandsActive(
auras: readonly ActiveAura[],
actorId: string,
now?: number,
): boolean {
return auras.some((aura) => aura.targetId === actorId
&& aura.definition.utilityTags?.includes(ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG)
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
}
/**
* Translate the current max-Energy aura between snapshots without changing
* current Energy. The stored pool maximum is the runtime authority, so apply
* only the ratio of the old and new aura modifiers (refreshes remain neutral).
*/
export function romRogueEnergyMaximumAfterAuraTransition(
currentMaximum: number,
previousAuras: readonly ActiveAura[],
nextAuras: readonly ActiveAura[],
targetId: string,
): number {
const maximumModifier = (auras: readonly ActiveAura[]) => auras
.filter((aura) => aura.targetId === targetId)
.reduce((total, aura) => {
const hasEnergizedHands = aura.definition.utilityTags?.includes(ROM_ROGUE_ENERGIZED_HANDS_AURA_TAG);
if (!hasEnergizedHands) return total;
const percent = (aura.definition.modifiers ?? []).reduce((value, modifier) => (
modifier.kind === "resource" && modifier.resource === "energy" && modifier.property === "maximum"
? value + (modifier.operation === "percent" ? modifier.value : 0)
: value
), 0);
return total * (1 + percent);
}, 1);
const previous = maximumModifier(previousAuras);
const next = maximumModifier(nextAuras);
return Math.max(0, Math.round(currentMaximum * next / Math.max(0.0001, previous)));
}
+242 -60
View File
@@ -8,22 +8,30 @@ import { romEffectsForCast } from "./romCombatMechanics";
import { PLAYER_AGGRO_ID } from "./aggro";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { combatAttackTable } from "./combatResults";
import { usePartyStore } from "./partyStore";
import {
ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER,
ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION,
ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID,
ROM_ROGUE_COERCE_PASSIVE_SPELL_ID,
ROM_ROGUE_COERCE_TAG,
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_INSTANT_SHADOW_READY_TAG,
ROM_ROGUE_OFF_HAND_MASTERY_TAG,
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
ROM_ROGUE_TREASURE_HUNTER_TAG,
romRogueCoerceAbilityAdjustment,
romRogueCoerceLowBlowDamageMultiplier,
romRogueDaggerDamageMultiplier,
romRogueEnchantedThrowShouldProc,
romRogueShadowWalkerAdjustment,
romRogueTreasureHunterLootChance,
} from "./romRogueMechanics";
import {
ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG,
ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG,
ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY,
ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG,
romRogueMageDayOfRainBaseMissPercent,
@@ -127,7 +135,90 @@ describe("RoM Rogue source mechanics", () => {
.toContain(ROM_ROGUE_DAGGER_MASTERY_TAG);
expect(treasureHunter?.kind === "apply-aura" && treasureHunter.aura.utilityTags)
.toContain(ROM_ROGUE_TREASURE_HUNTER_TAG);
expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]);
expect(effectAuras("rom-rogue-primary-12")[0]).toEqual(expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: [ROM_ROGUE_OFF_HAND_MASTERY_TAG] }),
}));
});
it("translates Coerce to a learned Rogue/Scout marker for current Low Blow behavior", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
const passive = abilityById("rom-rogue-rom-scout-elite-45")!;
const [marker] = effectAuras(passive.id);
expect(marker?.kind === "apply-aura" && marker.aura.utilityTags).toContain(ROM_ROGUE_COERCE_TAG);
expect(marker?.kind === "apply-aura" && marker.aura.modifiers).toBeUndefined();
});
it("applies Coerce's sourced 20.2% Low Blow multiplier only to learned Rogue/Scout Low Blow", () => {
const context = {
classId: "rom-rogue",
abilitySource: "runewaker",
abilitySpellId: ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID,
passiveTags: [ROM_ROGUE_COERCE_TAG],
} as const;
expect(romRogueCoerceLowBlowDamageMultiplier(context)).toBe(ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER);
expect(romRogueCoerceLowBlowDamageMultiplier({ ...context, abilitySpellId: 490324 })).toBe(1);
expect(romRogueCoerceLowBlowDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
});
it("reduces only learned Rogue/Scout Low Blow's Energy cost by the local sourced amount", () => {
const passiveSpellIds = [ROM_ROGUE_COERCE_PASSIVE_SPELL_ID];
expect(romRogueCoerceAbilityAdjustment(
"rom-rogue", "rom-scout", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, passiveSpellIds,
)).toMatchObject({ energyCostReduction: ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION, cooldownIncreaseMs: 0 });
expect(romRogueCoerceAbilityAdjustment(
"rom-rogue", "rom-scout", "runewaker", 490324, passiveSpellIds,
).energyCostReduction).toBe(0);
expect(romRogueCoerceAbilityAdjustment(
"rom-rogue", "rom-priest", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, passiveSpellIds,
).energyCostReduction).toBe(0);
expect(romRogueCoerceAbilityAdjustment(
"rom-rogue", "rom-scout", "runewaker", ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID, [],
).energyCostReduction).toBe(0);
});
it("applies Coerce's reduced Low Blow Energy cost during a Rogue/Scout player cast", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
const targetId = "rogue-coerce-target";
useCombatStore.getState().registerMob(targetId, {
name: "Coerce target", maxHealth: 100_000, level: 80, xpReward: 0,
});
setMobPosition(targetId, [0, 0, 2]);
useCombatStore.getState().selectMob(targetId);
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, hitRating: 1_000_000 },
resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 },
}));
const beforeEnergy = useCombatStore.getState().resourcePools.energy!;
expect(useCombatStore.getState().castAbility("rom-rogue-primary-1", { targetActorId: targetId, now: 1_000 }))
.toMatchObject({ ok: true });
expect(beforeEnergy - useCombatStore.getState().resourcePools.energy!).toBe(25);
});
it("scales Low Blow's damage effects by the current Coerce value for player and party cast contexts", () => {
const lowBlow = abilityById("rom-rogue-primary-1")!;
const context = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
passiveTags: [ROM_ROGUE_COERCE_TAG],
} as const;
const base = romEffectsForCast(lowBlow, { ...context, passiveTags: [] });
const coerced = romEffectsForCast(lowBlow, context);
const baseDamage = base.filter((effect) => effect.kind === "damage");
const coercedDamage = coerced.filter((effect) => effect.kind === "damage");
expect(coercedDamage).toHaveLength(baseDamage.length);
expect(coercedDamage.map((effect) => effect.kind === "damage" && effect.coefficient))
.toEqual(baseDamage.map((effect) => effect.kind === "damage" && effect.coefficient * ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER));
expect(romEffectsForCast(lowBlow, { ...context, passiveTags: [] }).filter((effect) => effect.kind === "damage"))
.toEqual(baseDamage);
});
it("learns Sinister Methods as a proc marker without an always-on critical aura", () => {
@@ -301,54 +392,143 @@ 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"
));
it("adds the CoA Fire packet from each landed Chaos Dagger hand and mitigates it as Fire", () => {
const weaponStats = { ...useCombatStore.getState().gearStats, hitRating: 1_000_000 };
const mainHandDamage = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 };
const offHandDamage = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 };
const mainHand = testWeapon("dagger", "chaos-dagger-player-main", { ...weaponStats, meleeWeapon: mainHandDamage });
const offHand = {
...testWeapon("dagger", "chaos-dagger-player-off", { ...weaponStats, meleeWeapon: offHandDamage }),
inventorySlot: "off-hand" as const,
};
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
inventory: [mainHand, offHand],
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
});
const manaBefore = useCombatStore.getState().resourcePools.mana;
useCombatStore.getState().registerMob("chaos-dagger-target", {
name: "Chaos Dagger target", maxHealth: 1_000_000, level: 80,
schoolImmunities: ["fire"],
});
setMobPosition("chaos-dagger-target", [0, 0, 3]);
useCombatStore.setState({
autoAttackTargetId: "chaos-dagger-target",
nextAutoAttackAt: 1_500,
nextOffhandAutoAttackAt: 1_500,
});
useCombatStore.getState().tick(0.25, 1_500);
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);
const hits = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.targetActorId === "chaos-dagger-target"
));
const fireHits = hits.filter((event) => event.school === "fire");
expect(fireHits.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]);
expect(fireHits.every((event) => event.effectiveAmount === 0)).toBe(true);
expect(hits.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore);
});
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,
it.each([
["unlearned below level 15", 14, "rom-mage", 80],
["wrong secondary class", 80, "rom-warrior", 80],
["missed attack", 80, "rom-mage", 255],
] as const)("does not add Chaos Dagger Fire damage for %s", (_label, level, secondaryClassId, targetLevel) => {
clearMobRuntimeRegistry();
const gear = { ...useCombatStore.getState().gearStats, hitRating: targetLevel === 255 ? 0 : 1_000_000 };
const mainHand = testWeapon("dagger", `chaos-dagger-gated-main-${level}-${secondaryClassId}-${targetLevel}`, {
...gear,
meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 },
});
const offHand = {
...testWeapon("dagger", `chaos-dagger-gated-off-${level}-${secondaryClassId}-${targetLevel}`, {
...gear,
meleeWeapon: { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 },
}),
inventorySlot: "off-hand" as const,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId, raceId: "rom-human", level,
inventory: [mainHand, offHand],
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
});
useCombatStore.getState().registerMob("chaos-dagger-gated-target", {
name: "Target", maxHealth: 1_000_000, level: targetLevel,
});
setMobPosition("chaos-dagger-gated-target", [0, 0, 3]);
useCombatStore.setState({
autoAttackTargetId: "chaos-dagger-gated-target",
nextAutoAttackAt: 1_500,
nextOffhandAutoAttackAt: 1_500,
});
for (let now = 1_500; now <= 20_000; now += 250) {
useCombatStore.getState().tick(0.25, now);
if (now === 1_500 && targetLevel === 255) break;
}
const damage = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.targetActorId === "chaos-dagger-gated-target"
));
expect(damage.some((event) => event.school === "fire")).toBe(false);
});
it("applies Off Hand Mastery damage only to the RoM Rogue player's equipped off-hand swing", () => {
const simulateOffhand = (masteryActive: boolean) => {
const weaponStats = {
...useCombatStore.getState().gearStats,
hitRating: 1_000_000,
meleeWeapon: { damageMin: 2_000, damageMax: 2_000, damageSchool: "physical" as const, speedMs: 2_000 },
};
const mainHand = testWeapon("dagger", `rom-offhand-main-${masteryActive}`, weaponStats);
const offHand = {
...testWeapon("dagger", `rom-offhand-second-${masteryActive}`, weaponStats),
inventorySlot: "off-hand" as const,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue",
raceId: "rom-human",
level: 80,
inventory: [mainHand, offHand],
equipment: { player: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId } },
});
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
))).toBe(true);
useCombatStore.getState().registerMob("rom-offhand-mastery-target", {
name: "Off-hand Mastery Target", maxHealth: 10_000_000, level: 80, xpReward: 0,
});
setMobPosition("rom-offhand-mastery-target", [0, 0, 3]);
useCombatStore.setState((state) => ({
...(masteryActive ? {} : { auras: state.auras.filter((aura) => (
!aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
)) }),
autoAttackTargetId: "rom-offhand-mastery-target",
nextAutoAttackAt: 1_500,
nextOffhandAutoAttackAt: 1_500,
}));
const presentations: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") presentations.push(message.event);
});
for (let now = 1_500; now <= 20_000; now += 250) {
useCombatStore.getState().tick(0.25, now);
if (presentations.some((event) => event.abilityId === "player-auto-attack-offhand")) break;
}
unsubscribe();
const combat = useCombatStore.getState();
return {
offhandSwingPresented: presentations.some((event) => event.abilityId === "player-auto-attack-offhand"),
totalDamage: combat.combatEvents
.filter((event) => event.kind === "damage" && event.targetActorId === "rom-offhand-mastery-target")
.reduce((total, event) => total + event.effectiveAmount, 0),
};
};
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);
const withoutMastery = simulateOffhand(false);
const withMastery = simulateOffhand(true);
expect(withoutMastery.offhandSwingPresented).toBe(true);
expect(withMastery.offhandSwingPresented).toBe(true);
expect(withMastery.totalDamage).toBeGreaterThan(withoutMastery.totalDamage);
});
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
@@ -391,13 +571,13 @@ describe("RoM Rogue source mechanics", () => {
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", () => {
it("applies Day of Rain's exact 50% miss rate only while Chaos Dagger is learned", () => {
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,
"rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG], 6,
);
expect(beforeBuff).toBeNull();
expect(withBuff).toBe(56);
@@ -410,12 +590,12 @@ describe("RoM Rogue source mechanics", () => {
rules: { baseMissChancePercent: withBuff! },
}).missPercent).toBe(50);
expect(romRogueMageDayOfRainBaseMissPercent(
"rom-rogue", "rom-warrior", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], 6,
"rom-rogue", "rom-warrior", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_CHAOS_DAGGER_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 => {
it("uses the learned Chaos Dagger hit penalty in Day of Rain's resolved combat result", () => {
const outcomeFor = (now: number, chaosDaggerLearned: boolean): string | undefined => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30,
@@ -423,8 +603,10 @@ describe("RoM Rogue source mechanics", () => {
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);
if (!chaosDaggerLearned) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG)),
}));
}
expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, now).ok).toBe(true);
return useCombatStore.getState().combatEvents.find((event) => (
@@ -444,17 +626,17 @@ describe("RoM Rogue source mechanics", () => {
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);
it("marks the learned Chaos Dagger passive for Day of Rain's hit-rate rider", () => {
const chaosDagger = abilityById("rom-rogue-rom-mage-elite-15")!;
expect(chaosDagger.passive).toBe(true);
expect(chaosDagger.costs).toEqual([]);
expect(chaosDagger.description).toContain("lists 25 MP");
expect(chaosDagger.description).toContain("no Mana charge is modeled");
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)
aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_CHAOS_DAGGER_TAG)
))).toBe(true);
});
+47
View File
@@ -1,5 +1,13 @@
export const ROM_ROGUE_DAGGER_MASTERY_TAG = "rom-dagger-mastery";
export const ROM_ROGUE_TREASURE_HUNTER_TAG = "rom-treasure-hunter";
export const ROM_ROGUE_OFF_HAND_MASTERY_TAG = "rom-off-hand-mastery";
export const ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT = 4;
export const ROM_ROGUE_OFF_HAND_MASTERY_ACCURACY_PERCENT = 4;
export const ROM_ROGUE_COERCE_TAG = "rom-rogue-coerce";
export const ROM_ROGUE_COERCE_PASSIVE_SPELL_ID = 493019;
export const ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID = 490323;
export const ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER = 1.202;
export const ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION = 5;
export const ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID = 491537;
export const ROM_ROGUE_INSTANT_SHADOW_READY_TAG = "rom-rogue-instant-shadow-ready";
@@ -37,6 +45,45 @@ const NO_SHADOW_WALKER_ADJUSTMENT: RomRogueAbilityAdjustment = Object.freeze({
cooldownIncreaseMs: 0,
});
/** Coerce reduces only Low Blow's Energy cost for a Rogue/Scout who learned the passive. */
export function romRogueCoerceAbilityAdjustment(
classId: string,
secondaryClassId: string | null | undefined,
abilitySource: string | undefined,
abilitySpellId: number,
passiveSpellIds: readonly number[],
): RomRogueAbilityAdjustment {
if (classId !== "rom-rogue"
|| secondaryClassId !== "rom-scout"
|| abilitySource !== "runewaker"
|| abilitySpellId !== ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID
|| !passiveSpellIds.includes(ROM_ROGUE_COERCE_PASSIVE_SPELL_ID)) {
return NO_SHADOW_WALKER_ADJUSTMENT;
}
return {
allowsCombatUse: false,
energyCostReduction: ROM_ROGUE_COERCE_ENERGY_COST_REDUCTION,
cooldownIncreaseMs: 0,
};
}
export interface RomRogueCoerceDamageContext {
readonly classId: string;
readonly abilitySource: string | undefined;
readonly abilitySpellId: number | undefined;
readonly passiveTags: readonly string[];
}
/** Current CoA Coerce adds 20.2% damage to Low Blow for a Rogue/Scout. */
export function romRogueCoerceLowBlowDamageMultiplier(context: RomRogueCoerceDamageContext): number {
return context.classId === "rom-rogue"
&& context.abilitySource === "runewaker"
&& context.abilitySpellId === ROM_ROGUE_COERCE_LOW_BLOW_SPELL_ID
&& context.passiveTags.includes(ROM_ROGUE_COERCE_TAG)
? ROM_ROGUE_COERCE_DAMAGE_MULTIPLIER
: 1;
}
/** Shadow Walker's exact Rogue/Priest payload applies only to Sneak Attack. */
export function romRogueShadowWalkerAdjustment(
classId: string,
@@ -1,161 +0,0 @@
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 }),
]));
});
});
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff. Show more