Update Healer Man server

This commit is contained in:
phenom committed 2026-09-28 22:46:45 -04:00
1 parent 9d7e0e2f9e
commit 35dfdaed61
94 files changed
+17034 -418

No files matched your search

+4
View File
@@ -98,6 +98,10 @@ function InputBridge() {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen || useBiteSizedLayoutsStore.getState().editorMode === "edit";
},
movementBlocked: () => {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen;
},
setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active),
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
setActionLayer: (layer) => useGameStore.getState().setActionLayer(layer),
+45 -12
View File
@@ -149,7 +149,9 @@ export interface AbilityHealingRecipients {
readonly center?: "caster" | "ability-target";
readonly radius?: number;
readonly maximumTargets?: number;
readonly selection?: "random";
readonly falloffPerJump?: number;
readonly selection?: "random" | "lowest-max-health";
readonly excludeCaster?: boolean;
}
export interface AbilityDamageResource {
@@ -166,12 +168,21 @@ 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 } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number } & 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 } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
| {
readonly kind: "summon";
readonly creatureId: number;
readonly coefficient: number;
readonly durationMs: number;
/** Source-defined pulse for stationary healing totems. */
readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number };
/** 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: "combo"; readonly amount: number }
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
@@ -180,7 +191,7 @@ export type AbilityEffect =
readonly kind: "apply-aura";
readonly aura: AuraDefinition;
/** Some hostile spells apply their beneficial rider to the caster. */
readonly recipient?: "ability-target" | "caster";
readonly recipient?: "ability-target" | "caster" | "pet";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
/** Some party effects benefit allies but exclude the caster. */
@@ -218,11 +229,18 @@ export type AbilityEffect =
| { 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 }
| { readonly kind: "totem"; readonly action: "destroy-all" }
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
| {
readonly kind: "rune";
readonly action: "activate";
readonly count: number;
readonly runeType?: "blood" | "frost" | "unholy";
readonly convertToDeathMs?: number;
}
| {
readonly kind: "cooldown-reset";
readonly mode: "reset" | "reduce";
readonly abilityName?: string;
readonly excludeAbilityNames?: readonly string[];
readonly amountMs?: number;
/** Fraction of the target ability's remaining cooldown removed at execution time. */
readonly amountPercent?: number;
@@ -907,7 +925,7 @@ const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicChannelTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
@@ -950,7 +968,7 @@ async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({
WOW335_ABILITIES_BY_CLASS,
WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
}) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
@@ -958,8 +976,8 @@ async function preloadClassicAbilityCatalog(): Promise<void> {
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
for (const [spellId, ability] of Object.entries(WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
classicChannelTriggeredAbilities.set(Number(spellId), ability);
for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
classicTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
classicLoad = null;
@@ -1026,7 +1044,7 @@ export function triggeredAbilityBySpellId(
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
?? classicChannelTriggeredAbilities.get(spellId)
?? classicTriggeredAbilities.get(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
@@ -1048,6 +1066,21 @@ export function abilitiesForCharacter(
return [...primary, ...secondaryGeneral, ...elites];
}
/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */
export function naturePowerMaximumForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
level: number,
): number {
const hasBrillianceOfNature = abilitiesForCharacter(classId, secondaryClassId).some((ability) => (
ability.source === "runewaker"
&& ability.dbcSpellId === 494931
&& ability.passive
&& level >= ability.unlockLevel
));
return hasBrillianceOfNature ? 15 : 10;
}
export function abilityRankForLevel(
ability: AbilityDefinition,
playerLevel: number,
+8
View File
@@ -0,0 +1,8 @@
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
/** Give placement controls keyboard focus without collapsing the editor panel. */
export function focusBiteSizedEditorScene(): void {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
document.querySelector<HTMLElement>(".scene-shell")?.focus({ preventScroll: true });
useBiteSizedLayoutsStore.setState({ panelFocused: false });
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import { naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import {
normalizeActorResourcePool,
type CombatActorState,
@@ -10,7 +10,7 @@ import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
: id === "nature-power" ? 10
: id === "nature-power" ? naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level)
: id === "psi" ? 6
: 100;
}
+1
View File
@@ -20,6 +20,7 @@ export interface DeathKnightRuneState {
readonly baseType: Exclude<DeathKnightRuneType, "death">;
readonly type: DeathKnightRuneType;
readonly readyAt: number;
readonly deathRuneExpiresAt?: number;
}
export interface CombatActorCastState {
+68
View File
@@ -124,6 +124,43 @@ describe("combat auras", () => {
expect(result.auras[0].absorbs.map((absorb) => absorb.remaining)).toEqual([0, 0]);
});
it("supports fractional, multi-school absorbs while keeping the capacity shared", () => {
const auras = apply(buff({
id: "anti-magic-shell",
absorbs: [{
id: "shell",
amount: 120,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
fraction: 0.75,
}],
}));
const magical = absorbDamage(auras, {
targetId: "player",
amount: 100,
school: "fire",
now: 100,
});
expect(magical).toMatchObject({ absorbed: 75, remainingDamage: 25 });
expect(magical.auras[0].absorbs[0].remaining).toBe(45);
const physical = absorbDamage(magical.auras, {
targetId: "player",
amount: 40,
school: "physical",
now: 200,
});
expect(physical).toMatchObject({ absorbed: 0, remainingDamage: 40 });
expect(physical.auras[0].absorbs[0].remaining).toBe(45);
const capped = absorbDamage(magical.auras, {
targetId: "player",
amount: 100,
school: "shadow",
now: 300,
});
expect(capped).toMatchObject({ absorbed: 45, remainingDamage: 55 });
});
it("supports guaranteed and failed deterministic proc rolls", () => {
const definition = buff({
procs: [{
@@ -149,6 +186,37 @@ describe("combat auras", () => {
expect(failed.triggered).toHaveLength(0);
});
it("spends melee-only proc charges only on landed melee attack events", () => {
const auras = apply(buff({
procs: [{
id: "melee-charge",
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
charges: 5,
action: { kind: "custom", id: "melee-only" },
}],
}));
const rangedHit = evaluateAuraProcs(auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
attackKind: "ranged",
}, 100, () => 0);
expect(rangedHit.triggered).toHaveLength(0);
expect(rangedHit.auras[0].procStates[0].remainingCharges).toBe(5);
const meleeHit = evaluateAuraProcs(rangedHit.auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
attackKind: "melee",
}, 200, () => 0);
expect(meleeHit.triggered).toHaveLength(1);
expect(meleeHit.auras[0].procStates[0].remainingCharges).toBe(4);
});
it("enforces proc internal cooldowns and consumes charges only on success", () => {
const roll = vi.fn(() => 0);
const definition = buff({
+40 -3
View File
@@ -69,8 +69,14 @@ export type AuraModifier =
export interface AuraAbsorbDefinition {
readonly id: string;
readonly amount: number;
/** Number of matching attacks this absorb fully negates instead of consuming a point pool. */
readonly hitCharges?: number;
/** Omit to absorb all damage schools. */
readonly school?: DamageSchool;
/** Optional school set for absorbs such as Anti-Magic Shell. */
readonly schools?: readonly DamageSchool[];
/** Fraction of each incoming packet this absorb can consume; defaults to 1. */
readonly fraction?: number;
/** Defaults to true, granting `amount` of capacity for each aura stack. */
readonly perStack?: boolean;
}
@@ -78,7 +84,10 @@ export interface AuraAbsorbDefinition {
export interface AuraAbsorbState {
readonly id: string;
readonly school?: DamageSchool;
readonly schools?: readonly DamageSchool[];
readonly fraction?: number;
readonly remaining: number;
readonly remainingHits?: number;
}
export type AuraProcAction =
@@ -114,6 +123,8 @@ export type AuraProcAction =
export interface AuraProcDefinition {
readonly id: string;
readonly triggers: ProcTrigger | readonly ProcTrigger[];
/** Limit attack-triggered procs to the listed landed attack types. */
readonly attackKinds?: readonly AttackKind[];
/** Probability from 0 through 1. */
readonly chance: number;
readonly internalCooldownMs?: number;
@@ -157,6 +168,8 @@ export interface AuraDefinition {
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
readonly redirectDamagePercent?: number;
/** Shared source-max-health cap for Paladin Divine Sacrifice redirection. */
readonly redirectDamageCapPercent?: number;
readonly dispelCategory?: DispelCategory;
readonly dispelPriority?: number;
readonly modifiers?: readonly AuraModifier[];
@@ -174,6 +187,8 @@ export interface ActiveAura {
readonly stacks: number;
readonly absorbs: readonly AuraAbsorbState[];
readonly procStates: readonly AuraProcState[];
/** Cumulative redirected damage for an aura that owns a shared redirect cap. */
readonly redirectedDamage?: number;
/** Number of Fire critical hits landed during the current Combustion buff. */
readonly combustionCriticalHits?: number;
}
@@ -214,6 +229,7 @@ export interface AbsorbDamageRequest {
readonly amount: number;
readonly school: DamageSchool;
readonly now: number;
readonly countsAsAttack?: boolean;
}
export interface AbsorbConsumption {
@@ -288,7 +304,12 @@ function newAbsorbStates(definition: AuraDefinition, stacks: number): readonly A
return (definition.absorbs ?? []).map((absorb) => ({
id: absorb.id,
school: absorb.school,
schools: absorb.schools,
fraction: absorb.fraction,
remaining: nonNegative(absorb.amount) * (absorb.perStack === false ? 1 : stacks),
...(absorb.hitCharges === undefined
? {}
: { remainingHits: Math.max(0, Math.floor(nonNegative(absorb.hitCharges))) }),
}));
}
@@ -313,6 +334,7 @@ function makeAura(request: ApplyAuraRequest, stacks: number): ActiveAura {
stacks,
absorbs: newAbsorbStates(definition, stacks),
procStates: newProcStates(definition),
...(definition.redirectDamageCapPercent === undefined ? {} : { redirectedDamage: 0 }),
};
}
@@ -498,15 +520,28 @@ export function absorbDamage(
if (index < 0) continue;
const aura = next[index];
const absorbs = aura.absorbs.map((absorb) => {
if (remainingDamage <= 0 || (absorb.school !== undefined && absorb.school !== request.school)) {
if (remainingDamage <= 0
|| (absorb.school !== undefined && absorb.school !== request.school)
|| (absorb.schools !== undefined && !absorb.schools.includes(request.school))) {
return absorb;
}
const amount = Math.min(absorb.remaining, remainingDamage);
const packetFraction = Math.max(0, Math.min(1, absorb.fraction ?? 1));
const isChargedAttackAbsorb = absorb.remainingHits !== undefined
&& request.countsAsAttack === true
&& absorb.remainingHits > 0;
if (absorb.remainingHits !== undefined && !isChargedAttackAbsorb) return absorb;
const amount = isChargedAttackAbsorb
? remainingDamage * packetFraction
: Math.min(absorb.remaining, remainingDamage * packetFraction);
remainingDamage -= amount;
if (amount > 0) {
consumptions.push({ auraInstanceId: aura.instanceId, absorbId: absorb.id, amount });
}
return { ...absorb, remaining: absorb.remaining - amount };
return {
...absorb,
remaining: absorb.remaining - amount,
...(isChargedAttackAbsorb ? { remainingHits: absorb.remainingHits! - 1 } : {}),
};
});
next[index] = { ...aura, absorbs };
}
@@ -545,6 +580,8 @@ export function evaluateAuraProcs(
if (
proc === undefined ||
!procHasTrigger(proc, event.trigger) ||
(proc.attackKinds !== undefined
&& (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) ||
state.readyAt > now ||
state.remainingCharges === 0
) {
+44 -2
View File
@@ -47,7 +47,47 @@ export function availableDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
now: number,
): readonly DeathKnightRuneState[] {
return runes.filter((rune) => rune.readyAt <= now);
return normalizeDeathKnightRunes(runes, now).filter((rune) => rune.readyAt <= now);
}
export function normalizeDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
now: number,
): readonly DeathKnightRuneState[] {
return Object.freeze(runes.map((rune) => {
if (rune.deathRuneExpiresAt === undefined || rune.deathRuneExpiresAt > now) return rune;
const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune;
void _expiredAt;
return Object.freeze({ ...baseRune, type: rune.baseType });
}));
}
export function canActivateDeathKnightBloodTapRune(
runes: readonly DeathKnightRuneState[],
now: number,
): boolean {
return normalizeDeathKnightRunes(runes, now).some((rune) => rune.baseType === "blood" && rune.type === "blood");
}
export function activateDeathKnightBloodTapRune(
runes: readonly DeathKnightRuneState[],
now: number,
durationMs: number,
): readonly DeathKnightRuneState[] {
const current = [...normalizeDeathKnightRunes(runes, now)];
const candidate = current
.filter((rune) => rune.baseType === "blood" && rune.type === "blood")
.sort((left, right) => Number(right.readyAt > now) - Number(left.readyAt > now)
|| right.readyAt - left.readyAt || left.slot - right.slot)[0];
if (!candidate) return Object.freeze(current);
return Object.freeze(current.map((rune) => rune.slot === candidate.slot
? Object.freeze({
...rune,
type: "death" as const,
readyAt: Math.min(rune.readyAt, now),
deathRuneExpiresAt: now + Math.max(1, durationMs),
})
: rune));
}
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
@@ -60,7 +100,9 @@ export function spendDeathKnightRunes(
now: number,
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
): readonly DeathKnightRuneState[] | null {
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
const current = [...normalizeDeathKnightRunes(
runes?.length === 6 ? runes : createDeathKnightRunes(now), now,
)];
const spent = new Set<number>();
for (const type of ["blood", "frost", "unholy"] as const) {
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
+4 -1
View File
@@ -476,7 +476,10 @@ function scoreAbility(
targetId: context.selfId ?? null,
} : null;
}
if (categories.has("threat") && !damaging && !maintenanceBuff) {
// Some defensive spells also reduce the target's threat (for example
// Pain Suppression). Keep them eligible for mitigation scoring even when
// the healer does not currently hold enemy attention.
if (categories.has("threat") && !categories.has("mitigation") && !damaging && !maintenanceBuff) {
if (THREAT_REDIRECT_NAMES.has(ability.name) && context.role !== "tank" && durableTarget) {
return {
ability,
+181 -4
View File
@@ -522,7 +522,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().shield).toBe(0);
});
it("keeps Inner Focus as an on-use, one-heal effect", () => {
it("keeps Inner Focus as an on-use, next-spell effect", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
const ranks = { [talent.id]: 1 };
@@ -543,7 +543,7 @@ describe("combat store", () => {
))).toBe(true);
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(0);
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 1_000);
const heal = useCombatStore.getState().castAbility("wow335-priest-greater-heal", undefined, 1_000);
expect(heal.ok).toBe(true);
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
const completion = useCombatStore.getState().activeCast!.completesAt;
@@ -576,6 +576,40 @@ describe("combat store", () => {
.toMatchObject({ ok: false, reason: "global-cooldown" });
});
it("reduces Inner Focus's active cooldown when Improved Flash Heal is used", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const talent = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
const ranks = { [talent.id]: talent.maxRank };
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
useCombatStore.setState((state) => ({
talentRanks: ranks,
talentModifiers: talentModifiersForRanks(ranks, "priest"),
cooldowns: { ...state.cooldowns, [innerFocus.id]: 10_000 },
}));
const cast = useCombatStore.getState().castAbility("priest-flash-heal", {
targetActorId: null,
now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const completion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, completion);
expect(useCombatStore.getState().cooldowns[innerFocus.id]).toBe(8_000);
});
it("converts health into mana with Life Tap", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
useCombatStore.setState((state) => ({ health: 5_000, resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 } }));
const lifeTap = useCombatStore.getState().abilities.find((ability) => ability.name === "Life Tap")!;
const result = useCombatStore.getState().castAbility(lifeTap.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, healing: 0 });
expect(useCombatStore.getState()).toMatchObject({ health: 3_510, resource: 1_490 });
});
it("ticks damage over time and awards kill experience exactly once", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
registerTarget("ooze", [10, 0, 0], 5, 100);
@@ -839,6 +873,34 @@ describe("combat store", () => {
});
});
it("spreads only active Death Knight diseases with Pestilence and taunts with Death Grip", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
registerTarget("disease-primary", [2, 0, 0], 5_000);
registerTarget("disease-neighbor", [5, 0, 0], 5_000);
useCombatStore.getState().selectMob("disease-primary");
const abilities = useCombatStore.getState().abilities;
const spellId = (name: string) => {
const ability = abilities.find((entry) => entry.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
};
expect(useCombatStore.getState().castAbility(spellId("Icy Touch"), undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(spellId("Plague Strike"), undefined, 3_000).ok).toBe(true);
const neighborHealth = useCombatStore.getState().mobs["disease-neighbor"].health;
expect(useCombatStore.getState().castAbility(spellId("Pestilence"), undefined, 5_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["disease-neighbor"].health).toBe(neighborHealth);
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === "disease-neighbor")
.map((effect) => effect.tag).sort()).toEqual(["blood-plague", "frost-fever"]);
useCombatStore.getState().selectMob("disease-neighbor");
expect(useCombatStore.getState().castAbility(spellId("Death Grip"), undefined, 7_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["disease-neighbor"]).toMatchObject({
forcedTarget: { actorId: PLAYER_AGGRO_ID, endsAt: 10_000 },
});
});
it("swings at the resolved companion without redirecting player damage", () => {
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
usePartyStore.setState((state) => ({
@@ -1425,6 +1487,97 @@ describe("combat store", () => {
expect(seals[0].definition.name).toContain("Seal of Wisdom");
});
it("applies Free Will Root and Slow immunity to the selected Knight/Priest ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-priest",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("free-will-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
const freeWill = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491351);
expect(freeWill).toBeDefined();
const result = useCombatStore.getState().castAbility(freeWill!.id, {
targetActorId: memberId,
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: freeWill!.id });
const aura = useCombatStore.getState().auras.find((entry) => (
entry.targetId === memberId && entry.definition.id === "rom-free-will-immunity"
));
expect(aura).toMatchObject({
targetId: memberId,
definition: {
durationMs: 10_000,
controlImmunities: ["root", "slow"],
},
});
expect(useCombatStore.getState().auras.some((entry) => (
entry.targetId === PLAYER_AURA_ENTITY_ID && entry.definition.id === "rom-free-will-immunity"
))).toBe(false);
});
it("fans out Crystalline Protection and Acceleration only to party members in range", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", level: 80, xp: 0 });
usePartyStore.getState().initializeParty("warden-crystalline-test", 80, useCombatStore.getState().health);
const inRangeId = preparePartyMember(0, [6, 0, 0], Number.MAX_SAFE_INTEGER);
const outOfRangeId = preparePartyMember(1, [8, 0, 0], Number.MAX_SAFE_INTEGER);
const abilities = useCombatStore.getState().abilities;
const protection = abilities.find((ability) => ability.dbcSpellId === 498201)!;
const acceleration = abilities.find((ability) => ability.dbcSpellId === 498202)!;
expect(useCombatStore.getState().castAbility(protection.id, { now: 1_000 }).ok).toBe(true);
let auras = useCombatStore.getState().auras;
expect(auras.some((entry) => entry.targetId === inRangeId && entry.definition.name === "Crystalline Protection"
&& (entry.definition.absorbs?.length ?? 0) > 0)).toBe(true);
expect(auras.some((entry) => entry.targetId === outOfRangeId && entry.definition.name === "Crystalline Protection"))
.toBe(false);
expect(useCombatStore.getState().castAbility(acceleration.id, { now: 3_000 }).ok).toBe(true);
auras = useCombatStore.getState().auras;
const accelerationAuraFor = (targetId: string) => auras.filter((entry) => (
entry.targetId === targetId && entry.definition.name.startsWith("Crystalline Acceleration")
));
expect(accelerationAuraFor(inRangeId).flatMap((entry) => entry.definition.modifiers ?? []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
]));
expect(accelerationAuraFor(outOfRangeId)).toHaveLength(0);
});
it("applies Coat of Arms to its caster and the selected ally only", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warden",
secondaryClassId: "rom-warrior",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("warden-coat-of-arms-test", 80, useCombatStore.getState().health);
const selectedId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
const otherId = preparePartyMember(1, [3, 0, 0], Number.MAX_SAFE_INTEGER);
const coatOfArms = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494611)!;
const result = useCombatStore.getState().castAbility(coatOfArms.id, {
targetActorId: selectedId,
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: coatOfArms.id });
const auras = useCombatStore.getState().auras.filter((entry) => entry.definition.name === "Coat of Arms");
const hasBothDefenses = (targetId: string) => auras.some((entry) => (
entry.targetId === targetId
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor")
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense")
));
expect(hasBothDefenses(PLAYER_AURA_ENTITY_ID)).toBe(true);
expect(hasBothDefenses(selectedId)).toBe(true);
expect(hasBothDefenses(otherId)).toBe(false);
});
it("redirects Hand of Sacrifice damage to the paladin", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
@@ -1619,15 +1772,39 @@ describe("combat store", () => {
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === secondId)).toHaveLength(1);
});
it("requires and consumes a Rejuvenation or Regrowth HoT for Swiftmend", () => {
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.setState({ health: 1_000 });
const swiftmend = useCombatStore.getState().abilities.find((ability) => ability.name === "Swiftmend")!;
unlockReadySpell(swiftmend.id);
expect(useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 1_000 }))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("druid-rejuvenation", {
targetActorId: null,
now: 1_000,
}).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(true);
const result = useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 3_000 });
expect(result.ok).toBe(true);
expect(result.healing).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeGreaterThan(1_000);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(false);
});
it("keeps healing components of hostile abilities on the player", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 2 });
useCombatStore.setState({ resource: 100 });
registerTarget("death-strike-target", [2, 0, 0], 100, 0);
registerTarget("death-strike-target", [2, 0, 0], 1_000, 0);
const memberId = preparePartyMember(0, [2, 0, 0], 1, true);
useCombatStore.getState().damagePlayer(40);
const playerHealthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", [0, 0, 0], 1_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 1_000);
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 3_000);
expect(result.ok).toBe(true);
expect(result.damage).toBeGreaterThan(0);
expect(result.healing).toBeGreaterThan(0);
+4018 -209
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -9,10 +9,13 @@ export function cooldownResetCandidates(
abilities: readonly AbilityDefinition[],
sourceAbilityId: string,
abilityName?: string,
excludeAbilityNames: readonly string[] = [],
): readonly AbilityDefinition[] {
const excludedNames = new Set(excludeAbilityNames.map((name) => name.toLowerCase()));
const eligible = abilities.filter((candidate) => !excludedNames.has(candidate.name.toLowerCase()));
if (abilityName) {
const normalizedName = abilityName.toLowerCase();
return abilities.filter((candidate) => candidate.name.toLowerCase() === normalizedName);
return eligible.filter((candidate) => candidate.name.toLowerCase() === normalizedName);
}
return abilities.filter((candidate) => candidate.id !== sourceAbilityId);
return eligible.filter((candidate) => candidate.id !== sourceAbilityId);
}
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
describe("Death Knight Empower Rune Weapon runtime", () => {
beforeEach(() => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
});
it("restores all six spent runes when cast", () => {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon");
expect(ability).toBeDefined();
useCombatStore.setState((state) => ({
deathKnightRunes: state.deathKnightRunes.map((rune) => ({ ...rune, readyAt: 11_000 })),
}));
const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 });
expect(result.ok).toBe(true);
expect(useCombatStore.getState().deathKnightRunes).toHaveLength(6);
expect(useCombatStore.getState().deathKnightRunes.every((rune) => rune.readyAt === 0)).toBe(true);
});
it("generates the source-listed 25 Runic Power when cast", () => {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon");
expect(ability).toBeDefined();
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 });
expect(result.ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(25);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(25);
});
});
+379
View File
@@ -0,0 +1,379 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { installCombatClock } from "./combatClock";
import { createDeathKnightRunes } from "./combatActors";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { talentNodesForTree } from "./talentCatalog";
let now = 1_000;
let restoreClock: (() => void) | undefined;
function ability(name: string) {
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
if (!found) throw new Error("Missing Death Knight ability: " + name);
return found;
}
function learnActiveAbility(name: string) {
const found = ability(name);
if (found.talentNodeId) {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [found.talentNodeId!]: 1 },
}));
}
return found;
}
function registerTarget(id = "dk-target", position: readonly [number, number, number] = [2, 0, 0]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
function selectPartyMember(index = 0, raceId: "undead" | "human" = "human") {
const initial = usePartyStore.getState().members[index];
if (!initial) throw new Error("Expected a party member for the Death Knight test.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === initial.id
? { ...member, raceId, health: 1 }
: member),
}));
registerPartyRuntimePosition(initial.id, [2, 0, 0]);
usePartyStore.getState().selectMember(initial.id);
return initial.id;
}
function setButchery(rank: number, runicPower = 0) {
const node = talentNodesForTree("death-knight-blood").find((candidate) => candidate.name === "Butchery");
if (!node) throw new Error("Missing Death Knight Butchery talent.");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [node.id]: rank },
resource: runicPower,
resourcePools: { ...state.resourcePools, "runic-power": runicPower },
}));
}
function registerXpTarget(id: string, xpReward: number) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1, xpReward });
setMobPosition(id, [2, 0, 0]);
}
describe("Death Knight source-backed runtime mechanics", () => {
beforeEach(() => {
now = 1_000;
restoreClock = installCombatClock(() => now);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
usePartyStore.getState().initializeParty("death-knight-source-runtime", 80,
useCombatStore.getState().health, "tank", 5);
});
afterEach(() => {
restoreClock?.();
restoreClock = undefined;
});
it("heals a friendly Undead with Death Coil and rejects a living ally", () => {
const allyId = selectPartyMember(0, "human");
const deathCoil = ability("Death Coil");
const rejected = useCombatStore.getState().castAbility(deathCoil.id, {
targetActorId: allyId,
now,
});
expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" });
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === allyId
? { ...member, raceId: "undead" }
: member),
}));
now = 3_000;
const healed = useCombatStore.getState().castAbility(deathCoil.id, {
targetActorId: allyId,
now,
});
expect(healed.ok).toBe(true);
expect(healed.healing).toBeGreaterThan(0);
expect(usePartyStore.getState().members.find((member) => member.id === allyId)!.health).toBeGreaterThan(1);
});
it("uses Death Coil's damaging branch against a hostile living target", () => {
registerTarget();
const deathCoil = ability("Death Coil");
const attempts = [1_000, 3_000, 5_000].map((castAt) => {
now = castAt;
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
return useCombatStore.getState().castAbility(deathCoil.id, { now });
});
expect(attempts.every((result) => result.ok)).toBe(true);
expect(attempts.some((result) => result.damage > 0)).toBe(true);
expect(attempts.every((result) => result.healing === 0)).toBe(true);
});
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);
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.name === "Anti-Magic Shell");
expect(aura?.absorbs[0]).toMatchObject({
remaining: Math.round(useCombatStore.getState().maxHealth * 0.5),
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
});
now += 1;
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId)
?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75);
});
it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => {
const shell = ability("Anti-Magic Shell");
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
const addControlAttacker = (id: string, school: "shadow" | "physical") => {
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 1_000,
xpReward: 0,
attacks: [{
id: `${id}-stun`,
name: "Stunning attack",
delivery: "projectile",
target: "primary",
school,
animation: "attack",
range: 30,
cooldownMs: 1_000,
damageMultiplier: 0,
effect: { kind: "control", mechanic: "stun", durationMs: 5_000 },
}],
});
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().damageMob(id, 1, now);
return useCombatStore.getState().mobs[id].nextAttackAt;
};
const magicReadyAt = addControlAttacker("magic-stun", "shadow");
useCombatStore.getState().advanceMobCombat(magicReadyAt, [0, 0, 0]);
expect(useCombatStore.getState().mobs["magic-stun"].attackRevision).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBe(0);
const physicalReadyAt = addControlAttacker("physical-stun", "physical");
useCombatStore.getState().advanceMobCombat(physicalReadyAt, [0, 0, 0]);
expect(useCombatStore.getState().mobs["physical-stun"].attackRevision).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBeGreaterThan(physicalReadyAt);
});
it("places Anti-Magic Zone at the selected point and protects party members within 10 yards", () => {
const inRangeAlly = selectPartyMember(0, "human");
const outOfRangeAlly = usePartyStore.getState().members[1].id;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 1 })),
}));
registerPartyRuntimePosition(inRangeAlly, [5, 0, 0]);
registerPartyRuntimePosition(outOfRangeAlly, [11, 0, 0]);
const antiMagicZone = learnActiveAbility("Anti-Magic Zone");
const cast = useCombatStore.getState().castAbility(antiMagicZone.id, {
now,
groundPosition: [0, 0, 0],
});
expect(cast.ok).toBe(true);
expect(useCombatStore.getState().summons).toContainEqual(expect.objectContaining({
creatureId: 28306,
position: [0, 0, 0],
expiresAt: now + 10_000,
antiMagicZone: expect.objectContaining({ radius: 10, absorbFraction: 0.75 }),
}));
const partyAuras = useCombatStore.getState().auras.filter((aura) => (
aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone")
));
expect(partyAuras.map((aura) => aura.targetId)).toEqual(expect.arrayContaining([
PLAYER_AURA_ENTITY_ID,
inRangeAlly,
]));
expect(partyAuras.map((aura) => aura.targetId)).not.toContain(outOfRangeAlly);
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)?.absorbs[0]).toMatchObject({
remaining: expect.any(Number),
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
});
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)!.absorbs[0].remaining).toBeGreaterThan(10_000);
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(100);
registerPartyRuntimePosition(inRangeAlly, [11, 0, 0]);
now += 250;
useCombatStore.getState().tick(0.25, now);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === inRangeAlly
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))).toBe(false);
});
it("spends Bone Shield's four charges on landed damage while keeping its reduction non-stacking", () => {
const boneShield = learnActiveAbility("Bone Shield");
const boneCast = useCombatStore.getState().castAbility(boneShield.id, { now });
expect(boneCast.reason).toBeUndefined();
expect(boneCast.ok).toBe(true);
const auraId = useCombatStore.getState().auras.find((aura) => aura.definition.name === "Bone Shield")!.instanceId;
for (const remaining of [3, 2, 1]) {
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === auraId)?.stacks).toBe(remaining);
}
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(10);
});
it("converts exactly one spent Blood Rune for Blood Tap without activating a Frost Rune", () => {
const initialRunes = createDeathKnightRunes().map((rune) => (
rune.slot === 0 || rune.slot === 2 ? { ...rune, readyAt: now + 10_000 } : rune
));
useCombatStore.setState({ deathKnightRunes: initialRunes });
const bloodTap = ability("Blood Tap");
expect(useCombatStore.getState().castAbility(bloodTap.id, { now }).ok).toBe(true);
const runes = useCombatStore.getState().deathKnightRunes;
expect(runes.filter((rune) => rune.type === "death")).toEqual([
expect.objectContaining({ slot: 0, baseType: "blood", readyAt: now, deathRuneExpiresAt: now + 20_000 }),
]);
expect(runes.find((rune) => rune.slot === 2)).toMatchObject({ type: "frost", readyAt: now + 10_000 });
expect(runes.filter((rune) => rune.readyAt <= now)).toHaveLength(5);
});
it("consumes Deathchill only on Icy Touch, Howling Blast, Frost Strike, or Obliterate", () => {
registerTarget();
const deathchill = learnActiveAbility("Deathchill");
expect(useCombatStore.getState().castAbility(deathchill.id, { now }).ok).toBe(true);
const auraId = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-deathchill-next-critical")
))!.instanceId;
now = 3_000;
expect(useCombatStore.getState().castAbility(ability("Chains of Ice").id, { now }).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(true);
now = 5_000;
const icyTouch = ability("Icy Touch");
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.abilityId === icyTouch.id && event.kind === "damage" && event.critical
))).toBe(true);
});
it("heals Death Strike for five percent of maximum health per active disease", () => {
registerTarget();
const icyTouch = ability("Icy Touch");
const plagueStrike = ability("Plague Strike");
const deathStrike = ability("Death Strike");
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
now = 3_000;
useCombatStore.setState((state) => ({ ...state, health: 1 }));
const oneDisease = useCombatStore.getState().castAbility(deathStrike.id, { now });
expect(oneDisease.ok).toBe(true);
expect(oneDisease.healing).toBeGreaterThan(0);
now = 5_000;
expect(useCombatStore.getState().castAbility(plagueStrike.id, { now }).ok).toBe(true);
useCombatStore.setState((state) => ({
...state,
health: 1,
deathKnightRunes: createDeathKnightRunes(now),
}));
now = 7_000;
const twoDiseases = useCombatStore.getState().castAbility(deathStrike.id, { now });
expect(twoDiseases.ok).toBe(true);
expect(twoDiseases.healing).toBeGreaterThan(oneDisease.healing);
expect(twoDiseases.healing).toBeCloseTo(oneDisease.healing * 2, -1);
});
it.each([
{ rank: 1, expectedRunicPower: 10 },
{ rank: 2, expectedRunicPower: 20 },
])("grants Butchery rank $rank's runic power on an eligible player kill", ({ rank, expectedRunicPower }) => {
const targetId = `butchery-rank-${rank}`;
registerXpTarget(targetId, 1_000);
setButchery(rank);
const result = useCombatStore.getState().damageMob(targetId, 10, now);
expect(result.killed).toBe(true);
expect(useCombatStore.getState().resource).toBe(expectedRunicPower);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(expectedRunicPower);
});
it("caps Butchery at the runic power maximum", () => {
const targetId = "butchery-cap-target";
registerXpTarget(targetId, 1_000);
setButchery(2, 95);
useCombatStore.getState().damageMob(targetId, 10, now);
expect(useCombatStore.getState().resource).toBe(100);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100);
});
it("does not grant Butchery without the talent or for a target with no XP reward", () => {
const noTalentTargetId = "butchery-no-talent-target";
registerXpTarget(noTalentTargetId, 1_000);
setButchery(0);
useCombatStore.getState().damageMob(noTalentTargetId, 10, now);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
const noXpTargetId = "butchery-no-xp-target";
registerXpTarget(noXpTargetId, 0);
setButchery(2);
useCombatStore.getState().damageMob(noXpTargetId, 10, now + 1);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
});
it("does not grant Butchery for a party or pet killing blow", () => {
setButchery(2);
const partyActorId = usePartyStore.getState().members[0].id;
for (const [targetId, actorId] of [
["butchery-party-target", partyActorId],
["butchery-pet-target", "butchery-test-pet"],
] as const) {
registerXpTarget(targetId, 1_000);
useCombatStore.getState().damageMob(targetId, 10, now, {
actorId,
role: "damage",
abilityId: "test-non-player-kill",
});
}
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
});
it("applies Improved Rune Tap's rank-specific healing and cooldown reductions", () => {
useCombatStore.setState((state) => ({
...state,
health: 1,
talentRanks: { ...state.talentRanks, "talent-1941": 1 },
}));
const runeTap = ability("Rune Tap");
const baseline = useCombatStore.getState().castAbility(runeTap.id, { now });
expect(baseline.ok).toBe(true);
now = 3_000;
useCombatStore.setState((state) => ({
...state,
health: 1,
cooldowns: {},
deathKnightRunes: createDeathKnightRunes(now),
talentRanks: { ...state.talentRanks, "talent-1942": 3 },
}));
const improved = useCombatStore.getState().castAbility(runeTap.id, { now });
expect(improved.ok).toBe(true);
expect(improved.healing).toBeCloseTo(baseline.healing * 2, -1);
expect(useCombatStore.getState().cooldowns[runeTap.id]).toBe(now + 30_000);
});
});
+4 -1
View File
@@ -22,12 +22,15 @@ export function healingRecipientIds(
if (!center) return [];
const candidates = Object.values(actors)
.filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0)
.filter(actor => !effect.excludeCaster || actor.id !== sourceId)
.filter(actor => {
const position = positionFor(actor.id);
return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2
&& hasLineOfSight(center, position);
})
.sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
.sort((a, b) => effect.selection === "lowest-max-health"
? a.maxHealth - b.maxHealth || a.id.localeCompare(b.id)
: a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
if (effect.selection === "random" && candidates.length > 0) {
let hash = 2166136261;
for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619);
+40
View File
@@ -81,6 +81,46 @@ function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Parti
}
describe("remappable gameplay controls", () => {
it("allows editor movement and jumping while combat actions remain disabled", () => {
stubInputEventTargets();
const next = inputActionSpies(true);
next.movementBlocked = () => false;
const dispose = installInput(next);
for (const [code, expected] of [
["KeyW", { moveX: 0, moveForward: 1 }], ["KeyS", { moveX: 0, moveForward: -1 }],
["KeyA", { moveX: -1, moveForward: 0 }], ["KeyD", { moveX: 1, moveForward: 0 }],
] as const) {
keyboardEvent("keydown", code); expect(readInputSnapshot()).toMatchObject(expected);
keyboardEvent("keyup", code); expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
}
keyboardEvent("keydown", "Space"); expect(consumeJumpRequest()).not.toBeNull();
keyboardEvent("keyup", "Space");
for (const code of ["Digit1", "KeyE", "Tab", "F1"]) keyboardEvent("keydown", code);
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled();
expect(next.cycleTarget).not.toHaveBeenCalled(); expect(next.setPartyCommand).not.toHaveBeenCalled();
setExternalControllerMovement({ moveX: .7, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot().moveForward).toBeGreaterThan(0);
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled();
dispose();
});
it("still clears held editor movement on panel focus and blocking overlays", () => {
stubInputEventTargets();
const next = inputActionSpies(true);
next.movementBlocked = vi.fn(() => false); next.inputSuspended = vi.fn(() => false);
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1);
(next.inputSuspended as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.inputSuspended as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1);
(next.movementBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.movementBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("keeps moving forward when a legacy WoW skill also owns W", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
+7 -3
View File
@@ -46,6 +46,8 @@ export interface InputActions {
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
gameplayActionsBlocked: () => boolean;
/** Editors may disable combat actions while retaining movement and camera controls. */
movementBlocked?: () => boolean;
setCameraLookActive: (active: boolean) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
setActionLayer: (layer: ActionBindingLayer) => void;
@@ -243,6 +245,7 @@ export function installInput(nextActions: InputActions): () => void {
actions = nextActions;
const keyboardModifiers = new Set<string>();
const suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
const movementBlocked = () => nextActions.movementBlocked?.() ?? nextActions.gameplayActionsBlocked();
const publishActionLayer = () => {
const held = getControllerHeldTokens();
const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => {
@@ -265,6 +268,7 @@ export function installInput(nextActions: InputActions): () => void {
};
const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => {
if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false;
if (entry.continuous || entry.id === "jump") return !movementBlocked();
if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked();
if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false;
if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false;
@@ -303,11 +307,11 @@ export function installInput(nextActions: InputActions): () => void {
const key = inputKeyForEvent(event);
if (!key) return;
const bindings = bindingsFor("pc");
if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
if (movementBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
const matches = candidates("pc").filter((entry) => bindings[entry.id] === key)
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground"));
// Preserve ordinary movement while a modifier is held (e.g. Shift + W).
const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
const continuous = !movementBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); }
if (!matches.length) return;
const entry = matches[0];
@@ -389,7 +393,7 @@ export function consumeMouseLook(): readonly [number, number] {
}
export function readInputSnapshot(): InputSnapshot {
if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) {
if (isCapturingInput() || actions?.inputSuspended?.() || (actions?.movementBlocked?.() ?? actions?.gameplayActionsBlocked())) {
keys.clear();
return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false };
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
describe("Mage Blink runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
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) => {
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: mechanic });
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
origin: [0, 0, 0],
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: "wow335-mage-blink" });
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID))
.toEqual(expect.arrayContaining([
expect.objectContaining({
expiresAt: 2_000,
definition: expect.objectContaining({ controlImmunities: ["root"] }),
}),
expect.objectContaining({
expiresAt: 2_000,
definition: expect.objectContaining({ controlImmunities: ["stun"] }),
}),
]));
});
it("does not move away from a target when forward facing is unavailable", () => {
useCombatStore.getState().registerMob("blink-target", {
name: "Blink target",
maxHealth: 100,
xpReward: 0,
});
setMobPosition("blink-target", [5, 0, 0]);
useCombatStore.getState().selectMob("blink-target");
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]);
});
});
+21 -7
View File
@@ -84,7 +84,7 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
import { romCreatureFamilyTargetTags } from "./romCreatureFamilies";
import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides";
@@ -797,7 +797,7 @@ function partyAbilityTargetIsValid(
&& aura.definition.utilityTags?.includes("rom-charged"))) return false;
if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false;
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsRangedWeapon(ability)) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
@@ -805,6 +805,7 @@ function partyAbilityTargetIsValid(
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false;
if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) return false;
}
}
const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
@@ -1239,6 +1240,10 @@ function executePartyAbility(
specializationId: member.specializationId, role: member.role, level: member.level,
})["main-hand"]?.weaponType === "dagger",
casterMaxHealth: member.maxHealth,
targetHealthPercent: combat.mobs[targetId]
? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth)
: 1,
naturePower: member.resourcePools["nature-power"] ?? 0,
passiveTags: combat.auras.filter((aura) => aura.targetId === member.id)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
abilitiesForCharacter(member.classId, member.secondaryClassId)
@@ -1455,9 +1460,10 @@ function executePartyAbility(
}
} else if (effect.kind === "resource") {
for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) {
const resource = effect.resource ?? member.resourceType;
effectiveAmount += useCombatStore.getState().restoreActorResource(
friendlyTargetId,
effect.resource ?? member.resourceType,
resource,
effect.amount,
effect.percentage,
member.id,
@@ -1473,12 +1479,20 @@ function executePartyAbility(
runtimeMember,
useCombatStore.getState().actors[member.id],
);
const restoredPool = resourceMirror.resourcePools[resource];
if (restoredPool === undefined) continue;
runtimeMember = {
...runtimeMember,
resource: resourceMirror.resource,
maxResource: resourceMirror.maxResource,
resourcePools: resourceMirror.resourcePools,
lastResourceSpentAt: resourceMirror.lastResourceSpentAt,
...(resource === runtimeMember.resourceType
? { resource: restoredPool, maxResource: resourceMirror.maxResource }
: {}),
resourcePools: { ...runtimeMember.resourcePools, [resource]: restoredPool },
...(resourceMirror.lastResourceSpentAt[resource] === undefined
? {}
: { lastResourceSpentAt: {
...runtimeMember.lastResourceSpentAt,
[resource]: resourceMirror.lastResourceSpentAt[resource],
} }),
};
}
}
+6 -3
View File
@@ -44,6 +44,7 @@ import type { PartyRole, PartySize } from "./partyRoles";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
naturePowerMaximumForCharacter,
resourceProfileForClass,
type ResourceType,
} from "./abilityCatalog";
@@ -251,8 +252,10 @@ function memberMaxResource(
: profile.maximum;
}
function secondaryResourceMaximum(type: ResourceType): number {
if (type === "nature-power") return 10;
function secondaryResourceMaximum(type: ResourceType, member: PartyMember): number {
if (type === "nature-power") {
return naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level);
}
if (type === "psi") return 6;
return 100;
}
@@ -279,7 +282,7 @@ function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
resourcePools[type] = member.resource;
continue;
}
const maximum = secondaryResourceMaximum(type);
const maximum = secondaryResourceMaximum(type, member);
resourcePools[type] = Math.max(0, Math.min(
maximum,
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
+404 -31
View File
@@ -15,8 +15,12 @@ import {
type RomSourceComponent,
} from "./romAbilitySemantics";
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomKnightEffects } from "./romKnightOverrides";
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
import { overrideRomDruidEffects } from "./romDruidOverrides";
import { romPriestDescription } from "./romPriestTooltips";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -89,7 +93,38 @@ function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
}));
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">;
function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "costs">): readonly AbilityCost[] {
let resolved = costs(row.costs);
if (row.sourceSkillId === 490136) {
resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
}
if (![493536, 494217].includes(row.sourceSkillId) || resolved.some((entry) => entry.resource === "nature-power")) return resolved;
return [...resolved, { resource: "nature-power", amount: 1 }];
}
function cooldownMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "cooldownMs">): number {
// 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;
return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs;
}
function castTimeMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): number {
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"] {
return row.sourceSkillId === 491164 || row.sourceSkillId === 498770
? "channel"
: row.castTimeMs > 0 ? "cast" : "instant";
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">
& Partial<Pick<GeneratedSkill, "classId" | "secondaryClassId">>;
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "effect";
@@ -128,6 +163,16 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
return "ability-target";
}
function abilityTarget(row: Pick<GeneratedSemanticRow, "sourceSkillId" | "target">): GeneratedSemanticRow["target"] {
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.
if (row.sourceSkillId === 498770) return "friendly";
if (row.sourceSkillId === 490239) return "hostile";
if (row.sourceSkillId === 494217) return "self";
return row.target;
}
const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"];
function percent(value: number): number {
@@ -340,7 +385,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
if (semantic.kind === "aura") {
const recipient = auraRecipient(row, semantic);
const modifiers = auraModifiers(semantic);
const partyScope = /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const partyScope = [490248, 491335, 491644, 494027, 498201, 498202, 498219, 498649, 498719]
.includes(row.sourceSkillId)
|| (row.sourceSkillId === 498722 && semantic.sourceComponentIds.includes(621463))
|| /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const aura: AuraDefinition = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
@@ -354,7 +402,12 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
? { utilityTags: ["stealth"] }
: {}),
};
return { kind: "apply-aura", aura, recipient, ...(partyScope ? { scope: "party" as const } : {}), sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
return {
kind: "apply-aura", aura, recipient,
...(partyScope ? { scope: "party" as const } : {}),
...(row.sourceSkillId === 498719 ? { excludeCaster: true } : {}),
sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0,
};
}
if (semantic.kind === "absorb") {
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
@@ -369,7 +422,11 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
dispelCategory: "magic",
absorbs: [{ id: `rom-absorb-pool-${effectId}`, amount: Math.max(0, semantic.amount + semantic.perSkillLevel), school: combatSchool(semantic.school) }],
};
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
return {
kind: "apply-aura", aura, recipient,
...([491513, 494027, 498201].includes(row.sourceSkillId) ? { scope: "party" as const } : {}),
sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0,
};
}
if (semantic.kind === "proc") {
const recipient = auraRecipient(row, semantic);
@@ -390,7 +447,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: semantic.triggerSkillId };
}
if (semantic.kind === "resource") {
const resourceType = knownResource(semantic.resource);
const manaPercentRegen = semantic.resource === "mana-percent"
&& semantic.intervalSeconds !== null
&& semantic.intervalSeconds > 0;
const resourceType = manaPercentRegen ? "mana" : knownResource(semantic.resource);
if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) {
const amountPerSecond = semantic.amount / semantic.intervalSeconds;
const aura: AuraDefinition = {
@@ -398,15 +458,31 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
name: row.name,
disposition: semantic.operation === "drain" ? "debuff" : "buff",
durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
modifiers: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
modifiers: manaPercentRegen
? [{
kind: "stat",
stat: "mana-regeneration-max-percent-per-second",
operation: "flat",
value: (semantic.operation === "drain" ? -amountPerSecond : amountPerSecond) / 100,
}]
: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
};
return {
kind: "apply-aura",
aura,
recipient: row.sourceSkillId === 498529 || row.target === "self" ? "caster" : "ability-target",
...(row.sourceSkillId === 491644 ? { scope: "party" as const } : {}),
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: semantic.amount,
triggerSpellId: 0,
};
return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: semantic.amount, triggerSpellId: 0 };
}
if (semantic.operation === "drain") return {
kind: "resource-drain",
@@ -462,7 +538,137 @@ function stateAuraEffect(
};
}
function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name: string, duration: number): AbilityEffect {
return {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: `rom-state-${stateId}`,
name,
disposition: "debuff",
durationMs: duration,
utilityTags: [`rom-${stateId}`],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: row.sourceSkillId,
};
}
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
if (row.classId === "rom-warden" && row.sourceSkillId === 494610) {
// This passive is a trigger marker; its source damage reduction is applied
// to Frantic Briar's target, not permanently to the Warden.
return [stateAuraEffect(row, "will-attack", row.name, null)];
}
if (row.classId === "rom-warden" && row.sourceSkillId === 493344) {
return translateRomAbilitySemantics(row).map((semantic, index) => {
const effect = semanticEffect(row, semantic, index);
return effect.kind === "summon"
? { ...effect, durationMs: Number.MAX_SAFE_INTEGER }
: effect;
});
}
if (row.classId === "rom-warden" && row.sourceSkillId === 491668) {
let keptAbsorb = false;
return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return true;
if (keptAbsorb) return false;
keptAbsorb = true;
return true;
})
.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
recipient: "caster" as const,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((absorb) => ({
...absorb,
amount: 0,
hitCharges: 2,
})),
},
}
: effect);
}
if (row.classId === "rom-champion" && row.sourceSkillId === 498564) {
return [
...translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)),
{ kind: "pull", toward: "caster" },
];
}
if (row.sourceSkillId === 494611) {
const modifiers = [...new Map(translateRomAbilitySemantics(row)
.filter((semantic): semantic is Extract<RomAbilitySemantic, { kind: "aura" }> => semantic.kind === "aura")
.flatMap(auraModifiers)
.map((modifier) => [JSON.stringify(modifier), modifier] as const)).values()];
if (!modifiers.length) return null;
const aura: AuraDefinition = {
id: "rom-coat-of-arms",
name: row.name,
disposition: "buff",
durationMs: durationMs(Math.max(0, ...(row.source?.components ?? []).map((component) => Number(component.effectTime ?? 0)))),
dispelCategory: "magic",
modifiers,
};
const apply = (recipient: "caster" | "ability-target"): AbilityEffect => ({
kind: "apply-aura",
aura,
recipient,
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
});
return [apply("caster"), apply("ability-target")];
}
if (row.sourceSkillId === 491173) {
return [stateAuraEffect(row, "cripple-joint", row.name, null, {
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
})];
}
if (row.sourceSkillId === 491351) {
return [{
kind: "apply-aura",
aura: {
id: "rom-free-will-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 0),
controlImmunities: ["root", "slow"],
},
recipient: "ability-target",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498570) {
return [{
kind: "apply-aura",
aura: {
id: "rom-overrule-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 3),
controlImmunities: ["fear", "confuse", "sleep", "stun", "root", "slow"],
},
recipient: "caster",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498744) {
return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }];
}
@@ -524,10 +730,88 @@ function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[]
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
const specialized = specializedEffects(row);
if (specialized) return specialized;
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
const pairOverride = overrideRomPriestKnightEffects(row, translated)
const semantics = translateRomAbilitySemantics(row);
const translated = semantics.map((semantic, index) => semanticEffect(row, semantic, index))
.map((effect) => row.sourceSkillId === 493642 && effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
},
} : effect);
const druidOverride = row.classId === "rom-druid" ? overrideRomDruidEffects(row, translated) : null;
if (druidOverride) return druidOverride;
if (row.sourceSkillId === 498825) return [
...translated,
{ kind: "cooldown-reset", mode: "reset", abilityName: "Lightning" },
{ kind: "cooldown-reset", mode: "reset", abilityName: "Discharge" },
];
if (row.sourceSkillId === 491606 || row.sourceSkillId === 490078) {
const damageComponentId = row.sourceSkillId === 491606 ? 502050 : 502102;
const triggerComponentId = row.sourceSkillId === 491606 ? 502049 : 502103;
const triggerComponent = row.source?.components?.find((component) => component.id === triggerComponentId);
const intervalSeconds = triggerComponent?.triggers?.onTimeSeconds ?? (row.sourceSkillId === 491606 ? 2 : 3);
const durationSeconds = triggerComponent?.effectTime ?? (row.sourceSkillId === 491606 ? 6 : 18);
const ticks = Math.max(1, Math.round(durationSeconds / intervalSeconds));
const damageSemanticIndex = semantics.findIndex((semantic) => semantic.kind === "damage"
&& semantic.sourceComponentIds.includes(damageComponentId));
const damageSemantic = semantics[damageSemanticIndex];
const damageEffect = damageSemantic?.kind === "damage"
? semanticEffect(row, damageSemantic, damageSemanticIndex)
: null;
const periodicDamage: AbilityEffect | null = damageEffect?.kind === "damage" && row.sourceSkillId === 491606
? {
kind: "dot",
coefficient: damageEffect.coefficient,
...(damageEffect.school ? { school: damageEffect.school } : {}),
...(damageEffect.basePoints === undefined ? {} : { basePoints: damageEffect.basePoints }),
...(damageEffect.dieSides === undefined ? {} : { dieSides: damageEffect.dieSides }),
...(damageEffect.pointsPerLevel === undefined ? {} : { pointsPerLevel: damageEffect.pointsPerLevel }),
...(damageEffect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: damageEffect.coefficientPerLevel }),
...(damageEffect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: damageEffect.bonusPowerCoefficient }),
...(damageEffect.sourceLevel === undefined ? {} : { sourceLevel: damageEffect.sourceLevel }),
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-holy-light-domain",
}
: row.sourceSkillId === 490078
? {
kind: "dot",
coefficient: 0,
school: "holy",
basePoints: 0,
pointsPerLevel: 0,
sourceLevel: 1,
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-war-prayer-seal",
}
: null;
if (!periodicDamage) return translated;
const output: AbilityEffect[] = [periodicDamage];
if (row.sourceSkillId === 490078 && damageEffect?.kind === "damage") output.unshift(damageEffect);
if (row.sourceSkillId === 491606) {
semantics.forEach((semantic, index) => {
if (!semantic.sourceComponentIds.includes(502051)) return;
output.push(semanticEffect(row, semantic, index));
});
}
return output;
}
if (row.sourceSkillId === 491133) {
const vulnerableDuration = row.source?.components?.find((component) => component.id === 501502)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "vulnerable", "Vulnerable", durationMs(vulnerableDuration))];
}
if (row.sourceSkillId === 491136) {
const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))];
}
const pairOverride = overrideRomKnightEffects(row, translated)
?? overrideRomWarriorEffects(row, translated)
?? overrideRomPriestKnightEffects(row, translated)
?? overrideRomScoutRogueEffects(row, translated)
?? overrideRomMageWarlockEffects(row, translated);
?? overrideRomMageWarlockEffects(row, translated)
?? overrideRomWarlockEffects(row, translated);
if (pairOverride) return pairOverride;
const outputStrength = (effect: AbilityEffect): number => (
"coefficient" in effect ? effect.coefficient : 0
@@ -541,6 +825,42 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => (
effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect
));
if (row.sourceSkillId === 494025) {
return oneOutput("damage", true).map((effect) => effect.kind === "damage"
? { ...effect, falloffPerJump: 0.7 }
: effect);
}
if (row.sourceSkillId === 499558) {
const shot = semantics.find((semantic): semantic is Extract<RomAbilitySemantic, { kind: "damage" }> => (
semantic.kind === "damage" && semantic.sourceComponentIds.includes(620194)
));
const healSource = semantics.find((semantic) => semantic.sourceComponentIds.includes(501888));
const output: AbilityEffect[] = [];
if (shot) {
const shotEffect = semanticEffect(row, shot, semantics.indexOf(shot));
if (shotEffect.kind === "damage") output.push(shotEffect);
}
if (healSource && (healSource.kind === "damage" || healSource.kind === "heal")) {
const healSemantic: RomAbilitySemantic = { ...healSource, kind: "heal" };
const healEffect = semanticEffect({ ...row, target: "friendly" }, healSemantic, semantics.indexOf(healSource));
if (healEffect.kind === "heal") output.push({
...healEffect,
recipient: "ability-target",
scope: "party",
maximumTargets: 1,
selection: "lowest-max-health",
});
}
return output;
}
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warden" && row.sourceSkillId === 494323) {
return translated.filter((effect) => effect.kind === "heal").slice(0, 2).map((effect) => effect.kind === "heal"
? { ...effect, falloffPerJump: 0.5 }
: effect);
}
// Source order is the Nature's Power enhanced HoT (504529), then the
// unconditional baseline HoT (620446). Runtime selects exactly one.
if (row.sourceSkillId === 494021) return translated.filter((effect) => effect.kind === "hot").slice(0, 2);
// These source rows carry alternate/duplicate payloads, while their player
// tooltips specify a single projectile hit. The first attack component is
// the primary shot; executing every component also applies unrelated Shot
@@ -576,6 +896,26 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
} : effect);
}
if (row.sourceSkillId === 498770) {
const heal = translated.find((effect) => effect.kind === "heal");
const buffs = translated.filter((effect) => effect.kind === "apply-aura");
if (!heal) return translated;
const buff = buffs[0];
const defenseModifiers = buffs.flatMap((effect) => effect.kind === "apply-aura"
? effect.aura.modifiers ?? []
: []);
// Local text says four heals; preserve that count as four separate channel
// payloads so the runtime emits one equal portion on each of four pulses.
return [
...Array.from({ length: 4 }, () => heal.kind === "heal"
? { ...heal, recipient: "ability-target" as const }
: heal),
...(buff?.kind === "apply-aura" ? [{
...buff,
aura: { ...buff.aura, modifiers: defenseModifiers },
}] : []),
];
}
if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) {
return translated.filter((effect) => effect.kind === "apply-aura");
}
@@ -615,7 +955,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490438) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
// Neck Strike has a six-second NPC stun (component 500877); the separate
// 503863 component supplies the reduced three-second player duration.
const durationMsForTarget = durationMs(row.source?.components?.find((component) => component.id === 500877)?.effectTime ?? 6);
return [{ kind: "status", status: "stun", durationMs: durationMsForTarget, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 491172) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
@@ -673,12 +1016,30 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) within (?:a )?range)\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
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 {
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 };
if (sourceSkillId === 490136) return { shape: "circle", radius: 10 };
if (sourceSkillId === 491513) return { shape: "circle", radius: 5, maximumTargets: 6 };
if (sourceSkillId === 492636) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494021) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494026) return { shape: "circle", radius: 12 };
if (sourceSkillId === 494027) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 494217) return { shape: "circle", radius: 5 };
if (sourceSkillId === 494025) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 494323) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 499936) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498641) return { shape: "circle", radius: 20, maximumTargets: 6 };
if (sourceSkillId === 493540) return { shape: "line" };
if (sourceSkillId === 498658) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 498721) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498534) return { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 };
if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 };
@@ -686,22 +1047,32 @@ function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"]
return undefined;
}
function sourceRange(sourceSkillId: number, fallback: number): number {
// 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 = costs(row.costs);
const rankCosts = costsFor(row);
const target = abilityTarget({ ...row, target: row.target ?? parent.target });
return {
rank: row.rank,
spellId: row.sourceSkillId,
level: row.level,
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
costs: rankCosts,
effects: effects({
...row,
target: row.target ?? parent.target,
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
target,
source: row.source?.components?.length ? row.source : parent.source,
}),
};
@@ -713,7 +1084,8 @@ function ability(row: GeneratedSkill): AbilityDefinition {
const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId)
? row.secondaryClassId as RomClassId
: undefined;
const abilityCosts = costs(row.costs);
const abilityCosts = costsFor(row);
const target = abilityTarget(row);
return {
id: row.id,
classId,
@@ -724,19 +1096,20 @@ function ability(row: GeneratedSkill): AbilityDefinition {
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: row.target,
range: { min: 0, max: row.range },
target,
range: { min: 0, max: sourceRange(row.sourceSkillId, row.range) },
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: abilityCosts,
effects: effects(row),
effects: effects({ ...row, target, classId }),
passive: row.passive,
source: "runewaker",
sourceUnlockLevel: row.unlockLevel,
+279
View File
@@ -9,6 +9,12 @@ function named(name: string) {
return ability;
}
function sourceSkill(sourceSkillId: number) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === sourceSkillId);
if (!ability) throw new Error(`Missing RuneWaker source skill: ${sourceSkillId}`);
return ability;
}
function isMeaningfulEffect(effect: AbilityEffect): boolean {
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) {
@@ -53,11 +59,272 @@ describe("executable RuneWaker ability mappings", () => {
expect(named("Shout").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "stun" } }),
]));
expect(named("Shout")).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
cooldownMs: 40_000,
cost: { resource: "rage", amount: 15 },
});
expect(named("Cleanse").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("keeps Priest and Priest/Scout support casts on friendly targets", () => {
for (const name of ["Grace of Life", "Amplified Attack"]) {
const ability = named(name);
expect(ability.target, name).toBe("friendly");
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]));
}
const curingShot = named("Curing Shot");
expect(curingShot.target).toBe("friendly");
expect(curingShot.effects.map((effect) => effect.kind)).toEqual(expect.arrayContaining(["heal", "hot"]));
expect(curingShot.effects.some((effect) => effect.kind === "dot")).toBe(false);
});
it("fans Priest-secondary class buffs out to the described party recipients", () => {
for (const sourceId of [491335, 498649]) {
const auras = sourceSkill(sourceId).effects.filter((effect) => effect.kind === "apply-aura");
expect(auras.length, `source ${sourceId}`).toBeGreaterThan(0);
expect(auras.every((effect) => effect.scope === "party"), `source ${sourceId}`).toBe(true);
}
const salvation = sourceSkill(498719).effects.filter((effect) => effect.kind === "apply-aura");
expect(salvation.length).toBeGreaterThan(0);
expect(salvation.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true);
const advance = sourceSkill(498722).effects.filter((effect) => effect.kind === "apply-aura");
const partyBuffs = advance.filter((effect) => effect.scope === "party");
const casterPenalties = advance.filter((effect) => effect.scope !== "party");
expect(partyBuffs.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0
)))).toBe(true);
expect(casterPenalties.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && ["armor", "magical-defense"].includes(modifier.stat) && modifier.value < 0
)))).toBe(true);
expect(casterPenalties.every((effect) => effect.recipient === "caster")).toBe(true);
});
it("keeps Scout/Knight Healing Shot damage and lowest-max-health party heal separate", () => {
const healingShot = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-rom-knight-elite-20")!;
expect(healingShot).toMatchObject({ target: "hostile" });
expect(healingShot.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
expect.objectContaining({
kind: "heal", scope: "party", maximumTargets: 1, selection: "lowest-max-health",
}),
]));
});
it("requires Nature's Power for Druid/Scout Group Exorcism", () => {
const exorcism = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-druid-rom-scout-elite-30")!;
expect(exorcism).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(exorcism.costs).toEqual(expect.arrayContaining([
expect.objectContaining({ resource: "nature-power", amount: 1 }),
]));
expect(exorcism.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("roots nearby enemies with Knight Shackles of Light", () => {
const shackles = named("Shackles of Light");
expect(shackles).toMatchObject({ target: "self", radius: 10 });
expect(shackles.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "root" } }),
]));
});
it("maps Scout Cripple Joint to its 55% Joint Blow root proc", () => {
const crippleJoint = sourceSkill(491173);
expect(crippleJoint.passive).toBe(true);
expect(crippleJoint.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
utilityTags: ["rom-cripple-joint"],
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
}),
}),
]);
expect(sourceSkill(490420)).toMatchObject({ name: "Joint Blow", dbcSpellId: 490420 });
});
it("uses Neck Strike's sourced six-second NPC stun instead of an unsupported ten seconds", () => {
expect(sourceSkill(490438).effects).toEqual([{
kind: "status",
status: "stun",
durationMs: 6_000,
maxTargets: 1,
breakOnDamage: true,
}]);
});
it("gives Champion nearby and fan spells their source-shaped target areas", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(bySpellId(498481)).toMatchObject({ target: "self", radius: 10 });
expect(bySpellId(498537)).toMatchObject({ target: "self", radius: 7.5 });
expect(bySpellId(498721)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
});
expect(bySpellId(498534)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 },
});
});
it("restores Rune Pulse Rage on the Champion after its hostile hit", () => {
const runePulse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498529)!;
expect(runePulse.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "rage", property: "regeneration" }),
]) }),
}),
]));
});
it("puts Scout/Mage Aggro Transfer mana regeneration on the selected ally", () => {
const aggroTransfer = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493255)!;
expect(aggroTransfer.target).toBe("friendly");
expect(aggroTransfer.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "mana", property: "regeneration" }),
]) }),
}),
]));
});
it("preserves Druid elite healing and chain targeting semantics", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
const arrows = bySpellId(494021);
expect(arrows).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 20, maximumTargets: 5 } });
expect(arrows.effects.map((effect) => effect.kind)).toEqual(["hot", "hot"]);
const arrowHots = arrows.effects.filter((effect): effect is Extract<typeof effect, { kind: "hot" }> => effect.kind === "hot");
expect(arrowHots).toHaveLength(2);
expect(arrowHots[0]!.basePoints).toBeCloseTo(23.6);
expect(arrowHots[1]!.basePoints).toBeCloseTo(18.88);
const earthChain = bySpellId(494025);
expect(earthChain).toMatchObject({ targeting: { shape: "chain", maximumTargets: 3, jumpRange: 6 } });
expect(earthChain.effects.filter((effect) => effect.kind === "damage")).toEqual([
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
]);
const ripple = bySpellId(494323);
expect(ripple).toMatchObject({ targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(ripple.effects.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ falloffPerJump: 0.5 }),
expect.objectContaining({ falloffPerJump: 0.5 }),
]);
expect(bySpellId(493540).targeting?.shape).toBe("line");
expect(bySpellId(494217)).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(bySpellId(494217).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", relationship: "friendly", mode: "cleanse" }),
]));
expect(bySpellId(494027)).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(bySpellId(494026).targeting).toMatchObject({ shape: "circle", radius: 12 });
});
it("models Champion Overrule as control immunity instead of applying control", () => {
const overrule = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498570)!;
expect(overrule.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 3_000,
controlImmunities: expect.arrayContaining(["stun", "root", "slow"]),
}),
}),
]));
expect(overrule.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("models Free Will as a 10-second Root and Slow immunity for its friendly target", () => {
const freeWill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491351)!;
expect(freeWill).toMatchObject({ target: "friendly" });
expect(freeWill.effects).toEqual([expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({
disposition: "buff",
durationMs: 10_000,
controlImmunities: ["root", "slow"],
}),
})]);
expect(freeWill.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("applies Fire Ward to the Priest's party", () => {
expect(named("Fire Ward").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
});
it("fans out Warden Crystalline Protection and Acceleration within their source radius", () => {
const protection = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498201)!;
const acceleration = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498202)!;
for (const ability of [protection, acceleration]) {
expect(ability).toMatchObject({
target: "self",
radius: 7.5,
targeting: { shape: "circle", radius: 7.5 },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
}
expect(protection.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
scope: "party",
aura: expect.objectContaining({ absorbs: expect.arrayContaining([expect.any(Object)]) }),
}),
]));
expect(acceleration.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
]));
});
it("applies Coat of Arms defenses to both the caster and selected ally", () => {
const coatOfArms = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494611)!;
expect(coatOfArms.target).toBe("friendly");
expect(coatOfArms.effects).toHaveLength(2);
expect(coatOfArms.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "armor", operation: "percent", value: 0.3 }),
expect.objectContaining({ kind: "stat", stat: "magical-defense", operation: "percent", value: 0.3 }),
]),
}),
}),
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]);
});
it("retains every executable part of multi-effect abilities", () => {
const kinds = named("Sneak Attack").effects.map((effect) => effect.kind);
expect(kinds).toEqual(["damage", "apply-aura", "dot"]);
@@ -170,6 +437,18 @@ describe("executable RuneWaker ability mappings", () => {
]));
});
it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => {
expect(sourceSkill(493642).effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["rom-briar-shield"]) }),
}),
]));
expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
]));
});
it("models the Knight's Holy Seal build-and-spend loop", () => {
expect(named("Holy Strike").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: 1 },
+1
View File
@@ -94,6 +94,7 @@ export interface RomSourceComponent {
}
export interface RomGeneratedAbilityLike {
readonly classId?: string;
readonly sourceSkillId?: number;
readonly name: string;
readonly description: string;
+117
View File
@@ -0,0 +1,117 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition } from "./combatAuras";
export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = {
id: "rom-champion-forge-stack",
name: "Forge",
description: "Each normal attack hit grants 2% physical attack for 8 seconds, stacking up to 5 times.",
disposition: "buff",
durationMs: 8_000,
maxStacks: 5,
stackBehavior: "add",
modifiers: [{
kind: "stat",
stat: "attack-power",
operation: "percent",
value: 0.02,
perStack: true,
}],
};
export const ROM_CHAMPION_RUNE_OVERLOAD_TAG = "rom-champion-rune-overload";
export const ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND = 15;
const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = {
id: "rom-champion-forge-active",
name: "Forge",
description: "For 5 minutes, normal attack hits build Forge stacks and restore Rage.",
disposition: "buff",
durationMs: 300_000,
utilityTags: ["rom-champion-forge-active"],
procs: [{
id: "rom-champion-forge-normal-hit",
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
action: { kind: "custom", id: "rom-champion-forge-hit" },
}],
};
function scaleDamage(effect: Extract<AbilityEffect, { kind: "damage" }>, factor: number): AbilityEffect {
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
};
}
/** Apply source-backed Champion cast branches that need runtime state. */
export function romChampionCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
targetHealthPercent: number,
casterMaxHealth: number,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-champion") return effects;
switch (ability.dbcSpellId) {
case 498584: // Champion/Warrior Determination Rune is a 30%-of-max-HP caster shield.
if (ability.secondaryClassId !== "rom-warrior") return effects;
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((absorb) => ({
...absorb,
amount: Math.max(1, Math.round(Math.max(0, casterMaxHealth) * absorb.amount / 100)),
})),
},
}
: effect);
case 498523: // Rank 1 Forge's guaranteed on-normal-attack trigger is child skill 498524.
return [{
kind: "apply-aura",
aura: ROM_CHAMPION_FORGE_ACTIVE_AURA,
recipient: "caster",
sourceEffectType: 0,
sourceAuraType: 0,
miscValue: 0,
triggerSpellId: 498524,
}];
case 498572: // Rune Overload ends when its Rage drain reaches zero.
return effects.map((effect) => effect.kind === "apply-aura"
? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_CHAMPION_RUNE_OVERLOAD_TAG],
},
}
: effect);
case 498531: // Fearless Blow's +30% hit applies only below 30% target health.
return effects.flatMap((effect) => {
if (effect.kind === "apply-aura" && effect.aura.id.includes("-621179-")) return [];
if (effect.kind === "apply-aura" && effect.aura.id.includes("-621183-")) {
return [{ ...effect, recipient: "caster" as const }];
}
if (effect.kind === "damage" && targetHealthPercent < 0.3) return [scaleDamage(effect, 1.3)];
return [effect];
});
case 498535: // Chain Drive's proc chance and damage bonus are absent from the source payload.
return effects.filter((effect) => effect.kind !== "cooldown-reset");
case 498599: // Dark Energy Strike's cooldown proc chance is absent from the source payload.
if (ability.secondaryClassId !== "rom-warlock") return effects;
return effects.filter((effect) => effect.kind !== "cooldown-reset");
case 498651: // Heart Collection Rune explicitly resets Rune Siphon.
if (ability.secondaryClassId !== "rom-warlock") return effects;
return effects.map((effect) => effect.kind === "cooldown-reset"
? { ...effect, abilityName: "Rune Siphon" }
: effect);
default:
return effects;
}
}
+194
View File
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
import { romChampionCastEffects } from "./romChampionOverrides";
const empty: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function sourceAbility(spellId: number) {
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
if (!ability) throw new Error(`Missing RoM source ability ${spellId}`);
return ability;
}
function castEffects(spellId: number, context: Partial<RomCastMechanics> = {}) {
return romEffectsForCast(sourceAbility(spellId), { ...empty, ...context });
}
describe("RoM Champion source-specific behavior", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("ends Rune Overload when its Rage drain reaches zero", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 40 });
useCombatStore.setState((state) => ({
resource: 30,
resourcePools: { ...state.resourcePools, rage: 30 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-primary-17", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("rom-champion-rune-overload")
))).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(15);
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("rom-champion-rune-overload")
))).toBe(false);
});
it("applies Fearless Blow's bonus only below 30% and gives its Rage rider to the caster", () => {
const atThreshold = castEffects(498531, { targetHealthPercent: 0.3 });
const belowThreshold = castEffects(498531, { targetHealthPercent: 0.299 });
const baseDamage = atThreshold.find((effect) => effect.kind === "damage");
const boostedDamage = belowThreshold.find((effect) => effect.kind === "damage");
expect(baseDamage?.kind).toBe("damage");
expect(boostedDamage?.kind).toBe("damage");
if (baseDamage?.kind === "damage" && boostedDamage?.kind === "damage") {
expect(boostedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.3);
expect(boostedDamage.basePoints ?? 0).toBeCloseTo((baseDamage.basePoints ?? 0) * 1.3);
}
expect(atThreshold.some((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621179"))).toBe(false);
const rage = belowThreshold.find((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621183"));
expect(rage?.kind).toBe("apply-aura");
if (rage?.kind === "apply-aura") expect(rage.recipient).toBe("caster");
});
it("adds Rune Draw's source-described pull and moves the selected target toward the Champion", () => {
const runeDraw = sourceAbility(498564);
expect(runeDraw.effects.filter((effect) => effect.kind === "pull")).toHaveLength(1);
expect(runeDraw.effects.some((effect) => effect.kind === "interrupt")).toBe(true);
expect(runeDraw.effects.some((effect) => effect.kind === "taunt")).toBe(true);
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 });
useCombatStore.getState().registerMob("champion-rune-draw-target", {
name: "Rune Draw Target",
maxHealth: 100_000,
});
setMobPosition("champion-rune-draw-target", [8, 0, 0]);
useCombatStore.getState().selectMob("champion-rune-draw-target");
expect(useCombatStore.getState().castAbility("rom-champion-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(getMobPosition("champion-rune-draw-target")?.[0]).toBeCloseTo(1.5);
});
it("rejects Indomitable Spirit at 65% health and permits it below the threshold", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
level: 70,
});
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState((state) => ({
health: Math.ceil(maxHealth * 0.65),
resource: 20,
resourcePools: { ...state.resourcePools, rage: 20 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({
health: Math.floor(maxHealth * 0.65) - 1,
resource: 20,
resourcePools: { ...state.resourcePools, rage: 20 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000))
.toMatchObject({ ok: true });
});
it("activates Forge for five minutes and grants one stack and Rage on a normal hit", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 });
useCombatStore.getState().registerMob("champion-forge-target", { name: "Forge Target", maxHealth: 100_000 });
setMobPosition("champion-forge-target", [3, 0, 0]);
useCombatStore.getState().selectMob("champion-forge-target");
const forge = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498523)!;
expect(useCombatStore.getState().castAbility(forge.id, undefined, 1_000)).toMatchObject({ ok: true });
const activeForge = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-active");
expect(activeForge?.expiresAt).toBe(301_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-forge-stack")).toBe(false);
useCombatStore.setState({ autoAttackTargetId: "champion-forge-target", nextAutoAttackAt: 1_000 });
useCombatStore.getState().tick(0.1, 1_000);
const forgeStack = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-stack");
expect(forgeStack?.stacks).toBe(1);
expect(forgeStack?.expiresAt).toBe(9_000);
expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThanOrEqual(10);
});
it("scales Determination Rune's source max-health shield and leaves unrelated casts unchanged", () => {
const determinationRune = sourceAbility(498584);
const sourceShield = determinationRune.effects.find((effect) => effect.kind === "apply-aura"
&& effect.aura.absorbs?.length);
expect(sourceShield?.kind).toBe("apply-aura");
if (sourceShield?.kind !== "apply-aura") throw new Error("Missing Determination Rune shield effect");
expect(sourceShield.aura.absorbs?.[0]?.amount).toBe(30);
expect(determinationRune.ranks?.[0]?.effects.find((effect) => effect.kind === "apply-aura"
&& effect.aura.absorbs?.length)).toMatchObject({
kind: "apply-aura",
aura: { absorbs: [expect.objectContaining({ amount: 30 })] },
});
const twoThousandHp = castEffects(498584, { casterMaxHealth: 2_000 });
const fiveThousandHp = castEffects(498584, { casterMaxHealth: 5_000 });
expect(twoThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length))
.toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 600 })] } });
expect(fiveThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length))
.toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 1_500 })] } });
const otherClass = { ...determinationRune, classId: "rom-mage" as const };
expect(romChampionCastEffects(otherClass, determinationRune.effects, 1, 2_000))
.toBe(determinationRune.effects);
const otherPair = { ...determinationRune, secondaryClassId: "rom-mage" as const };
expect(romChampionCastEffects(otherPair, determinationRune.effects, 1, 2_000))
.toBe(determinationRune.effects);
const otherChampionAbility = sourceAbility(498568);
expect(romChampionCastEffects(otherChampionAbility, otherChampionAbility.effects, 1, 2_000))
.toBe(otherChampionAbility.effects);
});
it("casts Determination Rune with an absorb equal to 30% of the current max HP", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion",
secondaryClassId: "rom-warrior",
raceId: "rom-dwarf",
level: 30,
});
const maxHealth = useCombatStore.getState().maxHealth;
expect(useCombatStore.getState().castAbility("rom-champion-rom-warrior-elite-30", undefined, 1_000))
.toMatchObject({ ok: true });
const shield = useCombatStore.getState().auras.find((aura) => aura.definition.absorbs?.length);
expect(shield?.definition.absorbs?.[0]?.amount).toBe(Math.round(maxHealth * 0.3));
});
it("keeps Champion cooldown resets source-scoped and removes undefined proc chances", () => {
const heartRuneResets = castEffects(498651)
.filter((effect) => effect.kind === "cooldown-reset");
expect(heartRuneResets.length).toBeGreaterThan(0);
expect(heartRuneResets.every((effect) => effect.kind === "cooldown-reset"
&& effect.abilityName === "Rune Siphon")).toBe(true);
expect(castEffects(498599).some((effect) => effect.kind === "damage")).toBe(true);
expect(castEffects(498599).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
expect(castEffects(498535).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
});
});
+115 -1
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import { abilityById, type AbilityEffect } from "./abilityCatalog";
import { romEffectsForCast, romTargetBleedCount, type RomCastMechanics } from "./romCombatMechanics";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics";
const empty: RomCastMechanics = {
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function cast(id: string, overrides: Partial<RomCastMechanics> = {}): readonly AbilityEffect[] {
@@ -17,6 +19,28 @@ function kinds(effects: readonly AbilityEffect[]): readonly string[] {
}
describe("RoM primary class conditional combat effects", () => {
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);
for (const spellId of [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629]) {
expect(romAbilityNeedsRangedWeapon(sourceAbility(spellId))).toBe(true);
}
});
it("selects the Nature's Power Healing Arrows HoT and consumes one point only when available", () => {
const baseline = cast("rom-druid-rom-scout-elite-25", { naturePower: 0 });
const empowered = cast("rom-druid-rom-scout-elite-25", { naturePower: 1 });
expect(baseline).toHaveLength(1);
expect(baseline[0]?.kind).toBe("hot");
if (baseline[0]?.kind === "hot") expect(baseline[0].basePoints).toBeCloseTo(18.88);
expect(empowered).toHaveLength(2);
expect(empowered[0]?.kind).toBe("hot");
if (empowered[0]?.kind === "hot") expect(empowered[0].basePoints).toBeCloseTo(23.6);
expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain"))
.toEqual([]);
});
it("counts only active Rogue bleeds applied by the caster to that target", () => {
const timed = [
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 },
@@ -73,6 +97,83 @@ describe("RoM primary class conditional combat effects", () => {
expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]);
});
it("requires an active Vulnerable target for Keen Attack", () => {
const keenAttackId = "rom-warrior-rom-rogue-elite-35";
expect(cast(keenAttackId)).toEqual([]);
expect(kinds(cast(keenAttackId, { targetTags: ["rom-vulnerable"] }))).toContain("damage");
});
it("gates Open Flank on Vulnerable and Opportunity on the resulting Weakened state", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(romAbilityRequiredTargetStateTag(sourceAbility(491136))).toBe("rom-vulnerable");
expect(romAbilityRequiredTargetStateTag(sourceAbility(492617))).toBe("rom-weakened");
expect(cast("rom-warrior-primary-2")).toEqual([]);
const openFlank = cast("rom-warrior-primary-2", { targetTags: ["rom-vulnerable"] });
expect(openFlank.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes("rom-weakened"))).toBe(true);
expect(cast("rom-warrior-rom-priest-elite-35")).toEqual([]);
expect(kinds(cast("rom-warrior-rom-priest-elite-35", { targetTags: ["rom-weakened"] })))
.toContain("damage");
});
it("only applies Tactical Attack's Slash bleed when a Slash bleed is active", () => {
expect(kinds(cast("rom-warrior-primary-3"))).toEqual(["damage"]);
expect(kinds(cast("rom-warrior-primary-3", { slashBleed: true }))).toEqual(["damage", "dot"]);
const effects = [
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 1 },
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "other", remainingTicks: 3 },
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 0 },
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "warrior", targetId: "target", remainingTicks: 3 },
];
expect(romTargetHasSlashBleed(effects, "target")).toBe(true);
expect(romTargetHasSlashBleed(effects.slice(1), "target")).toBe(false);
});
it("applies explicit Warrior passive Rage and cooldown reductions to their source skills", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(romAbilityNeedsTwoHand(sourceAbility(491142))).toBe(true);
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [491458]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [493248]))
.toEqual({ rageCostReduction: 20, cooldownReductionMs: 10_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491]))
.toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 10_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({
rageCostReduction: 0, cooldownReductionMs: 0,
});
expect(romWarriorAbilityAdjustment(sourceAbility(490053), [499827]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 });
});
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);
const baseDamage = romEffectsForCast(rageBlow, empty).find((effect) => effect.kind === "damage");
const trainedDamage = romEffectsForCast(rageBlow, {
...empty, learnedPassiveSpellIds: [499806],
}).find((effect) => effect.kind === "damage");
expect(baseDamage?.kind).toBe("damage");
expect(trainedDamage?.kind).toBe("damage");
if (baseDamage?.kind === "damage" && trainedDamage?.kind === "damage") {
expect(baseDamage.school).toBe("physical");
expect(trainedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.2);
}
const surpriseAttack = sourceAbility(490129);
const heroicEffects = romEffectsForCast(surpriseAttack, {
...empty, learnedPassiveSpellIds: [492661],
});
const heroicHit = heroicEffects.find((effect) => effect.kind === "damage");
expect(heroicHit?.kind).toBe("damage");
if (heroicHit?.kind === "damage") {
expect(heroicHit.onDamageResource).toEqual({ resource: "rage", amount: 25 });
}
expect(romWarriorThreatMultiplier(surpriseAttack, [492661])).toBe(5);
expect(romWarriorThreatMultiplier(surpriseAttack, [])).toBe(1);
});
it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => {
expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]);
expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]);
@@ -88,6 +189,19 @@ describe("RoM primary class conditional combat effects", () => {
]);
});
it("executes Moon Cleave's three source-authored attacks", () => {
const effects = cast("rom-warrior-primary-8");
expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
expect(effects.filter((effect) => effect.kind === "scripted")).toHaveLength(1);
});
it("only adds The Final Battle's second hit below 30% target health", () => {
const atThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.3 });
const belowThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.29 });
expect(atThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(belowThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(2);
});
it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => {
const effects = cast("rom-knight-general-7");
expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]);
+193 -17
View File
@@ -1,7 +1,13 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
import { romPriestWarlockCastEffects } from "./romPriestWarlockOverrides";
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
import { romDruidCastEffects } from "./romDruidOverrides";
import { romChampionCastEffects } from "./romChampionOverrides";
import { romWardenCastEffects } from "./romWardenOverrides";
import { romWarlockCastEffects } from "./romWarlockOverrides";
import { romKnightCastEffects } from "./romKnightOverrides";
const ROGUE_BLEED_ABILITIES = new Set([
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
@@ -16,8 +22,19 @@ export interface RomCastMechanics {
readonly charged: boolean;
readonly mainHandDagger: boolean;
readonly casterMaxHealth: number;
readonly targetHealthPercent: number;
readonly friendlyTargetHealthPercent?: number;
readonly naturePower?: number;
readonly passiveTags?: readonly string[];
readonly learnedPassiveSpellIds?: readonly number[];
readonly eruptionProcRoll?: number;
readonly targetTags?: readonly string[];
readonly slashBleed?: boolean;
}
export interface RomWarriorAbilityAdjustment {
readonly rageCostReduction: number;
readonly cooldownReductionMs: number;
}
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
@@ -34,7 +51,74 @@ export function romSingleTargetControl(ability: AbilityDefinition | null): boole
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& [490175, 490186, 490192, 490468].includes(ability.dbcSpellId);
&& [490175, 490186, 490192, 490468, 491333, 491484].includes(ability.dbcSpellId);
}
export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean {
return ability.source === "runewaker" && [490121, 491142].includes(ability.dbcSpellId);
}
export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629].includes(ability.dbcSpellId);
}
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
if (ability.source !== "runewaker") return null;
if (ability.dbcSpellId === 492617) return "rom-weakened";
if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable";
return null;
}
/** Source-described Warrior elite passives that reduce a skill's Rage cost and cooldown. */
export function romWarriorAbilityAdjustment(
ability: AbilityDefinition,
learnedPassiveSpellIds: readonly number[],
): RomWarriorAbilityAdjustment {
if (ability.source !== "runewaker" || ability.classId !== "rom-warrior") {
return { rageCostReduction: 0, cooldownReductionMs: 0 };
}
const passiveIds = new Set(learnedPassiveSpellIds);
const rules: Readonly<Record<number, {
readonly abilitySpellId: number;
readonly rageCostReduction: number;
readonly cooldownReductionMs: number;
}>> = {
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)
.filter(([passiveSpellId, rule]) => passiveIds.has(Number(passiveSpellId))
&& ability.dbcSpellId === rule.abilitySpellId)
.map(([, rule]) => rule);
return {
rageCostReduction: applicable.reduce((sum, rule) => sum + rule.rageCostReduction, 0),
cooldownReductionMs: applicable.reduce((sum, rule) => sum + rule.cooldownReductionMs, 0),
};
}
/** Heroic Surprise Attack makes Surprise Attack generate five times its usual threat. */
export function romWarriorThreatMultiplier(
ability: AbilityDefinition,
learnedPassiveSpellIds: readonly number[],
): number {
return ability.source === "runewaker"
&& ability.classId === "rom-warrior"
&& ability.dbcSpellId === 490129
&& learnedPassiveSpellIds.includes(492661)
? 5
: 1;
}
/** Some Warrior source components mark physical weapon attacks as an untyped spell. */
export function romWarriorPhysicalWeaponTooltip(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& ability.classId === "rom-warrior"
&& /\b(?:physical DPS|main hand weapon DPS)\b/i.test(ability.description)
&& !/\b(?:Dark|Wind|Fire|Water|Light|Earth) Damage\b/i.test(ability.description);
}
export function romTargetBleedCount(
@@ -51,6 +135,21 @@ export function romTargetBleedCount(
.map((effect) => effect.sourceAbilityId)).size;
}
export function romTargetHasSlashBleed(
effects: readonly {
readonly kind: string;
readonly sourceAbilityId: string;
readonly targetId: string | null;
readonly remainingTicks: number;
}[],
targetId: string | null,
): boolean {
return Boolean(targetId && effects.some((effect) => effect.kind === "dot"
&& effect.remainingTicks > 0
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rom-warrior-general-0"));
}
function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
@@ -67,18 +166,95 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return ability.effects;
const priestKnightEffects = romPriestKnightCastEffects(
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth, context.targetTags ?? [],
ability,
ability.effects,
context.passiveTags ?? [],
context.casterMaxHealth,
context.targetTags ?? [],
context.friendlyTargetHealthPercent ?? 1,
context.learnedPassiveSpellIds ?? [],
);
const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? []));
const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
const damage = effects.filter((effect) => effect.kind === "damage");
const priestWarlockEffects = romPriestWarlockCastEffects(
ability,
priestKnightEffects,
context.friendlyTargetHealthPercent ?? 1,
);
const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []);
const mageWarlockEffects = romMageWarlockCastEffects(
ability,
knightEffects,
new Set(context.passiveTags ?? []),
context.eruptionProcRoll,
);
const scoutRogueEffects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
const druidEffects = romDruidCastEffects(ability, scoutRogueEffects, context.naturePower ?? 0, context.passiveTags ?? []);
const championEffects = romChampionCastEffects(
ability, druidEffects, context.targetHealthPercent, context.casterMaxHealth,
);
const wardenEffects = romWardenCastEffects(
ability, championEffects, context.naturePower ?? 0, context.passiveTags ?? [],
);
const effects = romWarlockCastEffects(
ability,
wardenEffects,
context.targetTags ?? [],
context.learnedPassiveSpellIds ?? [],
);
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
? effects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
? { ...effect, school: "physical" as const }
: effect)
: effects;
const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806)
? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
&& (effect.school === undefined || effect.school === "physical")
? scaleOutput(effect, 1.2)
: effect)
: sourceSchoolAdjustedEffects;
const requiredTargetStateTag = romAbilityRequiredTargetStateTag(ability);
if (requiredTargetStateTag && !context.targetTags?.includes(requiredTargetStateTag)) return [];
const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage");
const seals = Math.max(0, Math.min(4, context.sealStacks));
switch (ability.dbcSpellId) {
case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target.
return [
...damage,
...(context.slashBleed ? warriorAdjustedEffects.filter((effect) => effect.kind === "dot") : []),
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage" && effect.kind !== "dot"
&& !(effect.kind === "scripted" && effect.label.startsWith("RoM conditional-use:"))),
];
case 494021: { // Healing Arrows chooses the Nature's Power HoT when a point is available.
const hots = warriorAdjustedEffects.filter((effect) => effect.kind === "hot");
const empowered = (context.naturePower ?? 0) >= 1;
const selectedHot = empowered ? hots[0] : hots[1] ?? hots[0];
return [
...(selectedHot ? [selectedHot] : []),
...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const }] : []),
];
}
case 493247: // The Final Battle only adds its second hit below 30% target HP.
return [
...damage.slice(0, context.targetHealthPercent < 0.3 ? 2 : 1),
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage"),
];
case 490121: // Moon Cleave has three main-hand attack pulses.
return [...damage, ...damage, ...damage,
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage")];
case 490129: { // Heroic Surprise Attack restores Rage only when its hit lands.
if (!warriorPassives.has(492661)) return warriorAdjustedEffects;
let attachedRage = false;
return warriorAdjustedEffects.map((effect) => {
if (effect.kind !== "damage" || attachedRage) return effect;
attachedRage = true;
return { ...effect, onDamageResource: { resource: "rage", amount: 25 } };
});
}
case 492625: // Infection's poison rider needs Poisonous active.
return context.passiveTags?.includes("rom-poisonous")
? effects : effects.filter((effect) => effect.kind !== "dot");
? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 491156: // Electrostatic Charge's source shield is a percent of max HP.
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
return warriorAdjustedEffects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
aura: {
@@ -93,25 +269,25 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
case 490150: { // Charge adds two stacks.
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
const nextStacks = Math.min(4, seals + gained);
return effects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
return warriorAdjustedEffects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
}
case 490313: // Wound Attack: one hit plus one rider per self-applied bleed.
return damage.slice(0, 1 + Math.min(2, context.bleedCount));
case 490323: // Low Blow only creates Grievous Wound on a bleeding target.
return context.bleedCount > 0 ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.bleedCount > 0 ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 491170: // Electric Explosion has mutually exclusive normal/Charged hits.
return context.charged ? damage.slice(0, 1) : damage.slice(-1);
case 490151: // Punishment spends Holy Seals and scales its one payoff.
return seals > 0 ? effects.map((effect) => scaleOutput(effect, seals / 4)) : [];
return seals > 0 ? warriorAdjustedEffects.map((effect) => scaleOutput(effect, seals / 4)) : [];
case 490153: { // Threaten: target debuffs and extra hit require Holy Seals.
const base = damage[0];
const threat = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const debuffs = seals > 0 ? effects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
const threat = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const debuffs = seals > 0 ? warriorAdjustedEffects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])];
}
case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider.
const stun = effects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
const stun = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
const scaledStun = stun && stun.kind === "apply-aura"
? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } }
: null;
@@ -119,7 +295,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])];
}
case 490186: { // Shield Bash shields its caster only when consuming seals.
const absorb = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
const absorb = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
const shield = seals > 0 && absorb?.kind === "apply-aura" ? {
...absorb,
recipient: "caster" as const,
@@ -133,10 +309,10 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])];
}
case 490306: // Shadowstab's bleed needs a main-hand dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 490337: // Sneak Attack always stuns, but its bleed needs a dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
default:
return effects;
return warriorAdjustedEffects;
}
}
+803
View File
@@ -11,6 +11,7 @@ import { useCombatStore } from "./combatStore";
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
import { targetingRulesForAbility } from "./abilityRules";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { applyAura } from "./combatAuras";
describe("RuneWaker combat runtime", () => {
beforeEach(() => {
@@ -178,6 +179,43 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(false);
});
it("roots only Joint Blow targets when Cripple Joint procs", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 80 });
const passive = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-state-cripple-joint");
expect(passive?.definition.procs?.[0]?.chance).toBe(0.55);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId !== passive?.instanceId
? aura
: {
...aura,
definition: {
...aura.definition,
procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })),
},
}),
}));
for (const id of ["cripple-other-attack", "cripple-joint-target"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [3, 0, 0]);
}
useCombatStore.getState().selectMob("cripple-other-attack");
expect(useCombatStore.getState().castAbility("rom-scout-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cripple-other-attack"]!.statuses.some((status) => status.kind === "root"))
.toBe(false);
useCombatStore.getState().selectMob("cripple-joint-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cripple-joint-target"]!.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "root",
sourceAbilityId: "rom-scout-general-3",
endsAt: 8_000,
breakOnDamage: true,
}),
]));
});
it("moves Rogue Shadow Step behind its target", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
useCombatStore.getState().registerMob("step-dummy", { name: "Step Dummy", maxHealth: 100_000 });
@@ -233,6 +271,387 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000);
});
it("requires Probing Attack's Vulnerable state before Keen Attack", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("keen-vulnerable-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("keen-vulnerable-target", [3, 0, 0]);
useCombatStore.getState().selectMob("keen-vulnerable-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "keen-vulnerable-target"
&& aura.definition.utilityTags?.includes("rom-vulnerable"))).toBe(true);
const result = useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 2_600);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["keen-vulnerable-target"] });
expect(result.damage).toBeGreaterThan(0);
});
it("requires Vulnerable for Open Flank and then permits Opportunity against its Weakened target", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("opportunity-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("opportunity-target", [3, 0, 0]);
useCombatStore.getState().selectMob("opportunity-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 2_600).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "opportunity-target"
&& aura.definition.utilityTags?.includes("rom-weakened"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 4_200))
.toMatchObject({ ok: true, affectedMobIds: ["opportunity-target"] });
});
it("applies Tactical Attack's conditional Slash bleed only after Slash lands", () => {
const weapon = {
instanceId: "warrior-two-hand-slash-test",
itemId: 1,
itemKey: "warrior-two-hand-slash-test",
name: "Test Two-Hander",
quality: "uncommon" as const,
inventorySlot: "two-hand",
armorType: null,
weaponType: "sword",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 20,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
useCombatStore.getState().registerMob("tactical-bleed-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("tactical-bleed-target", [3, 0, 0]);
useCombatStore.getState().selectMob("tactical-bleed-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 2_600).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === "rom-warrior-general-0"
&& effect.targetId === "tactical-bleed-target")).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-primary-3", undefined, 4_200).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === "rom-warrior-primary-3"
&& effect.targetId === "tactical-bleed-target")).toBe(true);
});
it("applies Warrior passive Rage and cooldown reductions to Moon Cleave and Whirlwind", () => {
const weapon = {
instanceId: "warrior-two-hand-passive-test",
itemId: 1,
itemKey: "warrior-two-hand-passive-test",
name: "Test Two-Hander",
quality: "uncommon" as const,
inventorySlot: "two-hand",
armorType: null,
weaponType: "sword",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
useCombatStore.getState().registerMob("passive-moon-cleave-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("passive-moon-cleave-target", [3, 0, 0]);
useCombatStore.getState().selectMob("passive-moon-cleave-target");
useCombatStore.setState((state) => ({
resource: 40,
resourcePools: { ...state.resourcePools, rage: 40 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-warrior-primary-8"]).toBe(11_000);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("passive-whirlwind-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("passive-whirlwind-target", [3, 0, 0]);
useCombatStore.getState().selectMob("passive-whirlwind-target");
useCombatStore.setState((state) => ({
resource: 30,
resourcePools: { ...state.resourcePools, rage: 30, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(21_000);
for (const [secondaryClassId, level, startingRage, expectedRage, expectedCooldown] of [
["rom-knight", 60, 100, 75, 16_000], // Whirlwind Battle Technique: -25 Rage, -15 seconds.
["rom-druid", 40, 100, 60, 21_000], // Recovery of Life: -10 Rage, -10 seconds.
] as const) {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId, raceId: "rom-human", level,
});
const targetId = `passive-whirlwind-${secondaryClassId}`;
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 });
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.getState().selectMob(targetId);
useCombatStore.setState((state) => ({
resource: startingRage,
resourcePools: { ...state.resourcePools, rage: startingRage },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(expectedRage);
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(expectedCooldown);
}
});
it("applies Warrior/Warlock Speed Slash cost and Warrior/Knight Heroic Surprise Attack hit effects", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 70,
});
useCombatStore.getState().registerMob("speed-slash-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("speed-slash-target", [3, 0, 0]);
useCombatStore.getState().selectMob("speed-slash-target");
useCombatStore.setState((state) => ({
resource: 15,
resourcePools: { ...state.resourcePools, rage: 15 },
}));
const slash = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490053)!;
expect(useCombatStore.getState().castAbility(slash.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("heroic-surprise-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("heroic-surprise-target", [3, 0, 0]);
useCombatStore.getState().selectMob("heroic-surprise-target");
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, rage: 0 },
}));
const surpriseAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490129)!;
const result = useCombatStore.getState().castAbility(surpriseAttack.id, undefined, 2_000);
expect(result.ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
expect(useCombatStore.getState().mobs["heroic-surprise-target"]?.threatByActor[PLAYER_AGGRO_ID])
.toBeCloseTo(1 + result.damage * 5);
});
it("adds Blood Retention's 3 Rage per 2 seconds after Bloodlust", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 40,
});
useCombatStore.getState().registerMob("blood-retention-dummy", { name: "Target", maxHealth: 100_000 });
setMobPosition("blood-retention-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("blood-retention-dummy");
useCombatStore.setState((state) => ({
resource: 25,
resourcePools: { ...state.resourcePools, rage: 25 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 1_000).ok).toBe(true);
useCombatStore.setState({ autoAttackTargetId: null });
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-30", undefined, 2_600).ok).toBe(true);
const afterBloodlust = useCombatStore.getState().resourcePools.rage!;
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-warrior:blood-retention"))
.toBe(true);
useCombatStore.getState().tick(1, 3_600);
useCombatStore.getState().tick(1, 4_600);
expect(useCombatStore.getState().resourcePools.rage).toBeCloseTo(afterBloodlust + 3);
});
it("only deals The Final Battle's second hit below 30% target health", () => {
const damageAtTargetHealth = (targetId: string, healthPercent: number) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000 });
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
mobs: {
...state.mobs,
[targetId]: {
...state.mobs[targetId]!,
health: Math.round(state.mobs[targetId]!.maxHealth * healthPercent),
},
},
}));
useCombatStore.getState().selectMob(targetId);
const result = useCombatStore.getState().castAbility("rom-warrior-rom-scout-elite-50", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: [targetId] });
return result.damage;
};
const aboveThresholdDamage = damageAtTargetHealth("final-battle-above", 0.4);
const belowThresholdDamage = damageAtTargetHealth("final-battle-below", 0.29);
expect(aboveThresholdDamage).toBeGreaterThan(0);
expect(belowThresholdDamage).toBeGreaterThan(aboveThresholdDamage);
});
it("gates Warrior Survival Instinct and Moon Cleave source requirements", () => {
const oneHandWeapon = {
instanceId: "warrior-one-hand-test",
itemId: 1,
itemKey: "warrior-one-hand-test",
name: "Test Sword",
quality: "uncommon" as const,
inventorySlot: "one-hand",
armorType: null,
weaponType: null,
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 50,
inventory: [oneHandWeapon], equipment: { player: { "main-hand": oneHandWeapon.instanceId } },
});
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({ health: Math.floor(state.maxHealth * 0.49) }));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000).ok).toBe(true);
});
it.each(["rom-warrior-rom-knight-elite-15", "rom-warrior-rom-knight-elite-30"])(
"requires an equipped shield for %s",
(abilityId) => {
const shield = {
instanceId: `knight-shield-gate-${abilityId}`,
itemId: 1,
itemKey: `knight-shield-gate-${abilityId}`,
name: "Test Shield",
quality: "uncommon" as const,
inventorySlot: "off-hand",
armorType: "shield",
weaponType: null,
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
const prepareCombat = (withShield: boolean) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
...(withShield ? { inventory: [shield], equipment: { player: { "off-hand": shield.instanceId } } } : {}),
});
useCombatStore.getState().registerMob("shield-gated-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("shield-gated-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shield-gated-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, mana: 100 },
}));
};
prepareCombat(false);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
prepareCombat(true);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
},
);
it("requires Briar Shield before Scout/Warden Thorn Arrow", () => {
const rangedWeapon = {
instanceId: "thorn-arrow-ranged-test",
itemId: 5243,
itemKey: "thorn-arrow-ranged-test",
name: "Test Ranged Weapon",
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType: "bow",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-warden", raceId: "rom-elf", level: 80,
inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } },
});
useCombatStore.getState().registerMob("thorn-arrow-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("thorn-arrow-target", [3, 0, 0]);
useCombatStore.getState().selectMob("thorn-arrow-target");
expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("rom-briar-shield"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 2_600))
.toMatchObject({ ok: true });
});
it("stuns all enemies within range 100 around a Warrior Shout", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 30 });
for (let index = 0; index < 6; index += 1) {
const id = "shout-target-" + index;
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000 });
setMobPosition(id, [index + 1, 0, 0]);
}
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const result = useCombatStore.getState().castAbility("rom-warrior-primary-9", undefined, 1_000);
expect(result.ok).toBe(true);
expect(result.affectedMobIds).toHaveLength(6);
expect(useCombatStore.getState().resourcePools.rage).toBe(85);
expect(useCombatStore.getState().cooldowns["rom-warrior-primary-9"]).toBe(41_000);
for (let index = 0; index < 6; index += 1) {
expect(useCombatStore.getState().mobs[`shout-target-${index}`]!.statuses)
.toEqual(expect.arrayContaining([expect.objectContaining({ kind: "stun" })]));
}
});
it("finishes a RuneWaker direct heal on the selected party member", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom");
@@ -249,6 +668,390 @@ 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", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-curing-shot", 80, useCombatStore.getState().health, "healer", 2, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
const injuredHealth = new Map(members.map((member, index) => {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3 + index, 0, 0]);
return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const;
}));
const member = members[0]!;
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(useCombatStore.getState().castAbility(curingShot.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
for (const [id, health] of injuredHealth) {
expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true);
}
});
it.each([[0, 18.88], [1, 23.6]] as const)(
"casts Druid/Scout Healing Arrows with %i Nature's Power point(s) using the matching HoT",
(naturePower, expectedHoTAmount) => {
const rangedWeapon = {
instanceId: "healing-arrows-ranged-test",
itemId: 5243,
itemKey: "healing-arrows-ranged-test",
name: "Test Ranged Weapon",
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType: "bow",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80,
inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } },
});
usePartyStore.getState().initializeParty(
`rom-healing-arrows-${naturePower}`, 80, useCombatStore.getState().health, "healer", 5, "rom",
);
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, focus: 100, mana: 100, "nature-power": naturePower },
}));
const ability = abilityById("rom-druid-rom-scout-elite-25")!;
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
const state = useCombatStore.getState();
expect(state.resourcePools["nature-power"]).toBe(0);
const memberHot = state.effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id && effect.targetId === member.id);
expect(memberHot?.kind).toBe("hot");
if (memberHot?.kind === "hot") expect(memberHot.basePoints).toBeCloseTo(expectedHoTAmount);
},
);
it("casts Warlock/Priest Ring of Comfort as a six-target friendly heal", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", level: 80,
});
usePartyStore.getState().initializeParty("rom-ring-of-comfort", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members;
const extraMembers = ["one", "two"].map((suffix) => ({
...members[0]!, id: `ring-of-comfort-extra-ally-${suffix}`, name: `Extra Ally ${suffix}`,
}));
usePartyStore.setState((state) => ({ members: [...state.members, ...extraMembers] }));
useCombatStore.getState().damagePlayer(500);
const before = new Map<string, number>();
for (const [index, member] of [...members, ...extraMembers].entries()) {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
before.set(member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health);
}
usePartyStore.getState().selectMember(members[0]!.id);
useCombatStore.getState().registerMob("ring-of-comfort-hostile", { name: "Hostile", maxHealth: 10_000 });
setMobPosition("ring-of-comfort-hostile", [3, 0, 0]);
const ability = abilityById("rom-warlock-rom-priest-elite-25")!;
expect(ability).toMatchObject({
target: "friendly",
targeting: { shape: "circle", radius: 20, maximumTargets: 6 },
});
expect(ability.effects).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "heal" })]));
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === ability.id);
expect(healingEvents).toHaveLength(6);
const healedActorIds = new Set(healingEvents.map((event) => event.targetActorId));
expect(healedActorIds.size).toBe(6);
expect(healedActorIds).toContain(members[0]!.id);
expect(healedActorIds).not.toContain(extraMembers[1]!.id);
expect(usePartyStore.getState().members.find((member) => member.id === members[0]!.id)!.health)
.toBeGreaterThan(before.get(members[0]!.id)!);
expect(useCombatStore.getState().mobs["ring-of-comfort-hostile"]!.health).toBe(10_000);
});
it.each([
["Spiritual Leader", "rom-scout", "rom-priest", "rom-human", 30, "rom-scout-rom-priest-elite-30"],
["Salvation Engraving", "rom-champion", "rom-priest", "rom-dwarf", 30, "rom-champion-rom-priest-elite-30"],
["Suicide Advance", "rom-champion", "rom-priest", "rom-dwarf", 45, "rom-champion-rom-priest-elite-45"],
["Soul Enlightenment", "rom-warlock", "rom-priest", "rom-dwarf", 40, "rom-warlock-rom-priest-elite-40"],
] as const)("applies %s auras to its party recipients", (name, classId, secondaryClassId, raceId, level, abilityId) => {
useCombatStore.getState().initializeCharacter({ classId, secondaryClassId, raceId, level });
usePartyStore.getState().initializeParty(`rom-${abilityId}`, level, useCombatStore.getState().health, "healer", 3, "rom");
const memberIds = usePartyStore.getState().members.map((member) => member.id);
memberIds.forEach((id, index) => registerPartyRuntimePosition(id, [2 + index, 0, 0]));
const ability = abilityById(abilityId)!;
const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura");
expect(auraEffects.length, name).toBeGreaterThan(0);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
const allRecipients = [PLAYER_AGGRO_ID, ...memberIds];
const activeAuras = useCombatStore.getState().auras;
for (const effect of auraEffects) {
const expectedRecipients = effect.scope === "party"
? allRecipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AGGRO_ID)
: effect.recipient === "caster" ? [PLAYER_AGGRO_ID] : [];
for (const recipientId of allRecipients) {
expect(activeAuras.some((aura) => aura.targetId === recipientId
&& aura.definition.id === effect.aura.id), `${name} -> ${recipientId} (${effect.aura.id})`)
.toBe(expectedRecipients.includes(recipientId));
}
}
if (name === "Salvation Engraving") {
expect(auraEffects.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true);
expect(activeAuras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.startsWith("rom-aura-498719-"))).toBe(false);
}
if (name === "Suicide Advance") {
expect(auraEffects.some((effect) => effect.scope === "party" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0
)))).toBe(true);
expect(auraEffects.some((effect) => effect.scope !== "party" && effect.recipient === "caster"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.value < 0))).toBe(true);
}
});
it("casts Scout/Priest Mana Arrows as a six-target party absorb around the caster", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-mana-arrows", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = [
...usePartyStore.getState().members,
{ ...usePartyStore.getState().members[0]!, id: "mana-arrows-extra-ally", name: "Nearby Ally" },
];
usePartyStore.setState({ members });
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [1 + index, 0, 0]));
const manaArrows = abilityById("rom-scout-rom-priest-elite-20")!;
expect(manaArrows.target).toBe("self");
expect(manaArrows.targeting).toMatchObject({ shape: "circle", radius: 5, maximumTargets: 6 });
expect(useCombatStore.getState().castAbility(manaArrows.id, undefined, 1_000)).toMatchObject({ ok: true });
const shields = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb"
&& event.abilityId === manaArrows.id);
expect(shields).toHaveLength(6);
expect(shields.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID,
...members.map((member) => member.id),
]));
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.name === "Mana Arrows"))
.toHaveLength(6);
});
it("casts Druid/Mage Spring of Blessings as a nearby party HoT", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-spring-of-blessings", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
const injuredHealth = new Map(members.map((member, index) => {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const;
}));
const spring = abilityById("rom-druid-rom-mage-elite-20")!;
expect(spring.target).toBe("self");
expect(spring.targeting).toMatchObject({ shape: "circle", radius: 12 });
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100 },
}));
expect(useCombatStore.getState().castAbility(spring.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
for (const [id, health] of injuredHealth) {
expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true);
}
});
it("heals only the lowest-max-health ally with Scout/Knight Healing Shot", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-healing-shot", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
for (const [index, member] of members.entries()) {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
}
useCombatStore.getState().synchronizeActors();
const expectedRecipient = usePartyStore.getState().members.slice(0, 4)
.reduce((lowest, member) => member.maxHealth < lowest.maxHealth ? member : lowest);
const healthBefore = expectedRecipient.health;
useCombatStore.getState().registerMob("healing-shot-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("healing-shot-target", [3, 0, 0]);
useCombatStore.getState().selectMob("healing-shot-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, focus: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-scout-rom-knight-elite-20", undefined, 1_000)).toMatchObject({ ok: true });
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === "rom-scout-rom-knight-elite-20");
expect(healingEvents).toHaveLength(1);
expect(healingEvents[0]!.targetActorId).toBe(expectedRecipient.id);
expect(usePartyStore.getState().members.find((member) => member.id === expectedRecipient.id)!.health)
.toBeGreaterThan(healthBefore);
});
it("requires Nature's Power and cleanses nearby allies with Group Exorcism", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-group-exorcism", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 2);
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
const auraDefinition = { id: "test:group-exorcism-debuff", name: "Test debuff", disposition: "debuff" as const,
dispelCategory: "magic" as const, durationMs: 20_000 };
let auras = useCombatStore.getState().auras;
for (const member of members) auras = [...applyAura(auras, {
definition: auraDefinition, sourceId: "test:source", targetId: member.id, now: 1_000,
}).auras];
useCombatStore.setState({ auras, resource: 100, resourcePools: { mana: 100, "nature-power": 0 } });
const exorcismId = "rom-druid-rom-scout-elite-30";
expect(useCombatStore.getState().castAbility(exorcismId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, "nature-power": 1 },
}));
expect(useCombatStore.getState().castAbility(exorcismId, undefined, 2_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 4_100);
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === auraDefinition.id)).toBe(false);
});
it("resolves Champion Vacuum Wave against nearby enemies around the caster", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("vacuum-wave-nearby", { name: "Nearby", maxHealth: 10_000 });
useCombatStore.getState().registerMob("vacuum-wave-far", { name: "Far", maxHealth: 10_000 });
setMobPosition("vacuum-wave-nearby", [5, 0, 0]);
setMobPosition("vacuum-wave-far", [9, 0, 0]);
useCombatStore.setState({ resource: 100, resourcePools: { rage: 100 } });
const result = useCombatStore.getState().castAbility("rom-champion-primary-10", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["vacuum-wave-nearby"] });
});
it("resolves Champion Light Pulse against nearby enemies and injured party members", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-light-pulse", 80, useCombatStore.getState().health, "healer", 5, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
const memberBefore = usePartyStore.getState().members[0]!.health;
useCombatStore.getState().registerMob("light-pulse-nearby", { name: "Nearby", maxHealth: 10_000 });
setMobPosition("light-pulse-nearby", [5, 0, 0]);
useCombatStore.setState({ resource: 100, resourcePools: { rage: 100, mana: 100 } });
const result = useCombatStore.getState().castAbility("rom-champion-rom-priest-elite-40", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["light-pulse-nearby"] });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(memberBefore);
});
it("lets Champion Overrule clear and protect against control", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", raceId: "rom-human", level: 80,
});
useCombatStore.setState({
controlledUntil: 2_000,
controlMechanic: "stun",
resource: 100,
resourcePools: { rage: 100 },
});
expect(useCombatStore.getState().castAbility("rom-champion-primary-15", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.controlImmunities?.includes("stun"))).toBe(true);
});
it("applies Scout/Mage Aggro Transfer mana regeneration to its selected ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-aggro-transfer", 80, useCombatStore.getState().health, "healer", 5, "rom");
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
expect(useCombatStore.getState().castAbility("rom-scout-rom-mage-elite-50", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "resource"
&& modifier.resource === "mana" && modifier.property === "regeneration"))).toBe(true);
});
it("chains Druid/Mage Earth Chain across three targets with 70% falloff", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
const targetIds = ["earth-chain-a", "earth-chain-b", "earth-chain-c", "earth-chain-outside"];
const positions = [[3, 0, 0], [8, 0, 0], [13, 0, 0], [21, 0, 0]] as const;
for (const [index, id] of targetIds.entries()) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, positions[index]!);
}
useCombatStore.getState().selectMob(targetIds[0]!);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100, "nature-power": 100 },
}));
expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-15", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 3_000);
const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "rom-druid-rom-mage-elite-15");
expect(hits.map((event) => event.targetActorId)).toEqual(targetIds.slice(0, 3));
expect(hits[0]!.effectiveAmount).toBeGreaterThan(hits[1]!.effectiveAmount);
expect(hits[1]!.effectiveAmount).toBeGreaterThan(hits[2]!.effectiveAmount);
expect(useCombatStore.getState().mobs[targetIds[3]!]!.health).toBe(100_000);
});
it("applies Druid/Mage Shield of Light to up to three nearby allies", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-shield-of-light", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
usePartyStore.getState().selectMember(members[2]!.id);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-25", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 3_000);
const shieldEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb"
&& event.abilityId === "rom-druid-rom-mage-elite-25");
expect(shieldEvents).toHaveLength(3);
expect(shieldEvents.some((event) => event.targetActorId === members[2]!.id)).toBe(true);
});
it("uses native RoM Rage gains from auto attacks and health damage", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
const empty: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 10_000,
targetHealthPercent: 1,
};
function cast(spellId: number, naturePower: number, passiveTags: 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 });
}
function passiveTag(spellId: number): string {
const passive = ROM_ELITE_ABILITIES.find((entry) => entry.dbcSpellId === spellId);
expect(passive, `RoM Druid passive ${spellId}`).toBeDefined();
const effect = passive!.effects.find((entry) => entry.kind === "apply-aura");
expect(effect?.kind).toBe("apply-aura");
return effect?.kind === "apply-aura" ? effect.aura.utilityTags?.[0] ?? "" : "";
}
describe("RoM Druid Nature Power branches", () => {
it("selects the matching Mother Earth's Fountain heal and spends one point for DMG0", () => {
const baseline = cast(493529, 0).filter((effect) => effect.kind === "heal");
const empowered = cast(493529, 1);
const poweredHeal = empowered.find((effect) => effect.kind === "heal");
expect(baseline).toHaveLength(1);
expect(baseline[0]).toMatchObject({ kind: "heal", basePoints: 84 });
expect(poweredHeal).toMatchObject({
kind: "heal", basePoints: 67.2, scope: "party", center: "ability-target", radius: 10,
});
expect(empowered.filter((effect) => effect.kind === "resource-drain")).toEqual([
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
]);
});
it("orders powered Seed, Wrath, and Widow consumption before their source refund", () => {
for (const spellId of [493546, 493545, 493549, 494022]) {
const effects = cast(spellId, 1);
const drain = effects.findIndex((effect) => effect.kind === "resource-drain"
&& effect.resource === "nature-power");
const refund = effects.findIndex((effect) => effect.kind === "resource"
&& effect.resource === "nature-power");
expect(drain, `spell ${spellId} drain`).toBeGreaterThanOrEqual(0);
expect(refund, `spell ${spellId} refund`).toBeGreaterThan(drain);
}
expect(cast(493549, 0).filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(cast(493549, 1).filter((effect) => effect.kind === "damage")).toHaveLength(1);
});
it("selects the powered Sandstorm and Earth Chain payloads without simultaneous alternatives", () => {
const sandstormBase = cast(493542, 0);
const sandstormPowered = cast(493542, 1);
expect(sandstormBase.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(sandstormPowered.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(sandstormPowered.some((effect) => effect.kind === "summon")).toBe(false);
expect(sandstormPowered.some((effect) => effect.kind === "resource-drain"
&& effect.resource === "nature-power")).toBe(true);
const chain = cast(494025, 1);
expect(chain.filter((effect) => effect.kind === "damage")).toEqual([
expect.objectContaining({ basePoints: 49.6, falloffPerJump: 0.7 }),
]);
expect(chain.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true);
});
it("selects Healing Wind's empowered heal and applies it to nearby party members", () => {
const effects = cast(494365, 1);
expect(effects.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ kind: "heal", basePoints: 60, scope: "party", center: "caster", radius: 10 }),
]);
expect(effects.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true);
});
it("selects Heart Piercing's powered strike without dropping its Rage payload", () => {
const baseline = cast(494216, 0);
const empowered = cast(494216, 1);
expect(baseline.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(empowered.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(empowered).toContainEqual({ kind: "resource", resource: "rage", amount: 15 });
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
});
it("selects Cross of Thorns' two-hit branch and consumes one point without an unknown refund chance", () => {
const baseline = cast(494018, 0);
const empowered = cast(494018, 1);
const baselineHits = baseline.filter((effect) => effect.kind === "damage");
const empoweredHits = empowered.filter((effect) => effect.kind === "damage");
expect(baselineHits).toHaveLength(2);
expect(empoweredHits).toHaveLength(2);
expect(empoweredHits[0]).not.toEqual(baselineHits[0]);
expect(empowered.filter((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power"))
.toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }]);
expect(empowered.some((effect) => effect.kind === "resource" && effect.resource === "nature-power"))
.toBe(false);
});
});
describe("RoM Druid passive state triggers", () => {
it("applies Holy Fury's source-backed six-second Wrath duration only without Nature Power", () => {
const holyFury = passiveTag(494935);
const baseline = cast(493549, 0);
const withPassive = cast(493549, 0, [holyFury]);
const empowered = cast(493549, 1, [holyFury]);
expect(baseline.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
expect(withPassive.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 9, intervalMs: 2_000 });
expect(empowered.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
});
it("extends Recover by six seconds with Extended Recovery and gates Knee Ulcer's five-second 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"))
.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 }) }),
]);
});
});
+273
View File
@@ -0,0 +1,273 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
function sourceDurationMs(row: RomGeneratedAbilityLike, fallbackSeconds: number): number {
const duration = row.source?.components?.find((component) => (component.effectTime ?? 0) > 0)?.effectTime;
return Math.max(1_000, Math.round((duration ?? fallbackSeconds) * 1_000));
}
function spiritOfLifeHot(effects: readonly AbilityEffect[]): AbilityEffect {
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
const sum = (key: "coefficient" | "basePoints" | "dieSides" | "pointsPerLevel" | "coefficientPerLevel"
| "bonusPowerCoefficient" | "attackPowerCoefficient" | "spiritCoefficient" | "percentMaxHealth") => (
heals.reduce((total, effect) => total + (effect[key] ?? 0), 0)
);
const hot: Extract<AbilityEffect, { kind: "hot" }> = {
kind: "hot",
coefficient: sum("coefficient"),
ticks: 10,
intervalMs: 4_000,
recipient: "caster",
scope: "party",
center: "caster",
radius: 7,
...(sum("percentMaxHealth") ? { percentMaxHealth: sum("percentMaxHealth") } : {}),
...(sum("basePoints") ? { basePoints: sum("basePoints") } : {}),
...(sum("dieSides") ? { dieSides: sum("dieSides") } : {}),
...(sum("pointsPerLevel") ? { pointsPerLevel: sum("pointsPerLevel") } : {}),
...(sum("coefficientPerLevel") ? { coefficientPerLevel: sum("coefficientPerLevel") } : {}),
...(sum("bonusPowerCoefficient") ? { bonusPowerCoefficient: sum("bonusPowerCoefficient") } : {}),
...(sum("attackPowerCoefficient") ? { attackPowerCoefficient: sum("attackPowerCoefficient") } : {}),
...(sum("spiritCoefficient") ? { spiritCoefficient: sum("spiritCoefficient") } : {}),
...(heals.find((effect) => effect.sourceLevel !== undefined)?.sourceLevel === undefined
? {} : { sourceLevel: heals.find((effect) => effect.sourceLevel !== undefined)!.sourceLevel }),
};
return hot;
}
function stateAura(
id: string,
name: string,
durationMs: number,
modifiers: NonNullable<Extract<AbilityEffect, { kind: "apply-aura" }>['aura']['modifiers']>,
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id,
name,
disposition: "buff",
durationMs,
modifiers,
utilityTags: ["rom-druid-unity-with-mother-earth", "rom-druid-movement-locked"],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
}
function passiveState(sourceId: number, name: string, tag: string): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-druid-passive-${sourceId}`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
};
}
/** Source-specific player effects for Druid rows the generic translator cannot express. */
export function overrideRomDruidEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 493531: // Savage Blessing buffs a selected friendly target.
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
recipient: "ability-target",
aura: { ...effect.aura, disposition: "buff" },
} : effect);
case 493536: { // Spirit Guidance charms a non-player enemy for the source effect duration.
const effectTimeSeconds = row.source?.components?.find((component) => component.id === 503805)?.effectTime ?? 3;
return [{
kind: "status",
status: "charm",
durationMs: Math.max(1_000, Math.round(effectTimeSeconds * 1_000)),
maxTargets: 1,
breakOnDamage: false,
}];
}
case 493548: // Unity guarantees critical spell results and adds 10% healing for 15 seconds.
return [stateAura(
"rom-druid-unity-with-mother-earth",
row.name,
sourceDurationMs(row, 15),
[
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.1 },
],
)];
case 494200: // Extended Recovery: passive state extends Recover by six seconds.
return [passiveState(494200, row.name, "rom-druid-extended-recovery")];
case 494935: // Holy Fury: enables the zero-Nature-Power Wrath duration branch.
return [passiveState(494935, row.name, "rom-druid-holy-fury")];
case 499944: // Knee Ulcer gates the five-second root child on Necrotic Wound.
return [passiveState(499944, row.name, "rom-druid-knee-ulcer")];
case 494025: // Preserve both Earth Chain payload branches for cast-time selection.
return translated.filter((effect) => effect.kind === "damage").map((effect) => ({
...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 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses.
return [spiritOfLifeHot(translated)];
default:
return null;
}
}
function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boolean {
return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet<string>).has(tag);
}
function naturePowerDrain(): AbilityEffect {
return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" };
}
function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] {
const refundIndex = effects.findIndex((effect) => effect.kind === "resource"
&& effect.resource === "nature-power" && effect.amount > 0);
if (refundIndex < 0) return [...effects, naturePowerDrain()];
return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)];
}
/** Resolve Druid source branches that depend on current Nature Power or learned passive state. */
export function romDruidCastEffects(
ability: import("./abilityCatalog").AbilityDefinition,
effects: readonly AbilityEffect[],
naturePower: number,
passiveTags: readonly string[] | ReadonlySet<string> = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
const empowered = naturePower >= 1;
const damage = effects.filter((effect) => effect.kind === "damage");
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
const selectDamage = (index: number, count = 1): readonly AbilityEffect[] => {
const selected = damage[index];
return selected ? Array.from({ length: count }, () => selected) : [];
};
switch (ability.dbcSpellId) {
case 493529: { // DMG1 is baseline; DMG0 is the one-point Nature Power branch.
const selected = heals[empowered ? 1 : 0] ?? heals[0];
if (!selected) return effects;
return [
...effects.filter((effect) => effect.kind !== "heal"),
{
...selected,
scope: "party",
center: "ability-target",
radius: 10,
},
...(empowered ? [naturePowerDrain()] : []),
];
}
case 493546: // Weakening Seed consumes and refunds one point when powered.
case 493545: // Withering Seed has the same source resource branch.
return empowered ? spendBeforeNaturePowerRefund(effects) : effects;
case 493549: { // Select DMG1/DMG0, keep the single source DoT, and consume one point if powered.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage)
.map((effect) => (
effect.kind === "dot" && !empowered && hasTag(passiveTags, "rom-druid-holy-fury")
? { ...effect, ticks: effect.ticks + 3 }
: effect
));
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
}
case 493542: { // The powered DMG0 component hits all targets twice; no creature summon row exists.
const hits = selectDamage(empowered ? 1 : 0, empowered ? 2 : 1);
return [
...hits,
...effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "summon"),
...(empowered ? [naturePowerDrain()] : []),
];
}
case 494025: { // Earth Chain keeps its 70% per-jump falloff in either branch.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
return [selectedDamage, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494365: { // Healing Wind selects DMG1 or DMG0 and heals the caster's nearby party.
const selected = heals[empowered ? 1 : 0] ?? heals[0];
if (!selected) return effects;
return [{
...selected,
scope: "party",
center: "caster",
radius: 10,
}, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494216: { // Heart Piercing selects its DMG1/DMG0 strike and spends one point when powered.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
return [
...effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage),
...(empowered ? [naturePowerDrain()] : []),
];
}
case 494018: { // Cross of Thorns uses two DMG1 hits or two DMG0 hits after spending one point.
const selectedHits = empowered ? selectDamage(1, 2) : [damage[0], damage[2]].filter((effect) => effect !== undefined);
if (!selectedHits.length) 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;
}
case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent.
if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects;
return effects.map((effect) => effect.kind === "hot"
? { ...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,
}];
}
default:
return effects;
}
}
/** Add Spirit of Life's source target-max-health component to a periodic heal tick. */
export function romDruidPeriodicHealAmount(
baseAmount: number,
targetMaxHealth: number,
percentMaxHealth: number,
): number {
return Math.max(0, baseAmount) + Math.max(0, targetMaxHealth) * Math.max(0, percentMaxHealth);
}
+188
View File
@@ -0,0 +1,188 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { installCombatClock } from "./combatClock";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { companionResourceMaximum } from "./combatActorAuthority";
import { romDruidPeriodicHealAmount } from "./romDruidOverrides";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
const TARGET_ID = "rom-druid-runtime-target";
function ability(spellId: number) {
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
expect(result, `RoM Druid spell ${spellId}`).toBeDefined();
return result!;
}
function finishCast(now: number) {
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
}
function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
...(secondaryClassId ? { secondaryClassId } : {}),
raceId: "rom-elf",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 1_000,
maxResource: 1_000,
resourcePools: { ...state.resourcePools, mana: 1_000, "nature-power": 0 },
maxResourcePools: { ...state.maxResourcePools, mana: 1_000 },
}));
}
describe("RuneWaker Druid runtime", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-druid"]);
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("casts Spirit Guidance for one Nature Power and applies the source three-second Charm", () => {
prepareDruid();
useCombatStore.getState().registerMob(TARGET_ID, { name: "Guidance Target", maxHealth: 10_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
const spiritGuidance = ability(493536);
expect(spiritGuidance.costs).toContainEqual({ resource: "nature-power", amount: 1 });
expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 1_000 }))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, "nature-power": 1 } }));
expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(spiritGuidance.id);
finishCast(3_000);
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
expect(useCombatStore.getState().mobs[TARGET_ID]!.statuses).toContainEqual(expect.objectContaining({
kind: "charm",
endsAt: activeCast!.completesAt + 3_000,
}));
// The combat model supports the Charm control effect; it has no faction/allegiance conversion state.
});
it("applies Unity with Mother Earth as a 15-second healing and guaranteed spell-crit aura", () => {
prepareDruid();
const unity = ability(493548);
expect(unity.target).toBe("self");
expect(useCombatStore.getState().castAbility(unity.id, { now: 1_000 }).ok).toBe(true);
const aura = useCombatStore.getState().auras.find((entry) => (
entry.targetId === PLAYER_AURA_ENTITY_ID
&& entry.definition.utilityTags?.includes("rom-druid-unity-with-mother-earth")
));
expect(aura?.expiresAt).toBe(16_000);
expect(aura?.definition.modifiers).toContainEqual({
kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100,
});
expect(aura?.definition.modifiers).toContainEqual({
kind: "healing", direction: "done", operation: "percent", value: 0.1,
});
useCombatStore.getState().registerMob(TARGET_ID, { name: "Unity Target", maxHealth: 10_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
const arrow = ability(493538);
expect(useCombatStore.getState().castAbility(arrow.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true);
finishCast(3_000);
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
});
it("prevents movement during Unity and restores movement when its 15-second aura expires", () => {
let now = 1_000;
const restoreClock = installCombatClock(() => now);
try {
prepareDruid();
const unity = ability(493548);
expect(useCombatStore.getState().castAbility(unity.id, { now }).ok).toBe(true);
useCombatStore.getState().setPlayerPosition([6, 0, 0]);
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]);
now = 16_001;
useCombatStore.getState().setPlayerPosition([6, 0, 0]);
expect(useCombatStore.getState().playerPosition).toEqual([6, 0, 0]);
} finally {
restoreClock();
}
});
it("raises Nature Power's maximum to 15 only after learning Brilliance of Nature", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 44,
});
expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(10);
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 45,
});
expect(useCombatStore.getState().abilities.some((entry) => entry.dbcSpellId === 494931 && entry.passive)).toBe(true);
expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(15);
const companion = createConfiguredPartyMember({
name: "Druid Companion",
classId: "rom-druid",
secondaryClassId: "rom-scout",
raceId: "rom-elf",
specialization: "Primary",
role: "healer",
}, 45, 0, "rom-druid-brilliance-cap");
expect(companionResourceMaximum(companion, "nature-power")).toBe(15);
});
it("adds exactly one percent of each healing target's max health to the Spirit of Life tick", () => {
expect(romDruidPeriodicHealAmount(500, 10_000, 0.01)).toBe(600);
expect(romDruidPeriodicHealAmount(500, 5_000, 0.01)).toBe(550);
});
it("gives Spirit of Life a 40-second, four-second party heal within its source radius", () => {
prepareDruid("rom-warden");
usePartyStore.getState().initializeParty("rom-druid-spirit-of-life", 80, useCombatStore.getState().health, "healer", 5, "rom");
const [near, far] = usePartyStore.getState().members;
expect(near).toBeDefined();
expect(far).toBeDefined();
useCombatStore.setState({ health: 1 });
usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ ...member, health: 1 })) }));
registerPartyRuntimePosition(near!.id, [5, 0, 0]);
registerPartyRuntimePosition(far!.id, [12, 0, 0]);
useCombatStore.getState().synchronizeActors();
const spirit = ability(494938);
const hot = spirit.effects.find((effect) => effect.kind === "hot");
expect(hot).toMatchObject({
ticks: 10, intervalMs: 4_000, scope: "party", radius: 7, center: "caster", percentMaxHealth: 0.01,
});
expect(spirit.effects.some((effect) => effect.kind === "summon" || effect.kind === "heal")).toBe(false);
const beforePlayer = useCombatStore.getState().health;
const beforeNear = usePartyStore.getState().members.find((member) => member.id === near!.id)!.health;
const beforeFar = usePartyStore.getState().members.find((member) => member.id === far!.id)!.health;
expect(useCombatStore.getState().castAbility(spirit.id, { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().summons.some((summon) => summon.sourceAbilityId === spirit.id)).toBe(false);
useCombatStore.getState().tick(4.1, 5_100);
expect(useCombatStore.getState().health).toBeGreaterThan(beforePlayer);
expect(usePartyStore.getState().members.find((member) => member.id === near!.id)!.health).toBeGreaterThan(beforeNear);
expect(usePartyStore.getState().members.find((member) => member.id === far!.id)!.health).toBe(beforeFar);
});
it("casts Savage Blessing on the selected friendly target with beneficial hit buffs", () => {
prepareDruid();
usePartyStore.getState().initializeParty("rom-druid-savage-blessing", 80, useCombatStore.getState().health, "healer", 5, "rom");
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
const blessing = ability(493531);
expect(blessing.target).toBe("friendly");
expect(useCombatStore.getState().castAbility(blessing.id, { targetActorId: ally.id, now: 1_000 }).ok).toBe(true);
finishCast(1_000);
const buffs = useCombatStore.getState().auras.filter((aura) => aura.definition.name.startsWith("Savage Blessing"));
expect(buffs.length).toBeGreaterThanOrEqual(2);
expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true);
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
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";
function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-491608",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491608,
}];
}
/** Source-backed Knight passive and cast adjustments. */
export function overrideRomKnightEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
if (row.sourceSkillId === 499934 && row.passive) return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-499934",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 499934,
}];
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_SHIELD_OF_VALOR_TAG],
},
} : effect);
return null;
}
/** Apply Divine Punishment's extracted +1% only to its named source skill. */
export function romKnightCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[] | ReadonlySet<string> = [],
): readonly AbilityEffect[] {
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;
return 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 }),
};
});
}
+323
View File
@@ -0,0 +1,323 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import {
ROM_KNIGHT_DIVINE_PUNISHMENT_TAG,
ROM_KNIGHT_GODS_VENGEANCE_TAG,
ROM_KNIGHT_SHIELD_OF_VALOR_TAG,
} from "./romKnightOverrides";
import { romEffectsForCast } from "./romCombatMechanics";
function stableCombatUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function godsVengeanceCastTime(wantProc: boolean): number {
for (let now = 11_500; now < 100_000; now += 100) {
const roll = stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:rom-knight-general-0:gods-vengeance-target:${now}:rom-gods-vengeance`,
);
if ((roll < 0.5) === wantProc) return now;
}
throw new Error("Could not find a deterministic God's Vengeance cast-time seed.");
}
describe("RuneWaker Knight periodic skills", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("keeps Holy Light Domain as a three-pulse Holy area effect", () => {
const domain = abilityById("rom-knight-rom-mage-elite-15")!;
expect(domain).toMatchObject({ dbcSpellId: 491606, target: "self", radius: 10 });
expect(domain.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "dot",
school: "holy",
ticks: 3,
intervalMs: 2_000,
tag: "rom-knight-holy-light-domain",
}),
]));
expect(domain.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "rom-trigger-skill"))).toBe(false);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
for (const [id, x] of [["domain-near", 5], ["domain-edge", 9], ["domain-far", 12]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
const initialHealth = Object.fromEntries(Object.entries(useCombatStore.getState().mobs)
.map(([id, mob]) => [id, mob.health]));
expect(useCombatStore.getState().castAbility(domain.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.filter((effect) => effect.kind === "dot")
.map((effect) => effect.targetId).sort()).toEqual(["domain-edge", "domain-near"]);
expect(useCombatStore.getState().mobs["domain-near"]!.health).toBe(initialHealth["domain-near"]);
useCombatStore.getState().tick(0.1, 3_000);
const afterFirstPulse = useCombatStore.getState();
expect(afterFirstPulse.mobs["domain-near"]!.health).toBeLessThan(initialHealth["domain-near"]!);
expect(afterFirstPulse.mobs["domain-edge"]!.health).toBeLessThan(initialHealth["domain-edge"]!);
expect(afterFirstPulse.mobs["domain-far"]!.health).toBe(initialHealth["domain-far"]);
});
it("adds War Prayer Holy Seal stacks on its six three-second pulses", () => {
const prayer = abilityById("rom-knight-rom-mage-elite-50")!;
expect(prayer).toMatchObject({ dbcSpellId: 490078, target: "hostile" });
expect(prayer.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", school: "holy" }),
expect.objectContaining({
kind: "dot",
ticks: 6,
intervalMs: 3_000,
tag: "rom-knight-war-prayer-seal",
}),
]));
expect(prayer.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "rom-trigger-skill"))).toBe(false);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("war-prayer-target", { name: "War Prayer Target", maxHealth: 100_000 });
setMobPosition("war-prayer-target", [3, 0, 0]);
useCombatStore.getState().selectMob("war-prayer-target");
expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(0);
expect(useCombatStore.getState().effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dot", targetId: "war-prayer-target", remainingTicks: 6 }),
]));
for (let tick = 1; tick <= 6; tick += 1) {
useCombatStore.getState().tick(0.1, 1_000 + tick * 3_000);
expect(useCombatStore.getState().comboTargetId).toBe("war-prayer-target");
expect(useCombatStore.getState().comboPoints).toBe(Math.min(tick, 4));
}
});
it("limits Divine Punishment's sourced 1% bonus to Punishment", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
});
const state = useCombatStore.getState();
const passive = state.abilities.find((ability) => ability.dbcSpellId === 491608);
expect(passive?.passive).toBe(true);
const passiveAura = state.auras.find((aura) => aura.definition.utilityTags?.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG));
expect(passiveAura).toBeDefined();
expect(passive?.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG],
}),
}),
]);
const passiveEffect = passive?.effects[0];
expect(passiveEffect?.kind).toBe("apply-aura");
if (passiveEffect?.kind === "apply-aura") expect(passiveEffect.aura.modifiers).toBeUndefined();
const punishment = abilityById("rom-knight-general-0")!;
const context = {
sealStacks: 4,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: state.maxHealth,
targetHealthPercent: 1,
} as const;
const baseline = romEffectsForCast(punishment, context);
const boosted = romEffectsForCast(punishment, {
...context,
passiveTags: passiveAura?.definition.utilityTags ?? [],
});
const damageValues = (effects: typeof baseline) => effects
.filter((effect) => effect.kind === "damage" || effect.kind === "dot")
.map((effect) => ({
coefficient: effect.coefficient,
basePoints: "basePoints" in effect ? effect.basePoints ?? 0 : 0,
pointsPerLevel: "pointsPerLevel" in effect ? effect.pointsPerLevel ?? 0 : 0,
}));
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(romEffectsForCast(abilityById("rom-knight-primary-1")!, {
...context,
passiveTags: passiveAura?.definition.utilityTags ?? [],
})).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context));
});
it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => {
const godVengeance = abilityById("rom-knight-rom-rogue-elite-50")!;
expect(godVengeance).toMatchObject({ dbcSpellId: 499934, passive: true });
expect(godVengeance.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }),
}),
]);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("gods-vengeance-target", {
name: "God's Vengeance Target", maxHealth: 1_000_000,
});
setMobPosition("gods-vengeance-target", [3, 0, 0]);
useCombatStore.getState().selectMob("gods-vengeance-target");
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
targetId: PLAYER_AURA_ENTITY_ID,
definition: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }),
}),
]));
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(4);
const castTime = godsVengeanceCastTime(preservesSeal);
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, castTime))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(preservesSeal ? 4 : 0);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.targetId === "gods-vengeance-target" && effect.tag === "rom-holy-seal"))
.toBe(preservesSeal);
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
&& event.abilityId === "rom-knight-general-0" && event.targetActorId === "gods-vengeance-target"
&& event.effectiveAmount > 0)).toBe(true);
});
it("reduces Holy Seal cooldown by one second when Shield of Valor parries", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", raceId: "rom-human", level: 80, role: "tank",
});
const holySeal = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490149)!;
const shieldOfValor = abilityById("rom-knight-primary-10")!;
expect(shieldOfValor).toMatchObject({ dbcSpellId: 490468 });
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, parryRating: 100_000 },
}));
expect(useCombatStore.getState().castAbility(shieldOfValor.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
targetId: PLAYER_AURA_ENTITY_ID,
definition: expect.objectContaining({ utilityTags: expect.arrayContaining([ROM_KNIGHT_SHIELD_OF_VALOR_TAG]) }),
}),
]));
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [holySeal.id]: 25_000 },
}));
useCombatStore.getState().registerMob("shield-of-valor-attacker", {
name: "Shield of Valor Attacker",
maxHealth: 100_000,
attacks: [DEFAULT_ENEMY_ATTACK],
});
setMobPosition("shield-of-valor-attacker", [2, 0, 0]);
useCombatStore.getState().damageMob("shield-of-valor-attacker", 1, 1_100);
let parryAt: number | null = null;
for (let now = 1_450; now < 16_000; now += 100) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().lastCombatResult?.parried) {
parryAt = now;
break;
}
}
expect(parryAt).not.toBeNull();
expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(24_000);
});
it("activates the source-backed Evil Shackles passive state", () => {
const passive = abilityById("rom-knight-rom-scout-elite-60")!;
expect(passive).toMatchObject({ dbcSpellId: 499926, passive: true });
expect(passive.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: ["rom-evil-shackles"],
}),
}),
]);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
definition: expect.objectContaining({ utilityTags: ["rom-evil-shackles"] }),
}),
]));
});
it("lets Evil Shackles root non-Undead/Demon targets for half duration", () => {
const holyChains = abilityById("rom-knight-rom-scout-elite-25")!;
const rootDuration = (targetTags: readonly string[]) => {
const root = romEffectsForCast(holyChains, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
passiveTags: ["rom-evil-shackles"],
targetTags,
}).find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root");
return root?.kind === "apply-aura" ? root.aura.durationMs : undefined;
};
expect(rootDuration(["rom-target-undead-demon"])).toBe(10_000);
expect(rootDuration(["rom-target-living"])).toBe(5_000);
expect(rootDuration([])).toBe(5_000);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Living target", maxHealth: 100_000, creatureFamily: "living",
});
setMobPosition("shackles-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shackles-target");
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 1_000)).toMatchObject({
ok: false, reason: "condition-not-met",
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Unknown target", maxHealth: 100_000, creatureFamily: "unknown",
});
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 2_000)).toMatchObject({
ok: false, reason: "condition-not-met",
});
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Living target", maxHealth: 100_000, creatureFamily: "living",
});
setMobPosition("shackles-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shackles-target");
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["shackles-target"]!.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "root", endsAt: 8_000 }),
]));
});
});
+365
View File
@@ -0,0 +1,365 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById, resourceProfileForClass } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, resolveResourceValue } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
romMageWarlockCastEffects,
} from "./romMageWarlockOverrides";
import { applyTalentModifierValue } from "./talentModifiers";
function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-mage",
secondaryClassId,
raceId: "rom-human",
level,
});
}
function addTarget(id: string, x: number, z = 0): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
describe("RoM Mage mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => {
const catalysis = abilityById("rom-mage-primary-0")!;
expect(catalysis.effects).toContainEqual(expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: expect.arrayContaining([
expect.objectContaining({
kind: "stat",
stat: "mana-regeneration-max-percent-per-second",
operation: "flat",
value: 0.01,
}),
]),
}),
}));
initializeMage(undefined, 60);
addTarget("catalysis-combat", 3);
useCombatStore.setState((state) => ({
...state,
resource: state.maxResource / 2,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } },
}));
const before = useCombatStore.getState().resourcePools.mana!;
expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true);
useCombatStore.getState().tick(1, 2_000);
useCombatStore.getState().tick(1, 3_000);
const restored = useCombatStore.getState().resourcePools.mana! - before;
expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02);
});
it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => {
initializeMage(undefined, 65);
const catalysis = abilityById("rom-mage-primary-0")!;
const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971);
expect(rankTwo).toBeDefined();
expect(rankTwo?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })],
}),
}),
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })],
}),
}),
]));
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false);
addTarget("catalysis-rank-two-combat", 3);
useCombatStore.setState((state) => ({
...state,
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } },
}));
const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.includes("497971")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true);
});
it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => {
const flame = abilityById("rom-mage-primary-1")!;
expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 });
initializeMage(undefined, 65);
addTarget("catalysis-haste-rank-two-baseline", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline");
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
initializeMage(undefined, 65);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-two-target", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-two-target");
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.includes("497971")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true);
expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: flame.id,
startedAt: 5_000,
originalDurationMs: Math.round(rankTwoBaselineMs / 1.5),
completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5),
});
initializeMage(undefined, 60);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-one-baseline", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline");
expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true });
const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
initializeMage(undefined, 60);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-one-target", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-one-target");
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-haste"))).toBe(false);
expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: flame.id,
startedAt: 11_000,
originalDurationMs: rankOneBaselineMs,
completesAt: 11_000 + rankOneBaselineMs,
});
});
it("does not apply a RoM spell-haste aura to another class's cast time", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
addTarget("non-mage-haste-baseline", 3);
useCombatStore.getState().selectMob("non-mage-haste-baseline");
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true });
const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
clearMobRuntimeRegistry();
addTarget("non-mage-haste-target", 3);
useCombatStore.getState().selectMob("non-mage-haste-target");
useCombatStore.setState((state) => ({
auras: applyAura(state.auras, {
definition: {
id: "rom-catalysis-non-mage-test",
name: "Test Catalysis haste",
disposition: "buff",
durationMs: 20_000,
modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }],
},
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 3_000,
}).auras,
}));
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: "priest-smite",
originalDurationMs: priestBaselineMs,
completesAt: 3_000 + priestBaselineMs,
});
});
it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
addTarget("ordinary-mana-combat", 3);
useCombatStore.setState((state) => ({
...state,
resource: state.maxResource / 2,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } },
}));
const beforeState = useCombatStore.getState();
const before = beforeState.resourcePools.mana!;
const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
const baseRegeneration = applyTalentModifierValue(
resourceProfileForClass("priest").regenerationPerSecond
+ beforeState.gearStats.mp5 / 5
+ beforeState.gearStats.spirit * 0.02,
beforeState.talentModifiers,
"resource-regeneration-percent",
);
const expectedPerSecond = resolveResourceValue(
resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"),
playerAuras,
"power",
"regeneration",
);
expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second"
)))).toBe(false);
useCombatStore.getState().tick(1, 2_000);
useCombatStore.getState().tick(1, 3_000);
expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2);
});
it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => {
initializeMage();
for (const [id, x, z] of [
["phoenix-target", 4, 0],
["phoenix-path", 8, 0],
["phoenix-off-line", 7, 2],
["phoenix-beyond", 12.1, 0],
] as const) addTarget(id, x, z);
useCombatStore.getState().selectMob("phoenix-target");
expect(abilityById("rom-mage-primary-6")).toMatchObject({
target: "hostile",
range: { max: 12 },
targeting: { shape: "line", radius: 12 },
});
expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000))
.toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] });
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 });
clearMobRuntimeRegistry();
for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) {
addTarget(id, x);
}
useCombatStore.getState().selectMob("arrow-target");
expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000))
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
});
it("doubles only Fireball and Flame on the 10% 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);
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
? expect.objectContaining({
coefficient: effect.coefficient * 2,
basePoints: (effect.basePoints ?? 0) * 2,
pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2,
})
: effect),
);
expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual(
fireball.effects.filter((effect) => effect.kind === "damage"),
);
expect(procced).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
id: "rom-mage-druid-control-flame-buff",
durationMs: 12_000,
modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })],
}),
}),
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
id: "rom-mage-druid-control-flame-cooldown",
durationMs: 20_000,
}),
}),
]));
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
...tags,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
]), 0.099).some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
});
it("applies Eruption and Control Flame when a Fireball proc succeeds", () => {
initializeMage("rom-druid");
addTarget("eruption-target", 3);
useCombatStore.getState().selectMob("eruption-target");
const before = useCombatStore.getState().mobs["eruption-target"]!.health;
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"))
.toBe(true);
const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff");
expect(flameBuff?.definition.modifiers).toEqual([
expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }),
]);
});
it("reduces Fireball cooldown by one second with Flame Follower", () => {
initializeMage("rom-warden");
addTarget("follower-target", 3);
useCombatStore.getState().selectMob("follower-target");
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000);
});
it("resets only Lightning and Discharge with Brain Shock", () => {
initializeMage("rom-champion");
useCombatStore.setState((state) => ({
cooldowns: {
...state.cooldowns,
"rom-mage-general-1": 8_000,
"rom-mage-primary-4": 10_000,
"rom-mage-general-0": 9_000,
},
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns).toMatchObject({
"rom-mage-general-1": 0,
"rom-mage-primary-4": 0,
"rom-mage-general-0": 9_000,
});
});
it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => {
initializeMage(undefined, 80);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
initializeMage("rom-champion", 40);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
initializeMage("rom-champion", 80);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000);
});
it("keeps Mage Fire Ward party-scoped", () => {
const fireWard = abilityById("rom-mage-general-5")!;
expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"))
.toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { AbilityDefinition } from "./abilityCatalog";
const ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID = 498_871;
const ELECTROSTATIC_CHARGE_SPELL_ID = 491_156;
/** Mage/Champion's level-45 passive shortens Electrostatic Charge by 30 seconds. */
export function romMageCooldownReductionMs(
classId: string | null,
secondaryClassId: string | null | undefined,
ability: Pick<AbilityDefinition, "dbcSpellId">,
level: number,
abilities: readonly Pick<AbilityDefinition, "dbcSpellId" | "unlockLevel" | "passive">[],
): number {
if (classId !== "rom-mage"
|| secondaryClassId !== "rom-champion"
|| ability.dbcSpellId !== ELECTROSTATIC_CHARGE_SPELL_ID
|| level < 45) return 0;
return abilities.some((candidate) => candidate.passive
&& candidate.dbcSpellId === ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID
&& candidate.unlockLevel <= level)
? 30_000
: 0;
}
+68
View File
@@ -5,6 +5,10 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG = "rom-electrostatic-percent-max-health";
export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({
eruption: "rom-mage-eruption",
controlFlame: "rom-mage-druid-control-flame",
controlFlameCooldown: "rom-mage-druid-control-flame-cooldown",
flameFollower: "rom-mage-warden-flame-follower",
industryFire: "rom-mage-warlock-industry-fire",
lightningDisplay: "rom-mage-warlock-lightning-display",
elementsOfPride: "rom-mage-warlock-elements-of-pride",
@@ -23,6 +27,55 @@ 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;
function scaledDamage(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage") return effect;
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }),
};
}
function controlFlameTriggeredAuras(): readonly AbilityEffect[] {
return [
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-mage-druid-control-flame-buff",
name: "Control Flame",
disposition: "buff",
durationMs: 12_000,
modifiers: [{ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }],
},
sourceEffectType: -1,
sourceAuraType: 171,
miscValue: 8,
triggerSpellId: 494054,
},
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-mage-druid-control-flame-cooldown",
name: "Control Flame cooldown",
disposition: "buff",
durationMs: 20_000,
utilityTags: [ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 494078,
},
];
}
function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect {
return {
@@ -53,6 +106,10 @@ export function overrideRomMageWarlockEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 490221) return [stateAura("eruption", ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption, null)];
if (row.sourceSkillId === 494054) return [stateAura("control-flame", ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame, null)];
if (row.sourceSkillId === 494051) return [stateAura("flame-follower", ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower, null)];
if (row.sourceSkillId === 491156) {
return translated.map((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return effect;
@@ -113,9 +170,20 @@ export function romMageWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: ReadonlySet<string>,
eruptionProcRoll = 1,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
if ([490204, 491150].includes(ability.dbcSpellId)
&& passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption)
&& eruptionProcRoll < MAGE_ERUPTION_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)
? [...doubledDamage, ...controlFlameTriggeredAuras()]
: doubledDamage;
}
if (ability.dbcSpellId === 497964 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire)) {
return [
{
+30 -4
View File
@@ -47,6 +47,11 @@ 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);
const knownEmptyEffectElites = new Set([
"rom-warlock-rom-warrior-elite-30",
"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) {
const key = `${ability.classId}:${ability.secondaryClassId}`;
@@ -56,7 +61,15 @@ describe("generated RuneWaker player catalog", () => {
for (const [pair, elites] of pairGroups) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true);
for (const ability of elites) {
expect(ability.source, `${pair}:${ability.id} source`).toBe("runewaker");
if (knownEmptyEffectElites.has(ability.id)) {
expect(ability.passive).toBe(true);
expect(ability.effects, `${pair}:${ability.id} source payload is incomplete`).toEqual([]);
} else {
expect(ability.effects, `${pair}:${ability.id} effects`).not.toEqual([]);
}
}
}
expect(ROM_ALL_ABILITIES).toHaveLength(930);
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
@@ -93,13 +106,24 @@ describe("generated RuneWaker player catalog", () => {
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
});
it("preserves every source rank and its full flat or percentage cost", () => {
it("preserves every source rank and cost except documented runtime overrides", () => {
const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({
resource: cost.resource,
amount: cost.amount,
...(cost.percentage ? { percentage: true } : {}),
}));
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
const sourceCosts = (sourceSkillId: number, rows: typeof generated.skills[number]["costs"]) => {
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.
if (sourceSkillId === 490136) {
normalized = normalized.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
}
if (![493536, 494217].includes(sourceSkillId)
|| normalized.some((entry) => entry.resource === "nature-power")) return normalized;
return [...normalized, { resource: "nature-power", amount: 1 }];
};
expect(generatedRanks).toBe(934);
expect(Math.max(...generated.skills.flatMap((ability) => (
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
@@ -107,11 +131,13 @@ describe("generated RuneWaker player catalog", () => {
for (const source of generated.skills) {
const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!;
expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs));
expect(runtime.costs, `${source.name} base costs`).toEqual(sourceCosts(source.sourceSkillId, source.costs));
expect(runtime.ranks).toHaveLength(source.ranks.length);
for (const [index, sourceRank] of source.ranks.entries()) {
expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId);
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs));
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(
sourceCosts(sourceRank.sourceSkillId, sourceRank.costs),
);
}
}
});
+13
View File
@@ -0,0 +1,13 @@
import type { AbilityDefinition } from "./abilityCatalog";
/** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */
export function romPriestChampionCooldownOverride(
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;
}
+111
View File
@@ -33,6 +33,8 @@ export function overrideRomPriestKnightEffects(
return passiveState(499968, row.name, "rom-splendor-ripple");
case 499957: // Light Guidance: activate healing bonus only with source state 499966.
return passiveState(499957, row.name, "rom-light-guidance");
case 492638: // Lightning Rod: Lightning shocks targets already afflicted by Bone Chill.
return passiveState(492638, row.name, "rom-priest-mage-lightning-rod");
case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage.
return passiveState(491610, row.name, "rom-enhanced-holy-strike");
case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage.
@@ -151,8 +153,36 @@ export function romPriestKnightCastEffects(
passiveTags: readonly string[] | ReadonlySet<string>,
casterMaxHealth: number,
targetTags: readonly string[] | ReadonlySet<string> = [],
friendlyTargetHealthPercent = 1,
learnedPassiveSpellIds: readonly number[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
if (ability.dbcSpellId === 490261
&& learnedPassiveSpellIds.includes(493039)
&& friendlyTargetHealthPercent < 0.3) {
// Saint's Blessing's active source components grant +30 healing points and
// +1% healing power for 10 seconds. Put the aura before Heal so the
// triggering cast itself receives the sourced bonus as well.
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-priest-saints-blessing-healing",
name: "Saint's Blessing",
disposition: "buff",
durationMs: 10_000,
modifiers: [
{ kind: "healing", direction: "done", operation: "flat", value: 30 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.01 },
],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 493039,
}, ...effects];
}
switch (ability.dbcSpellId) {
case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP.
if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects;
@@ -236,6 +266,28 @@ export function romPriestKnightCastEffects(
miscValue: 5,
triggerSpellId: 623015,
}];
case 490212: // Lightning Rod adds source child 502907 only while Bone Chill is on the target.
if (!hasTag(passiveTags, "rom-priest-mage-lightning-rod")
|| !hasTag(targetTags, "rom-491165-501548-0")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-priest-mage-lightning-rod-shock",
name: "Electric Shock",
disposition: "debuff",
durationMs: 20_000,
modifiers: [
{ kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 },
{ kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 },
],
utilityTags: ["rom-priest-mage-lightning-rod-shock"],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 502907,
}];
case 490434: // Source 499924 adds component 623167 to the base Blood Arrow.
if (!hasTag(passiveTags, "rom-blood-punishment")) return effects;
return [...effects, {
@@ -260,6 +312,65 @@ export function romPriestKnightCastEffects(
}
}
/** Select the pair-specific payload branches that depend on target state. */
export function romPriestKnightConditionalCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
sealStacks: number,
targetIsCaster: boolean,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-knight"
|| ability.secondaryClassId !== "rom-priest") return effects;
if (ability.dbcSpellId === 490194) {
const baseHit = effects.find((effect) => effect.kind === "damage");
if (!baseHit) return effects;
// The generated row carries a placeholder heal with no source-backed
// amount; only the Holy Seal components grant healing.
const nonDamage = effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "heal");
const seals = Math.min(4, Math.max(0, Math.trunc(sealStacks)));
if (seals === 0) return [baseHit, ...nonDamage];
const healPercent = Math.min(8, seals * 2) / 100;
return [
baseHit,
...nonDamage,
{ kind: "heal", coefficient: 0, percentMaxHealth: healPercent, recipient: "caster" },
{
kind: "heal",
coefficient: 0,
percentMaxHealth: healPercent,
scope: "party",
center: "caster",
maximumTargets: 1,
excludeCaster: true,
},
{ kind: "combo", amount: -seals },
];
}
if (ability.dbcSpellId === 491640) {
const selectedThreatPolarity = targetIsCaster ? 1 : -1;
const hasThreatPayload = effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat"));
if (!hasThreatPayload) return effects;
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) =>
modifier.kind === "stat" && modifier.stat === "threat")
? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat" && modifier.stat === "threat"
? { ...modifier, value: Math.abs(modifier.value) * selectedThreatPolarity }
: modifier),
},
}
: effect);
}
return effects;
}
/** Source skill 499966 is granted by the level-70 Priest/Knight elite pair. */
export function romPriestKnightAdditionalAbilities(
catalog: readonly AbilityDefinition[],
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { healingRecipientIds } from "./healingAbilityRules";
import type { CombatActorState } from "./combatActors";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides";
import { romPriestKnightConditionalCastEffects } from "./romPriestKnightOverrides";
const holySmite = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 490194)!;
const holyProtection = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 491640)!;
describe("RoM Knight/Priest conditional combat payloads", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
it("replaces Holy Smite's conditional damage with stack-scaled caster and ally heals", () => {
const noSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 0, false);
expect(noSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(noSeals.filter((effect) => effect.kind === "heal")).toHaveLength(0);
const fourSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 4, false);
expect(fourSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(fourSeals.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, recipient: "caster" }),
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, scope: "party", excludeCaster: true }),
]);
expect(fourSeals).toContainEqual({ kind: "combo", amount: -4 });
});
it("chooses only the matching Holy Protection threat payload for self versus ally", () => {
const threatAmount = (targetIsCaster: boolean) => {
const selected = romPriestKnightConditionalCastEffects(
holyProtection, holyProtection.effects, 0, targetIsCaster,
);
const aura = selected.find((effect) => effect.kind === "apply-aura");
if (aura?.kind !== "apply-aura") return null;
return aura.aura.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat")?.value;
};
expect(threatAmount(false)).toBe(-0.02);
expect(threatAmount(true)).toBe(0.02);
for (const targetIsCaster of [false, true]) {
const selected = romPriestKnightConditionalCastEffects(
holyProtection, holyProtection.effects, 0, targetIsCaster,
);
expect(selected.filter((effect) => effect.kind === "apply-aura")).toHaveLength(3);
expect(selected.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& (modifier.stat === "armor" || modifier.stat === "magical-defense")))).toBe(true);
}
});
it("excludes only the caster from Holy Smite's second heal recipient", () => {
const actors = {
[PLAYER_AGGRO_ID]: { id: PLAYER_AGGRO_ID, kind: "player", alive: true, health: 50, maxHealth: 100 },
ally: { id: "ally", kind: "companion", alive: true, health: 25, maxHealth: 100 },
} as unknown as Readonly<Record<string, CombatActorState>>;
const positionFor = (id: string) => id === PLAYER_AGGRO_ID
? [0, 0, 0] as const
: id === "ally" ? [3, 0, 0] as const : null;
const normalPartyHeal = healingRecipientIds(
{ scope: "party", center: "caster" }, PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true,
);
const holySmitePartyHeal = healingRecipientIds(
{ scope: "party", center: "caster", maximumTargets: 1, excludeCaster: true },
PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true,
);
expect(normalPartyHeal).toContain(PLAYER_AGGRO_ID);
expect(holySmitePartyHeal).toEqual(["ally"]);
});
it("removes Chain of Light's cooldown only with Priest/Champion Infused Light Chain", () => {
const chain = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491164)!;
expect(romPriestChampionCooldownOverride(chain, [])).toBeNull();
expect(romPriestChampionCooldownOverride(chain, [498610])).toBe(0);
expect(romPriestChampionCooldownOverride({ ...chain, classId: "rom-knight" }, [498610])).toBeNull();
});
it("applies Infused Light Chain to the active Priest/Champion channel", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 30,
});
useCombatStore.getState().registerMob("priest-champion-chain", { name: "Channel target", maxHealth: 100_000 });
setMobPosition("priest-champion-chain", [3, 0, 0]);
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();
});
it("consumes Holy Seals only when Holy Smite uses them and heals the caster plus one ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank",
});
usePartyStore.getState().initializeParty("knight-priest-holy-smite", 30, useCombatStore.getState().health, "tank", 2, "rom");
const ally = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(ally.id, Math.floor(ally.maxHealth * 0.5));
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5));
useCombatStore.getState().registerMob("knight-priest-holy-smite-target", {
name: "Holy Seal target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("knight-priest-holy-smite-target", [2, 0, 0]);
useCombatStore.getState().selectMob("knight-priest-holy-smite-target");
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(4);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(true);
const playerHealth = useCombatStore.getState().health;
const allyHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-20", undefined, 2_600))
.toMatchObject({ ok: true });
const afterCast = useCombatStore.getState();
expect(afterCast.comboPoints).toBe(0);
expect(afterCast.effects.some((effect) => effect.kind === "dot"
&& effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(false);
expect(afterCast.health).toBeGreaterThan(playerHealth);
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
expect(afterCast.combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "rom-knight-rom-priest-elite-20")).toHaveLength(1);
});
it("casts only the self or ally Holy Protection threat payload", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank",
});
usePartyStore.getState().initializeParty("knight-priest-holy-protection", 30, useCombatStore.getState().health, "tank", 2, "rom");
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 1_000))
.toMatchObject({ ok: true });
const selfProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat"));
expect(selfProtection?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "threat", value: 0.02 }),
]));
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
usePartyStore.getState().selectMember(ally.id);
const allyCast = useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 2_600);
expect(allyCast).toMatchObject({ ok: true });
const allyProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat"));
expect(allyProtection?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "threat", value: -0.02 }),
]));
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value > 0))).toBe(false);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
const lightningRodPassive = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 492638)!;
const lightning = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490212)!;
const boneChill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491165)!;
const lightningRodTag = "rom-priest-mage-lightning-rod";
const boneChillTag = "rom-491165-501548-0";
describe("RoM Priest/Mage Lightning Rod", () => {
it("exposes the passive and Bone Chill child tags used by the cast hook", () => {
expect(lightningRodPassive.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: [lightningRodTag] }),
}),
]));
expect(boneChill.effects.some((effect) => effect.kind === "dot" && effect.tag === boneChillTag)).toBe(true);
});
it("applies the 20-second Water/Wind resistance shock only with the passive and Bone Chill", () => {
const withoutPassive = romPriestKnightCastEffects(
lightning, lightning.effects, [], 1_000, [boneChillTag],
);
const withoutBoneChill = romPriestKnightCastEffects(
lightning, lightning.effects, [lightningRodTag], 1_000, [],
);
expect(withoutPassive).toEqual(lightning.effects);
expect(withoutBoneChill).toEqual(lightning.effects);
const withBothConditions = romPriestKnightCastEffects(
lightning, lightning.effects, [lightningRodTag], 1_000, [boneChillTag],
);
expect(withBothConditions).toContainEqual(expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
triggerSpellId: 502907,
aura: expect.objectContaining({
id: "rom-priest-mage-lightning-rod-shock",
name: "Electric Shock",
disposition: "debuff",
durationMs: 20_000,
modifiers: [
{ kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 },
{ kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 },
],
}),
}));
});
it("keeps the proc on the shared RoM cast-effects path", () => {
const mechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
};
const withoutBoneChill = romEffectsForCast(lightning, {
...mechanics,
passiveTags: [lightningRodTag],
});
const withBoneChill = romEffectsForCast(lightning, {
...mechanics,
passiveTags: [lightningRodTag],
targetTags: [boneChillTag],
});
expect(withoutBoneChill.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(false);
expect(withBoneChill.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(true);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490261)!;
describe("RoM Priest/Scout Saint's Blessing", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-scout",
raceId: "rom-human",
level: 50,
});
usePartyStore.getState().initializeParty(
"rom-priest-saints-blessing",
50,
useCombatStore.getState().health,
"healer",
2,
"rom",
);
});
it("arms the extracted 10-second +30 healing and +1% healing aura only below 30% HP", () => {
const base = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
learnedPassiveSpellIds: [493039],
};
const lowTarget = romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.299 });
expect(lowTarget[0]).toMatchObject({
kind: "apply-aura",
recipient: "caster",
triggerSpellId: 493039,
aura: {
id: "rom-priest-saints-blessing-healing",
durationMs: 10_000,
modifiers: [
{ kind: "healing", direction: "done", operation: "flat", value: 30 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.01 },
],
},
});
expect(lowTarget[1]).toEqual(heal.effects[0]);
expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.3 })).toEqual(heal.effects);
expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.1, learnedPassiveSpellIds: [] }))
.toEqual(heal.effects);
});
it("uses the selected ally's current HP and applies the aura before the triggering Heal", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: Math.floor(candidate.maxHealth * 0.2) }
: candidate),
}));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const injuredHealth = usePartyStore.getState().members[0]!.health;
expect(usePartyStore.getState().members[0]!.health / member.maxHealth).toBeLessThan(0.3);
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_000);
const state = useCombatStore.getState();
const aura = state.auras.find((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID
&& entry.definition.id === "rom-priest-saints-blessing-healing");
expect(aura).toMatchObject({ expiresAt: 13_000 });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
expect(state.combatEvents.some((event) => event.kind === "healing"
&& event.abilityId === "rom-priest-primary-2"
&& event.targetActorId === member.id
&& event.rawAmount > 30)).toBe(true);
});
});
+5
View File
@@ -20,6 +20,11 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
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.",
// The 1.5-second channel and 5-second cooldown are supplied by the current
// CoA Wiki entry for the matching skill ID 498770; healing and threshold
// buffs remain based on this repository's RuneWaker snapshot. CoA's added
// area-damage rider is intentionally not imported into this older payload.
498770: "Channels for 1.5 seconds, healing a friendly target four times. If the target finishes above 40% health, it gains 2% Physical and Magical Defense for 10 seconds. Below 40% health, the defense effect can gain two stacks per cast, up to eight stacks. Cooldown: 5 seconds.",
490256: "Sends a torrent at the target, dealing Water damage.",
490269: "Restores a friendly target's health every 2 seconds for 20 seconds.",
490300: "Makes you immune to damage for 5 seconds.",
+33
View File
@@ -0,0 +1,33 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
/** Select local Prayer of Tribulation buff stacks from its tooltip HP gates. */
export function romPriestWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
friendlyTargetHealthPercent: number,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-priest"
|| ability.secondaryClassId !== "rom-warlock"
|| ability.dbcSpellId !== 498770) return effects;
const buff = effects.find((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura"
));
if (!buff || friendlyTargetHealthPercent === 0.4) return effects.filter((effect) => effect !== buff);
const stacks = friendlyTargetHealthPercent < 0.4 ? 2 : 1;
const stackedBuff: Extract<AbilityEffect, { kind: "apply-aura" }> = {
...buff,
aura: {
...buff.aura,
maxStacks: 8,
stackBehavior: "add",
modifiers: buff.aura.modifiers?.map((modifier) => ({ ...modifier, perStack: true })),
},
};
return [
...effects.filter((effect) => effect !== buff),
...Array.from({ length: stacks }, () => stackedBuff),
];
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { romEffectsForCast } from "./romCombatMechanics";
const prayer = abilityById("rom-priest-rom-warlock-elite-25")!;
function prayerEffects(targetHealthPercent: number) {
return romEffectsForCast(prayer, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
friendlyTargetHealthPercent: targetHealthPercent,
});
}
describe("RoM Priest/Warlock Prayer of Tribulation", () => {
it("channels four source-authored friendly heals and fills missing timing from CoA", () => {
expect(prayer).toMatchObject({
dbcSpellId: 498770,
target: "friendly",
castMode: "channel",
castTimeMs: 1_500,
cooldownMs: 5_000,
channelPulses: 4,
});
const heals = prayer.effects.filter((effect) => effect.kind === "heal");
expect(heals).toHaveLength(4);
expect(prayer.effects.some((effect) => effect.kind === "damage")).toBe(false);
expect(channelPulseCount(prayer)).toBe(4);
expect(channelAmountEffects(prayer, 4).filter(({ effect }) => effect.kind === "heal"))
.toEqual(heals.map((effect) => ({ effect, multiplier: 0.25 })));
});
it("applies one +2% defense stack above 40%, two below, and none at 40%", () => {
const buffStacks = (effects: ReturnType<typeof prayerEffects>) => effects.filter((effect) => (
effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor"
))
));
expect(buffStacks(prayerEffects(0.41))).toHaveLength(1);
expect(buffStacks(prayerEffects(0.4))).toHaveLength(0);
expect(buffStacks(prayerEffects(0.39))).toHaveLength(2);
for (const effect of buffStacks(prayerEffects(0.39))) {
expect(effect).toMatchObject({
kind: "apply-aura",
aura: { durationMs: 10_000, maxStacks: 8, stackBehavior: "add" },
});
if (effect.kind === "apply-aura") {
expect(effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor" && modifier.value === 0.02 && modifier.perStack
))).toBe(true);
expect(effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "magical-defense" && modifier.value === 0.02 && modifier.perStack
))).toBe(true);
}
}
});
it("heals the selected ally over four pulses and applies two stacks below 40%", () => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
level: 30,
role: "healer",
});
usePartyStore.getState().initializeParty("rom-priest-warlock-prayer", 30, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, member.maxHealth - 1);
// Keep the target below 40% through the four small heals so this cast
// exercises the tooltip's low-health double-stack branch at resolve time.
usePartyStore.setState((state) => ({
members: state.members.map((partyMember) => partyMember.id === member.id
? { ...partyMember, maxHealth: 10_000 }
: partyMember),
}));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const healthAtCast = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true });
const healthByPulse: number[] = [];
let previousAt = 1_000;
for (const now of [1_375, 1_750, 2_125, 2_500]) {
useCombatStore.getState().tick((now - previousAt) / 1_000, now);
healthByPulse.push(usePartyStore.getState().members[0]!.health);
previousAt = now;
}
expect(healthByPulse[0]).toBeGreaterThan(healthAtCast);
expect(healthByPulse[1]).toBeGreaterThan(healthByPulse[0]!);
expect(healthByPulse[2]).toBeGreaterThan(healthByPulse[1]!);
expect(healthByPulse[3]).toBeGreaterThan(healthByPulse[2]!);
const defenseAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === member.id && aura.definition.name.includes("Prayer of Tribulation")
));
expect(defenseAura?.stacks).toBe(2);
expect(defenseAura?.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor" && modifier.perStack
))).toBe(true);
});
});
+307
View File
@@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { abilityById } from "./abilityCatalog";
import { clearMobRuntimeRegistry, getMobRuntimeTransform, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { LOOT_CATALOG } from "./lootCatalog";
import { abilityRequiresOutOfCombat } from "./abilityRules";
import { romEffectsForCast } from "./romCombatMechanics";
import { PLAYER_AGGRO_ID } from "./aggro";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import {
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
ROM_ROGUE_TREASURE_HUNTER_TAG,
romRogueDaggerDamageMultiplier,
romRogueShadowWalkerAdjustment,
romRogueTreasureHunterLootChance,
} from "./romRogueMechanics";
function effectAuras(abilityId: string) {
return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura");
}
function testWeapon(weaponType: string, instanceId: string, stats = { ...useCombatStore.getState().gearStats }) {
return {
instanceId,
itemId: 1,
itemKey: instanceId,
name: `Test ${weaponType}`,
quality: "uncommon" as const,
inventorySlot: weaponType === "crossbow" ? "two-hand" : "one-hand",
armorType: null,
weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats,
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
}
describe("RoM Rogue source mechanics", () => {
afterEach(() => resetCombatSpatialProvider());
beforeEach(() => {
resetCombatSpatialProvider();
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
useCombatStore.setState({ playerPosition: [0, 0, 0] });
registerCombatSpatialProvider({
actor: (id) => {
if (id === PLAYER_AGGRO_ID) return { id, position: useCombatStore.getState().playerPosition, yaw: 0 };
const transform = getMobRuntimeTransform(id);
return transform ? { id, ...transform } : null;
},
hasLineOfSight: () => true,
projectGroundPoint: (point) => point,
});
});
it("keeps Dagger Mastery, Treasure Hunter, and Off Hand Mastery as scoped passive tags", () => {
const daggerMastery = effectAuras("rom-rogue-primary-3")[0];
const treasureHunter = effectAuras("rom-rogue-primary-10")[0];
expect(daggerMastery?.kind === "apply-aura" && daggerMastery.aura.utilityTags)
.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([]);
});
it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => {
const context = {
classId: "rom-rogue",
sourceActorId: "player",
playerActorId: "player",
attackKind: "melee",
school: "physical",
mainHandDagger: true,
passiveTags: [ROM_ROGUE_DAGGER_MASTERY_TAG],
};
expect(romRogueDaggerDamageMultiplier(context)).toBe(1.1);
expect(romRogueDaggerDamageMultiplier({ ...context, mainHandDagger: false })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, classId: "rom-scout" })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, attackKind: "spell" })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
});
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2);
expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25);
expect(romRogueTreasureHunterLootChance(undefined, true)).toBeCloseTo(0.05);
expect(romRogueTreasureHunterLootChance(0.98, true)).toBe(1);
});
it("limits Shadow Walker's in-combat, cost, and cooldown rider to Rogue/Priest Sneak Attack", () => {
const sneakAttack = abilityById("rom-rogue-primary-9")!;
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: true, energyCostReduction: 10, cooldownIncreaseMs: 10_000 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-scout", sneakAttack.source, sneakAttack.dbcSpellId,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, [],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, 490306,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
});
it("adds one bonus boss loot roll only while Treasure Hunter is active", () => {
const bossKey = LOOT_CATALOG.bosses[0]!.bossKey;
const lootCount = (passiveActive: boolean) => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
if (!passiveActive) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG)),
}));
}
useCombatStore.getState().registerMob("rogue-treasure-target", {
name: "Treasure Target", boss: true, maxHealth: 10, lootSourceId: bossKey,
});
const random = vi.spyOn(Math, "random").mockReturnValue(0);
try {
useCombatStore.getState().damageMob("rogue-treasure-target", 10, 1_000);
} finally {
random.mockRestore();
}
return useCombatStore.getState().mobs["rogue-treasure-target"]!.pendingLoot.length;
};
const ordinaryDrops = lootCount(false);
const treasureHunterDrops = lootCount(true);
expect(treasureHunterDrops).toBe(ordinaryDrops + 1);
});
it("keeps Soul Stab's unconditional first hit and gates only the second hit on Weakened", () => {
const ability = abilityById("rom-rogue-rom-warlock-elite-25")!;
const context = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
const ordinary = romEffectsForCast(ability, context).filter((effect) => effect.kind === "damage");
const weakened = romEffectsForCast(ability, { ...context, targetTags: ["rom-weakened"] })
.filter((effect) => effect.kind === "damage");
expect(ordinary).toHaveLength(1);
expect(weakened).toHaveLength(2);
});
it("marks Blind Spot and Sneak Attack as rear attacks and preserves Sneak Attack's combat gate", () => {
const blindSpot = abilityById("rom-rogue-primary-4")!;
const sneakAttack = abilityById("rom-rogue-primary-9")!;
expect(blindSpot.targeting?.requiresBehindTarget).toBe(true);
expect(sneakAttack.targeting?.requiresBehindTarget).toBe(true);
expect(abilityRequiresOutOfCombat(sneakAttack)).toBe(true);
});
it("rejects Blind Spot from the front and allows Sneak Attack from behind out of combat", () => {
const dagger = testWeapon("dagger", "rogue-position-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
useCombatStore.getState().registerMob("rogue-rear-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-rear-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-rear-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, 2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-4", undefined, 1_000))
.toMatchObject({ ok: false, reason: "must-be-behind" });
useCombatStore.setState((state) => ({ playerPosition: [0, 0, -2], resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 } }));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 2_000).ok).toBe(true);
});
it("rejects Sneak Attack while an enemy is already engaged", () => {
const dagger = testWeapon("dagger", "rogue-combat-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
useCombatStore.getState().registerMob("rogue-engaged-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-engaged-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-engaged-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, -2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
mobs: { ...state.mobs, "rogue-engaged-target": { ...state.mobs["rogue-engaged-target"]!, engaged: true } },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("applies Shadow Walker to combat Sneak Attack with the exact Energy and cooldown rider", () => {
const dagger = testWeapon("dagger", "rogue-shadow-walker-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
expect(useCombatStore.getState().abilities.some((candidate) => (
candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && candidate.passive
))).toBe(true);
useCombatStore.getState().registerMob("rogue-shadow-walker-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-shadow-walker-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-shadow-walker-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, -2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
mobs: { ...state.mobs, "rogue-shadow-walker-target": { ...state.mobs["rogue-shadow-walker-target"]!, engaged: true } },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.energy).toBe(80);
expect(useCombatStore.getState().cooldowns["rom-rogue-primary-9"]).toBe(11_000);
});
it("enforces Energy Conservation's strict below-75% health threshold", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 80,
});
const maximum = useCombatStore.getState().maxHealth;
const thresholdHealth = Math.ceil(maximum * 0.75);
useCombatStore.setState((state) => ({
health: thresholdHealth,
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({
health: thresholdHealth - 1,
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000).ok).toBe(true);
});
it("requires the Rogue/Knight Lion's Protection aura for Power of the Lion", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
});
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-25", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-491552")))
.toBe(true);
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 2_600).ok).toBe(true);
});
it("increases Shadowstab damage only with Dagger Mastery and a main-hand dagger", () => {
const baselineStats = { ...useCombatStore.getState().gearStats };
const hitWith = (weaponType: string, passiveActive: boolean, instanceId: string) => {
const weapon = testWeapon(weaponType, instanceId, baselineStats);
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
const equipped = resolvedEquipmentLoadout(useCombatStore.getState().equipment, useCombatStore.getState().inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue",
specializationId: null, role: "damage", level: 80,
});
expect(equipped["main-hand"]?.weaponType).toBe(weaponType);
if (!passiveActive) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)),
}));
}
useCombatStore.getState().registerMob("rogue-dagger-mastery-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-dagger-mastery-target", [0, 0, 3]);
useCombatStore.getState().selectMob("rogue-dagger-mastery-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
const before = useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
const cast = useCombatStore.getState().castAbility("rom-rogue-general-0", undefined, 1_000);
if (!cast.ok) throw new Error(JSON.stringify(cast));
return before - useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
};
const baseDaggerDamage = hitWith("dagger", false, "rogue-mastery-dagger-without-passive");
const daggerDamage = hitWith("dagger", true, "rogue-mastery-dagger-with-passive");
const baseNonDaggerDamage = hitWith("crossbow", false, "rogue-mastery-crossbow-without-passive");
const nonDaggerDamage = hitWith("crossbow", true, "rogue-mastery-crossbow-with-passive");
expect(daggerDamage).toBeGreaterThan(baseDaggerDamage);
expect(daggerDamage / baseDaggerDamage).toBeCloseTo(1.1, 1);
expect(nonDaggerDamage).toBe(baseNonDaggerDamage);
});
});
+65
View File
@@ -0,0 +1,65 @@
export const ROM_ROGUE_DAGGER_MASTERY_TAG = "rom-dagger-mastery";
export const ROM_ROGUE_TREASURE_HUNTER_TAG = "rom-treasure-hunter";
export const ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID = 491537;
export interface RomRogueAbilityAdjustment {
readonly allowsCombatUse: boolean;
readonly energyCostReduction: number;
readonly cooldownIncreaseMs: number;
}
const NO_SHADOW_WALKER_ADJUSTMENT: RomRogueAbilityAdjustment = Object.freeze({
allowsCombatUse: false,
energyCostReduction: 0,
cooldownIncreaseMs: 0,
});
/** Shadow Walker's exact Rogue/Priest payload applies only to Sneak Attack. */
export function romRogueShadowWalkerAdjustment(
classId: string,
secondaryClassId: string | null | undefined,
abilitySource: string | undefined,
abilitySpellId: number,
passiveSpellIds: readonly number[],
): RomRogueAbilityAdjustment {
if (classId !== "rom-rogue"
|| secondaryClassId !== "rom-priest"
|| abilitySource !== "runewaker"
|| abilitySpellId !== 490337
|| !passiveSpellIds.includes(ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID)) {
return NO_SHADOW_WALKER_ADJUSTMENT;
}
return {
allowsCombatUse: true,
energyCostReduction: 10,
cooldownIncreaseMs: 10_000,
};
}
export interface RomRogueDaggerDamageContext {
readonly classId: string;
readonly sourceActorId: string;
readonly playerActorId: string;
readonly attackKind: string;
readonly school: string;
readonly mainHandDagger: boolean;
readonly passiveTags: readonly string[];
}
/** The RoM passive modifies only the player's physical melee hits while a dagger is equipped in the main hand. */
export function romRogueDaggerDamageMultiplier(context: RomRogueDaggerDamageContext): number {
return context.classId === "rom-rogue"
&& context.sourceActorId === context.playerActorId
&& context.attackKind === "melee"
&& context.school === "physical"
&& context.mainHandDagger
&& context.passiveTags.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)
? 1.1
: 1;
}
/** Treasure Hunter adds one five percentage point bonus roll to the supported boss-loot path. */
export function romRogueTreasureHunterLootChance(baseChance: number | undefined, passiveActive: boolean): number {
const base = Number.isFinite(baseChance) ? Math.max(0, Math.min(1, baseChance!)) : 0;
return passiveActive ? Math.min(1, base + 0.05) : base;
}
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { resolveDamageValue, resolveStatValue } from "./combatAuras";
import { useCombatStore } from "./combatStore";
describe("RoM Scout ranged weapon masteries", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
});
it.each(["bow", "crossbow"] as const)(
"applies Ranged Weapon Mastery and Speed Shooting Mastery once with an equipped %s",
(weaponType) => {
const rangedWeapon = {
instanceId: `scout-mastery-${weaponType}`,
itemId: 1,
itemKey: `scout-mastery-${weaponType}`,
name: `Test ${weaponType}`,
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats: EMPTY_ITEM_STATS,
quantity: 1,
sourceBossKey: "test",
sourceBossName: "test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
raceId: "rom-human",
level: 50,
inventory: [rangedWeapon],
equipment: { player: { ranged: rangedWeapon.instanceId } },
});
const state = useCombatStore.getState();
expect(state.equipment.player?.ranged).toBe(rangedWeapon.instanceId);
const playerAuras = state.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
const rangedDamageAuras = playerAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "damage"
&& modifier.direction === "dealt"
&& modifier.attackKind === "ranged"
&& modifier.operation === "percent"
)));
expect(rangedDamageAuras).toHaveLength(1);
expect(resolveDamageValue(100, playerAuras, { direction: "dealt", school: "physical", attackKind: "ranged" }))
.toBeCloseTo(108);
expect(resolveStatValue(0, playerAuras, "ranged-hit")).toBe(8);
expect(resolveStatValue(0, playerAuras, "physical-hit")).toBe(0);
expect(resolveStatValue(0, playerAuras, "ranged-haste")).toBe(11);
const masteryDamageModifiers = abilityById("rom-scout-primary-2")!.effects.flatMap((effect) => (
effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []
)).filter((modifier) => modifier.kind === "damage"
&& modifier.direction === "dealt"
&& modifier.attackKind === "ranged"
&& modifier.operation === "percent");
const speedMasteryModifiers = abilityById("rom-scout-general-8")!.effects.flatMap((effect) => (
effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []
)).filter((modifier) => modifier.kind === "stat" && modifier.stat === "ranged-haste");
expect(masteryDamageModifiers).toHaveLength(1);
expect(speedMasteryModifiers).toHaveLength(1);
},
);
it("Cripple Joint roots on Joint Blow hits, not on other Scout attacks", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
raceId: "rom-human",
level: 50,
});
for (const id of ["shot-target", "joint-target"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [3, 0, 0]);
}
// Make the proc deterministic here; its catalog regression separately checks the sourced 55% chance.
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.definition.utilityTags?.includes("rom-cripple-joint")
? {
...aura,
definition: {
...aura.definition,
procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })),
},
}
: aura),
}));
useCombatStore.getState().selectMob("shot-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["shot-target"]?.statuses.some((status) => status.kind === "root")).toBe(false);
useCombatStore.getState().selectMob("joint-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 2_600)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["joint-target"]?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }),
]));
});
});
+83 -3
View File
@@ -1,6 +1,10 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
import {
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_TREASURE_HUNTER_TAG,
} from "./romRogueMechanics";
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
@@ -30,6 +34,24 @@ function taggedPassive(
}];
}
function taggedRoguePassive(sourceId: number, name: string, tag: string): AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-aura:${sourceId}:rogue-passive`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
function customProc(
sourceId: number,
trigger: "hit" | "crit",
@@ -78,6 +100,35 @@ export function overrideRomScoutRogueEffects(
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 490504:
return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG);
case 490316:
return taggedRoguePassive(490316, "Treasure Hunter", ROM_ROGUE_TREASURE_HUNTER_TAG);
case 490143:
// The source applies damage and accuracy only to off-hand attacks; there
// is no distinct RoM off-hand hit yet, so do not turn either value into a
// generic player melee modifier.
return [];
case 491552:
if (row.classId !== "rom-rogue") return null;
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-491552"] },
} : effect);
case 490445: // Bow/crossbow accuracy must not grant melee hit; keep its range scope explicit.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 54)
.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-hit"
? { ...modifier, stat: "ranged-hit" }
: modifier),
},
} : effect);
case 490505: // Bow and crossbow each carry an equivalent +11% attack-speed modifier in the source.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 70);
case 490400: // Piercing Arrow: one projectile, then a line hit behind its target.
return firstDamage(translated);
case 490424: { // Combo Shot: three impacts of the same source shot.
@@ -88,6 +139,8 @@ export function overrideRomScoutRogueEffects(
const hit = firstDamage(translated)[0];
return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated;
}
case 498803: // Soul Stab's first hit is unconditional; its second hit requires Weakened.
return translated.filter((effect) => effect.kind === "damage");
case 490354: // Vanish is an aggro/stealth transition, not a self-heal.
return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } }
@@ -108,6 +161,9 @@ export function overrideRomScoutRogueEffects(
case 492916:
return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot",
customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 }));
case 493006:
return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting",
customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 }));
case 493007:
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
@@ -193,6 +249,15 @@ export function romScoutRogueCastEffects(
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
} : effect);
}
if (ability.dbcSpellId === 498803) {
let seenDamage = 0;
const weakened = target.has("rom-weakened");
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
seenDamage += 1;
return seenDamage === 1 || weakened;
});
}
if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) {
// 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s.
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster"
@@ -298,9 +363,24 @@ export type RomScoutRogueProcActionSpec =
export function romScoutRogueProcActionSpec(
actionId: string,
triggeringSourceSkillId: number,
_casterPoisonous: boolean,
casterPoisonous: boolean,
): RomScoutRogueProcActionSpec | null {
switch (actionId) {
case "rom-accurate-targeting": {
if (triggeringSourceSkillId !== 490423) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-scout-accurate-targeting",
name: "Accurate Targeting",
disposition: "buff",
durationMs: 15_000, // Source component 503255.
modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }],
},
provenance: "source",
};
}
case "rom-exploiting-shot":
if (triggeringSourceSkillId !== 490423) return null;
return {
@@ -332,7 +412,7 @@ export function romScoutRogueProcActionSpec(
provenance: "hybrid-estimate",
};
case "rom-poisonous-spit":
if (![490457, 490424].includes(triggeringSourceSkillId)) return null;
if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
return {
kind: "dot",
recipient: "target",
@@ -346,7 +426,7 @@ export function romScoutRogueProcActionSpec(
intervalMs: 2_000,
tag: "rom-poisonous-spit",
},
provenance: "source", // 499535/503522. Poison Powder is supplied without inventory gating.
provenance: "source", // 503522; the live Poison Powder aura gates the rider.
};
case "rom-reclaim-energy":
if (triggeringSourceSkillId === 490306) return {
+67 -5
View File
@@ -1,10 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
import { abilitiesForCharacter, abilitiesForClass, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
import { applyAura } from "./combatAuras";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
const scoutWarrior = abilitiesForCharacter("rom-scout", "rom-warrior");
const roguePrimary = abilitiesForClass("rom-rogue");
const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior");
function skill(abilities: readonly AbilityDefinition[], id: string): AbilityDefinition {
@@ -37,6 +41,20 @@ function prime(primary: "rom-scout" | "rom-rogue", level: number): void {
useCombatStore.getState().selectMob("target");
}
function primeScoutWarrior(level: number): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
secondaryClassId: "rom-warrior",
raceId: "rom-elf",
level,
});
for (const [id, x, z] of [["target", 3, 0], ["behind", 6, 0], ["nearby", 3, 3]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
useCombatStore.getState().selectMob("target");
}
describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
beforeEach(() => clearMobRuntimeRegistry());
@@ -159,9 +177,10 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("proc resolver poisons Reflected or Combo Shot with unlimited Poison Powder", () => {
it("Poisonous Spit only adds its source poison while Poison Powder is active", () => {
for (const sourceId of [490457, 490424]) {
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toMatchObject({
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, true)).toMatchObject({
kind: "dot", recipient: "target", provenance: "source",
effect: { kind: "dot", ticks: 4, intervalMs: 2_000, basePoints: 7.1, pointsPerLevel: 2.1 },
});
@@ -171,14 +190,57 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("executes the Scout/Rogue poison rider on a successful reflected hit", () => {
it("does not execute Poisonous Spit without Poison Powder, then poisons after it is applied", () => {
prime("rom-scout", 60);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 2_600))
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
&& effect.targetId === "target")).toBe(false);
const poisonPowder = roguePrimary.find((ability) => ability.dbcSpellId === 490329);
if (!poisonPowder) throw new Error("Missing Poison Powder");
const powderAura = poisonPowder.effects.find((effect) => effect.kind === "apply-aura");
if (powderAura?.kind !== "apply-aura") throw new Error("Poison Powder has no aura");
const applied = applyAura(useCombatStore.getState().auras, {
definition: powderAura.aura,
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 3_000,
});
useCombatStore.setState({ auras: applied.auras });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-poisonous")))
.toBe(true);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 22_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
&& effect.targetId === "target")).toBe(true);
});
it("executes Accurate Targeting on a successful Shot for its sourced duration and modifier", () => {
primeScoutWarrior(60);
const passive = scoutWarrior.find((ability) => ability.dbcSpellId === 493006);
if (!passive) throw new Error("Missing Accurate Targeting");
const proc = passive.effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0] : null).toMatchObject({
internalCooldownMs: 30_000,
action: { kind: "custom", id: "rom-accurate-targeting" },
});
expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490457, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490423, false)).toMatchObject({
kind: "aura", recipient: "caster", provenance: "source", aura: {
durationMs: 15_000,
modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }],
},
});
const shot = scoutRogue.find((ability) => ability.dbcSpellId === 490423);
if (!shot) throw new Error("Missing Shot");
expect(useCombatStore.getState().castAbility(shot.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-scout-accurate-targeting"
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-attack" && modifier.value === 50))).toBe(true);
});
it("proc resolver routes Reclaim Energy by trigger skill", () => {
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490306, false)).toMatchObject({
kind: "resource", resource: "energy", amount: 15, provenance: "hybrid-estimate",
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
const SMASH_ID = "rom-knight-rom-rogue-elite-20";
function registerTarget(): void {
useCombatStore.getState().registerMob("smash-target", {
name: "Smash target",
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition("smash-target", [2, 0, 0]);
useCombatStore.getState().selectMob("smash-target");
}
function playerDamageEvent(critical: boolean, id: number, sourceActorId = PLAYER_AGGRO_ID) {
return {
id,
occurredAt: id * 1_000,
kind: "damage" as const,
sourceActorId,
targetActorId: "smash-target",
abilityId: "rom-knight-primary-1",
school: "holy" as const,
rawAmount: 100,
effectiveAmount: 100,
overheal: 0,
absorbed: 0,
critical,
outcome: critical ? "crit" : "hit",
};
}
describe("RuneWaker Smash prerequisite", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level: 80,
xp: 0,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("rom-smash-test", 80, useCombatStore.getState().health);
registerTarget();
});
it("rejects Smash without a preceding player critical damage event without spending health or cooldown", () => {
const before = useCombatStore.getState();
const result = before.castAbility(SMASH_ID, undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", abilityId: SMASH_ID });
expect(useCombatStore.getState().health).toBe(before.health);
expect(useCombatStore.getState().cooldowns[SMASH_ID] ?? 0).toBe(0);
expect(useCombatStore.getState().mobs["smash-target"]?.health).toBe(1_000_000);
});
it("allows Smash after a real player critical hit and ignores the later cast event", () => {
useCombatStore.setState({
gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 1_000_000, hitRating: 1_000_000 },
});
const holySeal = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-knight-primary-1");
expect(holySeal).toBeDefined();
expect(useCombatStore.getState().castAbility(holySeal!.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.critical
&& event.effectiveAmount > 0
))).toBe(true);
const result = useCombatStore.getState().castAbility(SMASH_ID, undefined, 4_000);
expect(result).toMatchObject({ ok: true, abilityId: SMASH_ID });
expect(useCombatStore.getState().cooldowns[SMASH_ID]).toBe(9_000);
});
it("requires the latest player damage event to be a landed crit", () => {
useCombatStore.setState({ combatEvents: [
playerDamageEvent(true, 1),
playerDamageEvent(false, 2),
] });
expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState({ combatEvents: [playerDamageEvent(true, 1, "party-member")] });
expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export const ROM_WARDEN_WILL_ATTACK_TAG = "rom-will-attack";
export const ROM_WARDEN_DAMAGE_TRANSFER_TAG = "rom-warden-damage-transfer";
export const ROM_WARDEN_DAMAGE_TRANSFER_FRACTION = 0.3;
const classPair = (ability: AbilityDefinition, primary: string, secondary: string): boolean => (
ability.classId === primary && ability.secondaryClassId === secondary
);
function casterAura(
ability: AbilityDefinition,
id: string,
name: string,
durationMs: number | null,
options: Pick<Extract<AbilityEffect, { kind: "apply-aura" }>["aura"], "controlImmunities" | "utilityTags">,
): Extract<AbilityEffect, { kind: "apply-aura" }> {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id,
name,
disposition: "buff",
durationMs,
...options,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: ability.dbcSpellId,
};
}
/** Select source-described Warden and Warden-linked class payloads. */
export function romWardenCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
naturePower: number,
passiveTags: readonly string[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
if (ability.dbcSpellId === 493391 && ability.classId === "rom-warden") {
// The source row includes Will Attack's component, but it belongs on the
// target only when the Warden has learned that passive.
const reduction = effects.find((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173);
const baseEffects = effects.filter((effect) => !(effect.kind === "apply-aura" && effect.sourceAuraType === 173));
if (!passiveTags.includes(ROM_WARDEN_WILL_ATTACK_TAG) || !reduction || reduction.kind !== "apply-aura") {
return baseEffects;
}
return [...baseEffects, {
...reduction,
recipient: "ability-target",
aura: {
...reduction.aura,
id: "rom-warden-will-attack",
name: "Will Attack",
disposition: "debuff",
utilityTags: [...(reduction.aura.utilityTags ?? []), ROM_WARDEN_WILL_ATTACK_TAG],
},
}];
}
if (ability.dbcSpellId === 493399 && ability.classId === "rom-warden") {
return effects.map((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken"
&& modifier.operation === "percent"
&& modifier.value === -ROM_WARDEN_DAMAGE_TRANSFER_FRACTION)
? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_WARDEN_DAMAGE_TRANSFER_TAG],
},
}
: effect);
}
if (ability.dbcSpellId === 494043 && classPair(ability, "rom-rogue", "rom-warden")) {
// The source describes a chance on the second hit but contains no proc rate.
// Keep the guaranteed base strike and do not turn an unknown chance into 100%.
let keptBaseHit = false;
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
if (keptBaseHit) return false;
keptBaseHit = true;
return true;
});
}
if (ability.dbcSpellId === 499495 && classPair(ability, "rom-warrior", "rom-warden")) {
return [
casterAura(ability, "rom-warden-awakening-root-slow-immunity", ability.name, 10_000, {
controlImmunities: ["root", "slow"],
utilityTags: ["rom-awakening-of-the-wild-immunity"],
}),
casterAura(ability, "rom-warden-awakening-next-critical", ability.name, null, {
utilityTags: ["next-offensive-critical", "rom-awakening-of-the-wild-next-critical"],
}),
];
}
if (ability.dbcSpellId === 494323 && classPair(ability, "rom-druid", "rom-warden")) {
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
if (!heals.length) return effects;
if (naturePower < 1) return [heals[0]!];
return [
...heals.slice(0, 2),
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
];
}
if (ability.dbcSpellId === 499936 && classPair(ability, "rom-druid", "rom-warden")) {
const points = Math.max(0, Math.floor(naturePower));
if (points < 1) return [];
const heal = effects.find((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
if (!heal) return effects;
return [
{
...heal,
coefficient: 0,
percentMaxHealth: Math.min(1, points * 0.1),
basePoints: 0,
dieSides: 0,
pointsPerLevel: 0,
coefficientPerLevel: 0,
bonusPowerCoefficient: 0,
},
{ kind: "resource-drain", amount: points, burn: false, resource: "nature-power" },
];
}
if (ability.dbcSpellId === 494372 && classPair(ability, "rom-druid", "rom-warden")) {
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length
? { ...effect, scope: "party" }
: effect);
}
return effects;
}
+300
View File
@@ -0,0 +1,300 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES, romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import { applyAura, absorbDamage } from "./combatAuras";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { usePartyStore } from "./partyStore";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
import {
ROM_WARDEN_DAMAGE_TRANSFER_FRACTION,
ROM_WARDEN_DAMAGE_TRANSFER_TAG,
ROM_WARDEN_WILL_ATTACK_TAG,
} from "./romWardenOverrides";
const emptyContext: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function effectsFor(spellId: number, context: Partial<RomCastMechanics> = {}) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId)!;
expect(ability).toBeDefined();
return romEffectsForCast(ability, { ...emptyContext, ...context });
}
function initializeRomCharacter(
classId: "rom-warden" | "rom-warrior" | "rom-druid",
secondaryClassId?: "rom-warden" | "rom-warrior",
) {
useCombatStore.getState().initializeCharacter({
classId,
...(secondaryClassId ? { secondaryClassId } : {}),
raceId: classId === "rom-warrior" ? "rom-human" : "rom-elf",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 1_000,
maxResource: 1_000,
resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000, "nature-power": 0 },
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000, "nature-power": 100 },
}));
}
describe("RoM Warden and Warden-linked source behaviors", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("gates Will Attack's 10% damage reduction on the passive and places it on Frantic Briar's target", () => {
const passive = effectsFor(494610).find((effect) => effect.kind === "apply-aura");
expect(passive?.kind).toBe("apply-aura");
if (passive?.kind === "apply-aura") {
expect(passive.aura.utilityTags).toContain(ROM_WARDEN_WILL_ATTACK_TAG);
expect(passive.aura.modifiers ?? []).toHaveLength(0);
}
const withoutPassive = effectsFor(493391);
expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toBe(false);
expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 24)).toBe(true);
expect(withoutPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2);
const withPassive = effectsFor(493391, { passiveTags: [ROM_WARDEN_WILL_ATTACK_TAG] });
expect(withPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(withPassive.filter((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toHaveLength(1);
expect(withPassive.find((effect) => effect.kind === "apply-aura" && effect.aura.id === "rom-warden-will-attack"))
.toMatchObject({
kind: "apply-aura",
recipient: "ability-target",
aura: {
name: "Will Attack",
disposition: "debuff",
durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: -0.1 }],
},
});
});
it("reduces a Frantic Briar target's melee hit only while Will Attack is learned", () => {
const incomingDamage = (withWillAttack: boolean) => {
clearMobRuntimeRegistry();
initializeRomCharacter("rom-warden", "rom-warrior");
if (!withWillAttack) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)),
}));
}
const mobId = "will-attack-target";
useCombatStore.getState().registerMob(mobId, {
name: "Will Attack target",
maxHealth: 100_000,
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "will-attack-swing", initialCooldownMs: [0, 0] }],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
const hasPassive = useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)
));
expect(hasPassive).toBe(withWillAttack);
expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 1_000))
.toMatchObject({ ok: true });
const targetDebuff = useCombatStore.getState().auras.some((aura) => (
aura.targetId === mobId && aura.definition.id === "rom-warden-will-attack"
));
expect(targetDebuff).toBe(withWillAttack);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
return useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
};
const normalDamage = incomingDamage(false);
const reducedDamage = incomingDamage(true);
expect(normalDamage).toBeGreaterThan(0);
expect(reducedDamage).toBeCloseTo(normalDamage * 0.9, 0);
});
it("keeps Nature Crystal active and makes its owner immune while blocking attack skills", () => {
initializeRomCharacter("rom-warden");
const now = Date.now();
expect(useCombatStore.getState().castAbility("rom-warden-primary-6", undefined, now)).toMatchObject({ ok: true });
useCombatStore.getState().tick(4, now + 4_000);
const crystal = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102325)!;
expect(crystal.expiresAt).toBeGreaterThan(now + 60_000);
const health = useCombatStore.getState().health;
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0);
expect(useCombatStore.getState().health).toBe(health);
useCombatStore.getState().registerMob("warden-crystal-target", { name: "Target", maxHealth: 10_000 });
useCombatStore.getState().selectMob("warden-crystal-target");
expect(useCombatStore.getState().castAbility("rom-warden-primary-0", undefined, now + 6_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("scales Crystalline Protection to 10% of each recipient's own maximum health", () => {
initializeRomCharacter("rom-warden");
usePartyStore.getState().initializeParty("warden-crystalline-protection", 80, useCombatStore.getState().health, "healer", 3, "rom");
const [first, second] = usePartyStore.getState().members;
expect(first).toBeDefined();
expect(second).toBeDefined();
const members = usePartyStore.getState().members.map((member, index) => ({
...member,
maxHealth: index === 0 ? 2_000 : 5_000,
health: index === 0 ? 1_000 : 2_500,
}));
usePartyStore.setState({ members });
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
const playerMaxHealthBeforeCast = useCombatStore.getState().maxHealth;
expect(useCombatStore.getState().castAbility("rom-warden-primary-23", undefined, Date.now())).toMatchObject({ ok: true });
const auras = useCombatStore.getState().auras;
const absorbFor = (targetId: string) => auras.find((aura) => aura.targetId === targetId
&& aura.definition.name === "Crystalline Protection")?.absorbs[0]?.remaining;
expect(absorbFor(PLAYER_AURA_ENTITY_ID)).toBe(Math.round(playerMaxHealthBeforeCast * 0.1));
expect(absorbFor(members[0]!.id)).toBe(200);
expect(absorbFor(members[1]!.id)).toBe(500);
});
it("absorbs exactly two Heart of the Oak attack packets and leaves ordinary point shields unchanged", () => {
initializeRomCharacter("rom-warden");
expect(useCombatStore.getState().castAbility("rom-warden-primary-10", undefined, Date.now())).toMatchObject({ ok: true });
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(0);
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0);
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBeGreaterThan(0);
const flatShield = applyAura([], {
definition: {
id: "test:flat-absorb",
name: "Flat absorb",
disposition: "buff",
durationMs: 10_000,
absorbs: [{ id: "pool", amount: 5 }],
},
sourceId: "test-source",
targetId: "target",
now: 1,
}).auras;
const result = absorbDamage(flatShield, {
targetId: "target",
amount: 10,
school: "physical",
now: 2,
countsAsAttack: true,
});
expect(result).toMatchObject({ absorbed: 5, remainingDamage: 5 });
});
it("transfers 30% of incoming damage to a nearby Warden pet and disables the reduction without one", () => {
const incomingHealthLoss = (withPet: boolean, petDistance = 0) => {
initializeRomCharacter("rom-warden");
const now = Date.now();
if (withPet) {
const summonAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493333)!;
expect(useCombatStore.getState().castAbility(summonAbility.id, undefined, now)).toMatchObject({ ok: true });
const summonCast = useCombatStore.getState().activeCast;
if (summonCast) useCombatStore.getState().tick(0.1, summonCast.completesAt);
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.creatureId === 102297
? { ...summon, expiresAt: now + 20_000 }
: summon),
}));
}
const petBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297);
if (petBefore && petDistance > 0) {
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.id === petBefore.id
? { ...summon, position: [petDistance, 0, 0] }
: summon),
}));
}
const transferAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493399)!;
expect(useCombatStore.getState().castAbility(transferAbility.id, undefined, now + 6_000)).toMatchObject({ ok: true });
const playerHealthBefore = useCombatStore.getState().health;
const petHealthBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297)?.health ?? 0;
useCombatStore.getState().damagePlayer(500, "physical", 80, now + 7_000);
const state = useCombatStore.getState();
const petHealthAfter = state.summons.find((summon) => summon.creatureId === 102297)?.health ?? 0;
return {
playerDamage: playerHealthBefore - state.health,
petDamage: petHealthBefore - petHealthAfter,
};
};
const unshared = incomingHealthLoss(false);
const unsharedDamage = unshared.playerDamage;
const outOfRange = incomingHealthLoss(true, 301);
expect(unshared.petDamage).toBe(0);
expect(outOfRange.playerDamage).toBe(unsharedDamage);
expect(outOfRange.petDamage).toBe(0);
const shared = incomingHealthLoss(true);
expect(shared.petDamage).toBeGreaterThan(0);
expect(shared.playerDamage + shared.petDamage).toBeCloseTo(unsharedDamage, -1);
expect(shared.petDamage / (shared.playerDamage + shared.petDamage))
.toBeCloseTo(ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, 1);
});
it("removes the unquantified Weak Point Strike bonus and models Awakening immunity and next critical", () => {
const weakPoint = effectsFor(494043);
expect(weakPoint.filter((effect) => effect.kind === "damage")).toHaveLength(1);
const awakening = effectsFor(499495);
const auras = awakening.filter((effect) => effect.kind === "apply-aura");
expect(auras).toHaveLength(2);
expect(auras).toEqual(expect.arrayContaining([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({ durationMs: 10_000, controlImmunities: ["root", "slow"] }),
}),
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: expect.arrayContaining(["next-offensive-critical"]),
}),
}),
]));
expect(auras.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("spends one Nature Power for Healing Ripple's additional heal", () => {
const baseline = effectsFor(494323, { naturePower: 0 });
const empowered = effectsFor(494323, { naturePower: 1 });
expect(baseline.filter((effect) => effect.kind === "heal")).toHaveLength(1);
expect(baseline.some((effect) => effect.kind === "resource-drain")).toBe(false);
expect(empowered.filter((effect) => effect.kind === "heal")).toHaveLength(2);
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
});
it("scales Gift Pulse by spent Nature Power and targets nearby party members", () => {
expect(effectsFor(499936, { naturePower: 0 })).toEqual([]);
const empowered = effectsFor(499936, { naturePower: 3 });
const empoweredHeal = empowered.find((effect) => effect.kind === "heal");
expect(empoweredHeal?.kind).toBe("heal");
if (empoweredHeal?.kind === "heal") expect(empoweredHeal.percentMaxHealth).toBeCloseTo(0.3);
expect(empowered.find((effect) => effect.kind === "resource-drain"))
.toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power" });
expect(effectsFor(499936, { naturePower: 20 }).find((effect) => effect.kind === "heal"))
.toMatchObject({ percentMaxHealth: 1 });
expect(ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499936))
.toMatchObject({ targeting: { shape: "circle", radius: 10 } });
});
it("puts Mysterious Grace modifier auras on the party, and leaves Revival's absent state unresolved", () => {
const grace = effectsFor(494372);
const modifierAuras = grace.filter((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length);
expect(modifierAuras.length).toBeGreaterThan(0);
expect(modifierAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
expect(grace.filter((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length)
.every((effect) => effect.kind === "apply-aura" && effect.scope === undefined)).toBe(true);
expect(romTriggeredAbilityBySpellId(499544)).toBeNull();
});
});
+195
View File
@@ -0,0 +1,195 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_WARLOCK_STATE_TAGS = Object.freeze({
heartFence: "rom-warlock-state-501577",
perceptionExtraction: "rom-warlock-state-497960",
soulPain: "rom-warlock-state-497964",
soulPoisonedFang: "rom-warlock-state-498670",
} as const);
const CRIME_AND_PUNISHMENT_STATE_TAGS = [
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
] as const;
function appendAuraTag(effect: AbilityEffect, tag: string): AbilityEffect {
if (effect.kind !== "apply-aura") return effect;
return {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), tag] },
};
}
/** Source-specific state markers and recipient mapping for the Warlock's paired skills. */
export function overrideRomWarlockEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-warlock") return null;
if (row.sourceSkillId === 498549) {
// Defense Net's active component supplies the 5-second magic mitigation;
// the source tooltip also describes immunity to Helpless, Stun, Root, and
// Slow. In this runtime, Helpless maps to fear control.
return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142
? {
...effect,
aura: {
...effect.aura,
controlImmunities: ["fear", "stun", "root", "slow"],
},
}
: effect);
}
if (row.sourceSkillId === 498710 || row.sourceSkillId === 498662) {
const targetAbilityName = row.sourceSkillId === 498710 ? "Flaming Heart Strike" : "Rising Tide";
return [
...translated.filter((effect) => effect.kind !== "cooldown-reset"),
{ kind: "cooldown-reset", mode: "reset", abilityName: targetAbilityName },
];
}
if ([498634, 498648, 498668].includes(row.sourceSkillId ?? 0)) {
// These passive tooltips describe event-gated resets. Keep their cooldown
// riders out of the generic cast path until their source events are wired.
return translated.filter((effect) => effect.kind !== "cooldown-reset");
}
if (row.sourceSkillId === 498695) {
let selectedTargetThreatAura = false;
const selectedTargetEffects: AbilityEffect[] = translated.flatMap((effect): AbilityEffect[] => {
if (effect.kind !== "apply-aura") return [effect];
const threatModifiers = effect.aura.modifiers?.filter((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat"
)) ?? [];
const threatModifier = threatModifiers[0];
if (!threatModifier || threatModifier.kind !== "stat") return [effect];
if (threatModifier.value > 0) {
// 621654 repeats the +5% modifier on a hidden script component; the tooltip
// describes one buff on the selected friendly target.
if (selectedTargetThreatAura) return [];
selectedTargetThreatAura = true;
return [{ ...effect, recipient: "ability-target" }];
}
if (threatModifier.value < 0) {
return [{ ...effect, recipient: "caster", scope: "party" }];
}
return [effect];
});
const reductionComponent = row.source?.components?.find((component) => component.id === 621433);
const reductionModifier = reductionComponent?.abilityModifiers?.find((modifier) => modifier.type === 138);
if (!reductionComponent || !reductionModifier) return selectedTargetEffects;
const partyReductionAura: AbilityEffect = {
kind: "apply-aura",
aura: {
id: "rom-warlock-liquidation-party-threat-reduction",
name: `${row.name}: party threat reduction`,
disposition: "buff",
durationMs: Math.max(0, Math.round((reductionComponent.effectTime ?? 10) * 1_000)),
modifiers: [{
kind: "stat",
stat: "threat",
operation: "percent",
value: reductionModifier.value / 100,
}],
},
recipient: "caster",
scope: "party",
sourceEffectType: -1,
sourceAuraType: 138,
miscValue: reductionModifier.value,
triggerSpellId: row.sourceSkillId ?? 0,
};
return [...selectedTargetEffects, partyReductionAura];
}
const stateTag = row.sourceSkillId === 497960
? ROM_WARLOCK_STATE_TAGS.perceptionExtraction
: row.sourceSkillId === 497964
? ROM_WARLOCK_STATE_TAGS.soulPain
: row.sourceSkillId === 498670
? ROM_WARLOCK_STATE_TAGS.soulPoisonedFang
: null;
if (stateTag) {
return translated.map((effect) => effect.kind === "dot"
? { ...effect, tag: stateTag }
: appendAuraTag(effect, stateTag));
}
if (row.source?.components?.some((component) => component.id === 501577)) {
return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 226
? appendAuraTag(effect, ROM_WARLOCK_STATE_TAGS.heartFence)
: effect);
}
return null;
}
function scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage") return effect;
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }),
};
}
/** Add only the source-described conditional riders for the Warlock primary build. */
export function romWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
targetTags: readonly string[],
learnedPassiveSpellIds: readonly number[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return effects;
if (ability.dbcSpellId === 498549
&& learnedPassiveSpellIds.includes(498648)) {
// Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second.
return effects.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142
? {
...effect,
aura: { ...effect.aura, durationMs: Math.max(0, (effect.aura.durationMs ?? 0) - 1_000) },
}
: effect);
}
if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior"
&& targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)) {
return [...effects, { kind: "resource", amount: 100, percentage: true, resource: "rage" }];
}
if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") {
const matchedStates = CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag));
const additionalStates = Math.max(0, matchedStates.length - 1);
return additionalStates > 0
? effects.map((effect) => scaleDamage(effect, 1 + additionalStates * 0.25))
: effects;
}
return effects;
}
/** Return state tags whose source durations are refreshed by this successful cast. */
export function romWarlockStateRefreshTags(
ability: AbilityDefinition,
targetTags: readonly string[],
): readonly string[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return [];
if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior") {
return targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)
? [ROM_WARLOCK_STATE_TAGS.heartFence]
: [];
}
if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") {
return CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag));
}
return [];
}
+324
View File
@@ -0,0 +1,324 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { cooldownResetCandidates } from "./cooldownReset";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
import {
ROM_WARLOCK_STATE_TAGS,
} from "./romWarlockOverrides";
const TARGET_ID = "rom-warlock-runtime-target";
function ability(spellId: number) {
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
expect(result, `RoM Warlock spell ${spellId}`).toBeDefined();
return result!;
}
function finishCast(now: number) {
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
}
function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") {
useCombatStore.getState().initializeCharacter({
classId: "rom-warlock",
secondaryClassId,
raceId: "rom-human",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 100,
maxResource: 100,
resourcePools: {
...state.resourcePools,
focus: 100,
rage: 0,
energy: 100,
psi: 0,
},
maxResourcePools: {
...state.maxResourcePools,
focus: 100,
rage: 100,
energy: 100,
psi: 6,
},
}));
}
function registerTarget() {
useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
}
describe("RuneWaker Warlock runtime", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-warlock"]);
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => {
prepareWarlock("rom-warrior");
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, rage: 70, psi: 1 },
}));
const mindTransference = ability(498642);
expect(mindTransference.costs).toEqual([]);
expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().resourcePools.psi).toBe(3);
});
it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => {
prepareWarlock("rom-warrior");
usePartyStore.getState().initializeParty(
"rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } }));
const heartFence = ability(498645);
expect(useCombatStore.getState().castAbility(heartFence.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
&& aura.definition.name.startsWith("Heart Fence")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"));
expect(defenseAuras).toHaveLength(2);
expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true);
});
it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => {
prepareWarlock("rom-warrior");
registerTarget();
const heartStrike = ability(498646);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } }));
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
const soulTrauma = ability(498550);
const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 });
expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true });
const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } }));
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(100);
const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
expect(heartStatesAfterStrike.length).toBeGreaterThan(0);
expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true);
});
it("scales Crime and Punishment by 25% per state after the first", () => {
const crimeAndPunishment = ability(498700);
const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage");
if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect");
const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 10_000,
targetHealthPercent: 1,
targetTags,
});
const oneState = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
]).find((effect) => effect.kind === "damage");
const twoStates = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
]).find((effect) => effect.kind === "damage");
const threeStates = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
]).find((effect) => effect.kind === "damage");
expect(oneState).toEqual(baseDamage);
expect(twoStates).toMatchObject({
coefficient: baseDamage.coefficient * 1.25,
basePoints: baseDamage.basePoints! * 1.25,
});
expect(threeStates).toMatchObject({
coefficient: baseDamage.coefficient * 1.5,
basePoints: baseDamage.basePoints! * 1.5,
});
});
it("refreshes the active three Warlock state effects after Crime and Punishment", () => {
prepareWarlock("rom-rogue");
registerTarget();
for (const spellId of [497960, 497964, 498670]) {
const stateSpell = ability(spellId);
const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000;
expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true);
finishCast(castAt);
}
useCombatStore.getState().tick(2.1, 6_100);
const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
&& effect.targetId === TARGET_ID
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true);
expect(before).toHaveLength(3);
expect(before.every((effect) => {
const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId)
?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
entry.kind === "dot" && entry.tag === effect.tag
));
if (sourceDot?.kind !== "dot") return false;
return effect.remainingTicks < sourceDot.ticks;
})).toBe(true);
const crimeAndPunishment = ability(498700);
expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true);
const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
&& effect.targetId === TARGET_ID
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
expect(after).toHaveLength(before.length);
expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true);
expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => {
const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId);
return source?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
entry.kind === "dot" && entry.tag === effect.tag
))?.ticks;
}));
});
it("applies Liquidation Suffering to its selected ally and the rest of the party", () => {
prepareWarlock("rom-rogue");
usePartyStore.getState().initializeParty(
"rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const [selectedAlly, otherAlly] = usePartyStore.getState().members;
expect(selectedAlly).toBeDefined();
expect(otherAlly).toBeDefined();
registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]);
registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]);
useCombatStore.getState().synchronizeActors();
const liquidation = ability(498695);
expect(useCombatStore.getState().castAbility(liquidation.id, {
targetActorId: selectedAlly!.id,
now: 1_000,
}).ok).toBe(true);
const threatAuras = useCombatStore.getState().auras.filter((aura) => (
aura.definition.name.startsWith("Liquidation Suffering")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat")
));
const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId)
?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat");
expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 });
expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 });
expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 });
expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0
)))).toHaveLength(1);
expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
});
it("grants Defense Net's source-described control immunity to its friendly target", () => {
prepareWarlock("rom-warrior");
usePartyStore.getState().initializeParty(
"rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 }
: candidate),
}));
useCombatStore.getState().synchronizeActors();
const defenseNet = ability(498549);
expect(useCombatStore.getState().castAbility(defenseNet.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
finishCast(1_000);
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 });
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
&& candidate.definition.name.startsWith("Defense Net"));
expect(aura).toMatchObject({
expiresAt: 6_000,
definition: {
controlImmunities: ["fear", "stun", "root", "slow"],
modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]),
},
});
});
it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => {
prepareWarlock("rom-priest");
usePartyStore.getState().initializeParty(
"rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
const defenseNet = ability(498549);
expect(useCombatStore.getState().castAbility(defenseNet.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
finishCast(1_000);
expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
&& candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000);
});
it("resolves Warlock paired named resets to only their source-named spells", () => {
const expectations = new Map<number, number[]>([
[498744, [491156]], // Static Resonance -> Electrostatic Charge
[498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well
[498662, [490256]], // Touch of Faith -> Rising Tide
[498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike
]);
for (const [sourceSpellId, expectedTargets] of expectations) {
const source = ability(sourceSpellId);
const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId);
const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset"
? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId)
: []);
expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual(
[...expectedTargets].sort((left, right) => left - right),
);
}
for (const sourceSpellId of [498634, 498648, 498668]) {
expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`)
.toBe(false);
}
expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock")
.flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset"
&& effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import type { AbilityEffect } from "./abilityCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
describe("RoM Warrior/Priest Defender's Roar party scope", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("marks only Defender's Roar aura effects as party scoped for the exact class pair", () => {
const maxHealthAura: AbilityEffect = {
kind: "apply-aura",
recipient: "caster",
aura: {
id: "test-aura",
name: "test",
disposition: "buff",
durationMs: 120_000,
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
const unrelatedAura: AbilityEffect = {
kind: "apply-aura",
recipient: "caster",
aura: { id: "test-proc", name: "proc", disposition: "buff", durationMs: null },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 123,
};
const unrelated: AbilityEffect = { kind: "damage", coefficient: 1, school: "physical" };
const row = {
sourceSkillId: 491466,
name: "Defender's Roar",
description: "your and your party members' maximum HP",
target: "self" as const,
classId: "rom-warrior",
secondaryClassId: "rom-priest",
};
const overridden = overrideRomWarriorEffects(row, [maxHealthAura, unrelatedAura, unrelated]);
expect(overridden?.[0]).toMatchObject({ kind: "apply-aura", scope: "party" });
expect(overridden?.[1]).toBe(unrelatedAura);
expect(overridden?.[2]).toBe(unrelated);
expect(overrideRomWarriorEffects({ ...row, classId: "rom-priest" }, [maxHealthAura])).toBeNull();
expect(overrideRomWarriorEffects({ ...row, secondaryClassId: "rom-scout" }, [maxHealthAura])).toBeNull();
expect(overrideRomWarriorEffects({ ...row, sourceSkillId: 490122 }, [maxHealthAura])).toBeNull();
});
it("fans both source-backed maximum-health auras out to party members", () => {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 491466)!;
expect(ability).toMatchObject({
id: "rom-warrior-rom-priest-elite-15",
classId: "rom-warrior",
secondaryClassId: "rom-priest",
});
const partyAuras = ability.effects.filter((effect) => effect.kind === "apply-aura");
const maximumHealthAuras = partyAuras.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health"));
expect(maximumHealthAuras).toHaveLength(2);
expect(maximumHealthAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
expect(partyAuras.some((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length
&& effect.scope !== "party")).toBe(true);
expect(maximumHealthAuras.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }),
expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.01 }),
]));
const rankMaximumHealthAuras = ability.ranks?.[0]?.effects.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health"));
expect(rankMaximumHealthAuras).toHaveLength(2);
expect(rankMaximumHealthAuras?.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
secondaryClassId: "rom-priest",
raceId: "rom-human",
level: 30,
});
usePartyStore.getState().initializeParty(
"warrior-defenders-roar-party-scope",
30,
useCombatStore.getState().health,
"damage",
2,
"rom",
);
const member = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
const appliedAuras = useCombatStore.getState().auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
&& aura.definition.name.startsWith("Defender's Roar:"));
expect(appliedAuras).toHaveLength(4);
expect(appliedAuras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)).toHaveLength(2);
expect(appliedAuras.filter((aura) => aura.targetId === member.id)).toHaveLength(2);
expect(appliedAuras.every((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "maximum-health"
)))).toBe(true);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
type RomWarriorGeneratedRow = RomGeneratedAbilityLike & {
readonly classId?: string;
readonly secondaryClassId?: string;
};
/** Give the source-defined Warrior/Priest Defender's Roar buffs their party scope. */
export function overrideRomWarriorEffects(
row: RomWarriorGeneratedRow,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-warrior"
|| row.secondaryClassId !== "rom-priest"
|| row.sourceSkillId !== 491466) return null;
return translated.map((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "maximum-health")
? { ...effect, scope: "party" }
: effect);
}
+78
View File
@@ -67,6 +67,49 @@ describe("talent runtime modifiers", () => {
]));
});
it("scopes Improved Rejuvenation's effect bonus to Rejuvenation healing", () => {
const improvedRejuvenation = TALENT_NODES.find((node) => node.id === "talent-830")!;
const modifiers = talentModifiersForRanks({ [improvedRejuvenation.id]: 3 }, "druid");
expect(modifiers).toContainEqual(expect.objectContaining({
kind: "healing-percent",
amount: 20,
sourceTalentId: "talent-830",
rank: 3,
}));
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
abilityName: "Rejuvenation",
tags: ["periodic", "healing", "nature"],
})).toBe(120);
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
abilityName: "Regrowth",
tags: ["periodic", "healing", "nature"],
})).toBe(100);
});
it("keeps Warrior named-ability damage and cooldown bonuses on their source abilities", () => {
const improvedExecute = TALENT_NODES.find((node) => node.name === "Improved Execute")!;
const executeModifiers = talentModifiersForRanks({ [improvedExecute.id]: 1 }, "warrior");
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Execute" })).toBe(108);
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Heroic Strike" })).toBe(100);
expect(applyTalentModifierValue(15, executeModifiers, "resource-cost-percent", { abilityName: "Heroic Strike" })).toBe(15);
const unrelentingAssault = TALENT_NODES.find((node) => node.name === "Unrelenting Assault")!;
const assaultModifiers = talentModifiersForRanks({ [unrelentingAssault.id]: 1 }, "warrior");
for (const abilityName of ["Overpower", "Revenge"]) {
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName })).toBeCloseTo(110);
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName })).toBe(9_500);
}
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName: "Bloodthirst" })).toBe(100);
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName: "Bloodthirst" })).toBe(10_000);
const improvedDisciplines = TALENT_NODES.find((node) => node.name === "Improved Disciplines")!;
const disciplineModifiers = talentModifiersForRanks({ [improvedDisciplines.id]: 1 }, "warrior");
expect(applyTalentModifierValue(300_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Shield Wall" })).toBe(290_000);
expect(applyTalentModifierValue(30_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Retaliation" })).toBe(20_000);
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
});
it("keeps critical damage bonuses out of ordinary hit damage", () => {
const modifiers = parseTalentModifiers("Increases the critical damage of your spells by 150%.")
.map((modifier) => ({ ...modifier, sourceTalentId: "test", sourceTalentName: "Thunderous", rank: 1, description: "" }));
@@ -88,4 +131,39 @@ describe("talent runtime modifiers", () => {
tags: ["cast-time", "holy"],
})).toBe(20);
});
it("keeps Paladin activation and aura text out of persistent modifiers", () => {
for (const name of ["Aura Mastery", "Divine Favor"]) {
const talent = TALENT_NODES.find((node) => node.name === name);
expect(talent, name).toBeDefined();
expect(talentModifiersForRanks({ [talent!.id]: 1 }, "paladin"), name).toEqual([]);
}
});
it("scopes Improved Flash Heal critical chance and keeps its Inner Focus proc out of static cooldowns", () => {
const improvedFlashHeal = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
const modifiers = talentModifiersForRanks({ [improvedFlashHeal.id]: 3 }, "priest");
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Flash Heal" })).toBe(20);
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Greater Heal" })).toBe(5);
expect(modifiers.filter((modifier) => modifier.kind === "cooldown-flat-ms")).toEqual([]);
});
it("applies Black Ice to either Frost or Shadow damage", () => {
const blackIce = TALENT_NODES.find((node) => node.name === "Black Ice")!;
const modifiers = talentModifiersForRanks({ [blackIce.id]: blackIce.maxRank }, "death-knight");
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Icy Touch",
tags: ["damage", "frost"],
})).toBeCloseTo(110);
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Death Coil",
tags: ["damage", "shadow"],
})).toBeCloseTo(110);
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Plague Strike",
tags: ["damage", "physical"],
})).toBe(100);
});
});
+112 -13
View File
@@ -43,12 +43,36 @@ interface ParsedModifier extends Omit<TalentModifier, "sourceTalentId" | "source
const EXPLICIT_ACTIVATION_TALENTS = new Set([
"Inner Focus",
// These effects only exist after a proc or charge is earned; their tooltip
// percentages must not be applied as passive stats while inactive.
"Holy Concentration",
"Serendipity",
"Shadow Weaving",
// Eradication and Nightfall are conditional proc states, not passive haste,
// damage, or resource-cost modifiers from their tooltip text.
"Eradication",
"Nightfall",
// Backlash has passive crit chance plus a conditional next-cast proc. Its
// proc damage and cost clauses must not become always-on bonuses.
"Backlash",
// Aura Mastery and Divine Favor carry scoped next-cast/aura effects. Their
// activation tooltips must not be parsed as persistent global modifiers.
"Aura Mastery",
"Divine Favor",
// The Beast Within activates from a pet Bestial Wrath proc; its tooltip
// percentages must not become always-on modifiers.
"The Beast Within",
// These Shaman talents activate temporary effects from combat events;
// their conditional percentages are applied by the Shaman runtime.
"Elemental Focus",
"Reverberation",
"Maelstrom Weapon",
]);
/**
* These talents are modeled as on-use abilities and must never be interpreted
* as always-on scalar bonuses merely because their activation tooltip contains
* phrases such as "reduces cast time" or "reducing mana cost".
* These talents have activation or proc behavior and must never be interpreted
* as always-on scalar bonuses merely because their tooltip contains phrases
* such as "reduces cast time" or "reducing mana cost".
*/
export function talentUsesExplicitActivation(talentName: string): boolean {
return EXPLICIT_ACTIVATION_TALENTS.has(talentName);
@@ -91,6 +115,11 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
add({ kind: "damage-percent", amount, unit: "percent", scope });
}
if (/\bhealing\b|heals? (?:done|cast)/.test(lower)) add({ kind: "healing-percent", amount, unit: "percent", scope });
// The generated Druid tooltip for Improved Rejuvenation describes its
// healing as an increase to the spell's "effect" rather than naming heal.
if (/\beffect of your rejuvenation spell\b/.test(lower)) {
add({ kind: "healing-percent", amount, unit: "percent", scope });
}
if (/\barmor\b/.test(lower)) add({ kind: "armor-percent", amount, unit: "percent", scope });
if (/\brange\b/.test(lower)) add({ kind: "range-percent", amount, unit: "percent", scope });
if (/\bduration\b|lasts? longer/.test(lower)) add({ kind: "duration-percent", amount, unit: "percent", scope });
@@ -134,7 +163,9 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
const castTime = sentence.match(/reduces? (?:the )?(?:casting|cast) time (?:of |for )?.*? by (\d+(?:\.\d+)?) sec/i);
if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence });
const cooldown = sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
? null
: sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence });
}
@@ -143,17 +174,74 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
if (active.talentName === "Backlash") {
return [{
kind: "critical-chance-percent" as const,
amount: active.rank,
unit: "percent" as const,
scope: "your spells and abilities",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
}
if (talentUsesExplicitActivation(active.talentName)) return [];
// Its healing and damage bonuses only apply below a target-health threshold;
// the scalar parser cannot encode that condition. CombatStore applies the
// rank-aware bonus at the individual target instead.
if (active.talentName === "Test of Faith") return [];
const override = talentBalanceOverride(active.talentId);
const scale = (override?.effectScalePercent ?? 100) / 100;
const parsed = parseTalentModifiers(active.description).map((modifier) => ({
...modifier,
amount: modifier.amount * scale,
const parsed = parseTalentModifiers(active.description)
// Improved Shadow Bolt's 5% spell crit applies to the debuffed target;
// only its named Shadow Bolt damage bonus is a persistent caster modifier.
.filter((modifier) => active.talentName !== "Improved Shadow Bolt" || modifier.kind !== "critical-chance-percent")
.filter((modifier) => !(classId === "warrior" && active.talentName === "Unrelenting Assault"
&& ["damage-percent", "cooldown-percent"].includes(modifier.kind)))
.flatMap((modifier) => {
if (classId === "warrior" && active.talentName === "Improved Execute") {
// This tooltip combines a flat Rage reduction, damage percent, and a
// conditional capstone crit. The broad parser merges the first two
// clauses, so rebuild only the source-backed damage component here;
// combatStore applies the flat Rage reduction when casting Execute.
if (["resource-cost-percent", "damage-percent", "critical-chance-percent"].includes(modifier.kind)) {
if (modifier.kind !== "damage-percent") return [];
const increase = active.description.match(/increases its damage by (\d+(?:\.\d+)?)%/i);
if (!increase) return [];
return [{
...modifier,
amount: Number(increase[1]) * 2 * scale,
scope: "damage of your Execute ability",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
}
}
return [{
...modifier,
amount: modifier.amount * scale,
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
});
const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault"
? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i)
: null;
const explicitScoped = unrelentingAssaultDamage ? [{
kind: "damage-percent" as const,
amount: Number(unrelentingAssaultDamage[1]) * scale,
unit: "percent" as const,
scope: "damage of your Overpower and Revenge abilities",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}));
}] : [];
const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => {
if (!Number.isFinite(amount) || amount === 0) return [];
return [{
@@ -167,7 +255,7 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
description: active.description,
}];
});
return [...parsed, ...supplemental];
return [...parsed, ...explicitScoped, ...supplemental];
});
}
@@ -181,8 +269,12 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
if (scope.includes("instant") && !tags.has("instant")) return false;
if (scope.includes("periodic") && !tags.has("periodic")) return false;
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
if (scope.includes(school) && !tags.has(school)) return false;
const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const)
.filter((school) => scope.includes(school));
if (schools.length > 1) {
if (!schools.some((school) => tags.has(school))) return false;
} else if (schools.length === 1 && !tags.has(schools[0])) {
return false;
}
if (context.abilityName && /\b(?:spell|ability)\b/.test(scope)) {
const normalizedName = context.abilityName.toLowerCase();
@@ -190,10 +282,17 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
if (namedScope && !namedScope.includes("your") && !scope.includes(normalizedName)) return false;
}
if (context.abilityName) {
const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time).*?\b(?:by|of|with) your\b/.test(scope);
const normalizedName = context.abilityName.toLowerCase();
const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope);
const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope)
|| /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);
if (namedScope && !genericScope && !scope.includes(context.abilityName.toLowerCase())) return false;
const namedAbilityList = genericScope
? undefined
: scope.match(/\byour\s+(.+?)\s+abilit(?:y|ies)\b/)?.[1]
?.split(/\s*(?:,|\band\b|\bor\b)\s*/)
.map((name) => name.trim());
if (namedAbilityList?.length && !namedAbilityList.includes(normalizedName)) return false;
if (namedScope && !genericScope && !scope.includes(normalizedName)) return false;
}
return true;
}
+301 -8
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
WOW335_ABILITIES_BY_CLASS,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./wow335AbilityCatalog";
import generatedData from "./wow335AbilityData.generated.json";
import { talentNodeById } from "./talentCatalog";
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
@@ -39,6 +42,99 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
});
it("keeps Bloodthirst's player cast, next-hit aura, and triggered heal on their source rows", () => {
const bloodthirst = abilityAtLevel(byId("wow335-warrior-bloodthirst"), 80);
expect(bloodthirst).toMatchObject({
dbcSpellId: 23881,
target: "hostile",
range: { min: 0, max: 5 },
cost: { resource: "rage", amount: 20 },
cooldownMs: 4_000,
});
expect(effectsOfKind(bloodthirst, "damage")).toHaveLength(1);
const bloodthirstAura = effectsOfKind(bloodthirst, "apply-aura")
.find((effect) => effect.sourceAuraType === 42);
expect(bloodthirstAura).toMatchObject({
recipient: "caster",
aura: {
durationMs: 8_000,
procs: [expect.objectContaining({
triggers: ["hit", "crit"],
attackKinds: ["melee"],
charges: 3,
action: { kind: "custom", id: "wow335-trigger-spell", data: { spellId: 23880 } },
})],
},
});
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[23880]?.effects).toEqual([
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.01 }),
]);
});
it("selects Sweeping Strikes' player self-buff row instead of its damage child", () => {
const source = byId("wow335-warrior-sweeping-strikes");
const sweepingStrikes = abilityAtLevel(source, 80);
expect(source).toMatchObject({ unlockLevel: 30, sourceUnlockLevel: 30 });
expect(sweepingStrikes).toMatchObject({
dbcSpellId: 12328,
target: "self",
range: { min: 0, max: 0 },
cost: { resource: "rage", amount: 30 },
cooldownMs: 30_000,
});
expect(effectsOfKind(sweepingStrikes, "damage")).toEqual([]);
expect(effectsOfKind(sweepingStrikes, "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: 30_000,
procs: [expect.objectContaining({
triggers: ["hit", "crit"],
attackKinds: ["melee"],
charges: 5,
action: { kind: "custom", id: "wow335-sweeping-strikes-secondary-hit" },
})],
}),
}),
]);
});
it("maps Battle Shout and Commanding Shout to nearby party-wide buffs", () => {
for (const [abilityId, auraCount] of [
["wow335-warrior-battle-shout", 2],
["wow335-warrior-commanding-shout", 1],
] as const) {
const ability = abilityAtLevel(byId(abilityId), 80);
const auras = effectsOfKind(ability, "apply-aura");
expect(ability).toMatchObject({ target: "self", radius: 30 });
expect(targetingRulesForAbility(ability)).toMatchObject({ shape: "circle", radius: 30 });
expect(auras).toHaveLength(auraCount);
expect(auras.every((effect) => effect.scope === "party")).toBe(true);
}
});
it("maps Nature's Swiftness to one next-Nature-spell cast-time aura and its shared cooldown", () => {
const natureSwiftness = abilityAtLevel(byId("wow335-shaman-nature-s-swiftness"), 80);
const druidNatureSwiftness = abilityAtLevel(byId("wow335-druid-nature-s-swiftness"), 80);
const elementalMastery = abilityAtLevel(byId("wow335-shaman-elemental-mastery"), 80);
expect(natureSwiftness.cooldownGroup).toBe("shaman-nature-swiftness");
expect(elementalMastery.cooldownGroup).toBe("shaman-nature-swiftness");
expect(natureSwiftness.cooldownMs).toBe(180_000);
expect(druidNatureSwiftness.cooldownMs).toBe(180_000);
for (const ability of [natureSwiftness, druidNatureSwiftness]) {
const auraEffect = effectsOfKind(ability, "apply-aura")[0];
expect(auraEffect).toMatchObject({
recipient: "caster",
aura: {
durationMs: null,
utilityTags: ["wow335-next-nature-instant"],
},
});
if (auraEffect?.kind === "apply-aura") expect(auraEffect.aura.modifiers).toBeUndefined();
}
});
it("combines each Holy Nova damage rank with its matching healing payload", () => {
const nova = byId("wow335-priest-holy-nova");
expect(nova.ranks).toHaveLength(9);
@@ -55,6 +151,47 @@ describe("WoW 3.3.5 executable effect mapping", () => {
});
});
it("keeps Holy Shock's cast wrapper while combining damage and healing ranks", () => {
const shock = byId("wow335-paladin-holy-shock");
expect(shock.ranks).toHaveLength(7);
for (const rank of shock.ranks ?? []) {
expect(effectsOfKind(rank, "damage")).toHaveLength(1);
expect(effectsOfKind(rank, "heal")).toHaveLength(1);
expect(rank.cost.amount).toBeGreaterThan(0);
expect(rank.cooldownMs).toBe(6_000);
}
expect(abilityAtLevel(shock, 80)).toMatchObject({
dbcSpellId: 48825,
target: "any",
range: { min: 0, max: 40 },
castMode: "instant",
gcdMs: 1_500,
cooldownMs: 6_000,
});
});
it("models Life Tap as a health cost and mana restoration, not a heal", () => {
const lifeTap = abilityAtLevel(byId("wow335-warlock-life-tap"), 80);
expect(lifeTap).toMatchObject({
dbcSpellId: 57946,
target: "self",
cost: { resource: "health", amount: 1_490 },
});
expect(effectsOfKind(lifeTap, "resource")).toContainEqual(
{ kind: "resource", resource: "mana", amount: 1_490 },
);
expect(effectsOfKind(lifeTap, "heal")).toEqual([]);
for (const id of [
"wow335-warlock-create-healthstone",
"wow335-warlock-create-soulstone",
"wow335-warlock-demonic-empowerment",
]) {
expect(effectsOfKind(abilityAtLevel(byId(id), 80), "heal"), id).toEqual([]);
}
});
it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => {
const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80);
expect(effectsOfKind(envenom, "finisher-damage")).toEqual([
@@ -143,7 +280,7 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
expect(byId("wow335-death-knight-death-coil")).toMatchObject({
target: "hostile",
target: "any",
effects: expect.arrayContaining([expect.objectContaining({ kind: "damage" })]),
});
});
@@ -185,15 +322,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bloodthirst"), "damage")).toContainEqual(
expect.objectContaining({ coefficient: 1.2 }),
expect.objectContaining({ coefficient: 1, basePoints: 50, bonusPowerCoefficient: 1 }),
);
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "resource")).toEqual([
{ kind: "resource", resource: "rage", amount: 20 },
]);
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toContainEqual(
expect.objectContaining({ coefficient: 1.8 }),
expect(effectsOfKind(abilityAtLevel(byId("wow335-warrior-execute"), 80), "damage")).toContainEqual(
expect.objectContaining({ basePoints: 1_456, coefficient: 1 }),
);
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
@@ -209,13 +347,25 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect.objectContaining({ coefficient: 2 }),
);
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "combo")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toContainEqual(
expect.objectContaining({ coefficient: 0.5, ticks: 5 }),
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toEqual([]);
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "utility")).toContainEqual(
expect.objectContaining({ action: "class-script", durationMs: 2_000 }),
);
expect(byId("wow335-rogue-killing-spree").target).toBe("hostile");
expect(effectsOfKind(byId("wow335-warlock-conflagrate"), "damage")).toEqual([
expect.objectContaining({ coefficient: 1.4 }),
expect.objectContaining({ coefficient: 0, school: "fire" }),
]);
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "taunt")).toEqual([
{ kind: "taunt", durationMs: 3_000 },
]);
expect(effectsOfKind(byId("wow335-death-knight-chains-of-ice"), "status")).toEqual([
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
]);
expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "damage")).toEqual([]);
expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "utility")).toContainEqual(
expect.objectContaining({ action: "class-script", effectType: 77 }),
);
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
expect.objectContaining({ triggers: "damage-taken", charges: 3 }),
@@ -249,6 +399,92 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(hurricane.cost.amount).toBeGreaterThan(0);
});
it("makes Empower Rune Weapon activate all six runes from the player spell", () => {
const empowerRuneWeapon = abilityAtLevel(byId("wow335-death-knight-empower-rune-weapon"), 80);
expect(empowerRuneWeapon.dbcSpellId).toBe(47568);
expect(effectsOfKind(empowerRuneWeapon, "rune")).toEqual([
{ kind: "rune", action: "activate", count: 6 },
]);
expect(effectsOfKind(empowerRuneWeapon, "resource")).toEqual([
{ kind: "resource", resource: "runic-power", amount: 25 },
]);
});
it("models Death Knight source healing, rune, charge, and magic-shell effects", () => {
const deathCoil = abilityAtLevel(byId("wow335-death-knight-death-coil"), 80);
expect(deathCoil.target).toBe("any");
expect(effectsOfKind(deathCoil, "damage")).not.toEqual([]);
expect(effectsOfKind(deathCoil, "heal")).not.toEqual([]);
const deathStrike = abilityAtLevel(byId("wow335-death-knight-death-strike"), 80);
expect(effectsOfKind(deathStrike, "heal")).toEqual([
expect.objectContaining({ coefficient: 0, percentMaxHealth: 0.05 }),
]);
const antiMagicShell = abilityAtLevel(byId("wow335-death-knight-anti-magic-shell"), 80);
expect(effectsOfKind(antiMagicShell, "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: 5_000,
absorbs: [expect.objectContaining({
amount: 0.5,
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
})],
}),
}),
]);
const antiMagicZone = abilityAtLevel(byId("wow335-death-knight-anti-magic-zone"), 80);
expect(antiMagicZone).toMatchObject({
target: "self",
description: expect.stringContaining("75% of incoming magic damage"),
targeting: { shape: "ground", radius: 10, groundRange: 30 },
});
expect(effectsOfKind(antiMagicZone, "summon")).toEqual([
expect.objectContaining({
creatureId: 28306,
durationMs: 10_000,
antiMagicZone: {
radius: 10,
capacityBase: 10_000,
capacityPerAttackPower: 2,
absorbFraction: 0.75,
intervalMs: 250,
},
}),
]);
const boneShield = abilityAtLevel(byId("wow335-death-knight-bone-shield"), 80);
expect(effectsOfKind(boneShield, "apply-aura")).toEqual([
expect.objectContaining({ aura: expect.objectContaining({
durationMs: 300_000,
initialStacks: 4,
maxStacks: 4,
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "damage", direction: "taken", value: -0.2, perStack: false }),
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.02, perStack: false }),
]),
procs: [expect.objectContaining({
triggers: "damage-taken",
charges: 4,
action: { kind: "custom", id: "wow335-death-knight-bone-shield-charge" },
})],
}) }),
]);
expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-blood-tap"), 80), "rune")).toEqual([
{ kind: "rune", action: "activate", count: 1, runeType: "blood", convertToDeathMs: 20_000 },
]);
expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-deathchill"), 80), "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["wow335-deathchill-next-critical"]) }),
}),
]);
});
it("keeps every rank of imported channels in channel mode", () => {
const mismatches = Object.values(WOW335_ABILITIES_BY_CLASS).flat()
.filter((ability) => ability.castMode === "channel" && ability.ranks?.some((rank) => rank.castMode !== "channel"))
@@ -321,6 +557,14 @@ describe("WoW 3.3.5 executable effect mapping", () => {
.toEqual(expect.arrayContaining([expect.objectContaining({ stat: "incoming-ranged-hit", value: -25 })]));
});
it("keeps Blink's root and stun breaks without inventing target-relative teleport movement", () => {
const blink = byId("wow335-mage-blink");
expect(blink.dbcSpellId).toBe(1953);
expect(effectsOfKind(blink, "movement")).toEqual([]);
expect(effectsOfKind(blink, "apply-aura").map((effect) => effect.aura.controlImmunities))
.toEqual([["stun"], ["root"]]);
});
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
expect(lightningShield.dbcSpellId).toBe(49281);
@@ -427,6 +671,36 @@ describe("WoW 3.3.5 executable effect mapping", () => {
});
});
it("maps Paladin revive, healing riders, class-group blessings, and aura mechanics", () => {
const redemption = byId("wow335-paladin-redemption");
expect(redemption.target).toBe("friendly");
expect(effectsOfKind(redemption, "resurrection")).toContainEqual(expect.objectContaining({
flatHealth: 65,
}));
const divineStorm = byId("wow335-paladin-divine-storm");
expect(divineStorm.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", coefficient: 1.1, school: "physical" }),
expect.objectContaining({ kind: "heal", percentOfDamage: 0.25, scope: "party", maximumTargets: 3 }),
]));
const beacon = abilityAtLevel(byId("wow335-paladin-beacon-of-light"), 80);
expect(effectsOfKind(beacon, "apply-aura")[0]?.aura.utilityTags).toContain("beacon-of-light");
const greaterBlessing = byId("wow335-paladin-greater-blessing-of-kings");
expect(effectsOfKind(greaterBlessing, "apply-aura")[0]?.scope).toBe("party");
const avengersShield = byId("wow335-paladin-avenger-s-shield");
expect(avengersShield.targeting).toMatchObject({ shape: "chain", maximumTargets: 3, jumpRange: 10 });
const auraMastery = byId("wow335-paladin-aura-mastery");
expect(effectsOfKind(auraMastery, "apply-aura").every((effect) => (
effect.scope === "party" && effect.aura.utilityTags?.includes("wow335-aura-mastery-silence-interrupt")
))).toBe(true);
const divineFavor = byId("wow335-paladin-divine-favor");
expect(effectsOfKind(divineFavor, "apply-aura")[0]?.aura.utilityTags)
.toContain("wow335-divine-favor-next-holy-heal");
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
@@ -491,4 +765,23 @@ describe("WoW 3.3.5 executable effect mapping", () => {
aura: { disposition: "debuff", dispelCategory: "magic" },
});
});
it("maps Soulshatter to its source 50-yard self-centered threat reduction", () => {
const soulshatter = byId("wow335-warlock-soulshatter");
const source = generatedData.classes.warlock.find((chain) => chain.name === "Soulshatter")!;
expect(source.ranks[0]).toMatchObject({
spellId: 29858,
description: expect.stringContaining("Reduces threat by 50%"),
effects: [expect.objectContaining({ effectType: 3, basePoints: -50, radius: 50 })],
});
expect(soulshatter).toMatchObject({
dbcSpellId: 29858,
target: "self",
radius: 50,
});
expect(effectsOfKind(soulshatter, "threat")).toEqual([
{ kind: "threat", mode: "percent", amount: -50 },
]);
});
});
File diff suppressed because it is too large. Load diff
+249
View File
@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, resolveStatValue } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
const TARGET_ID = "druid-proc-target";
function unit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function findDruidProcStart(name: string, startAt: number, chance: number, procTag: string): number {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
for (let start = startAt; start < startAt + 100_000; start += 100) {
const impactAt = start + ability!.castTimeMs;
const directSeed = `${PLAYER_AGGRO_ID}:${ability!.id}:${TARGET_ID}:${impactAt}:direct`;
if (unit(`${directSeed}:outcome`) > 0.1 && unit(`${directSeed}:${procTag}`) < chance) return start;
}
throw new Error(`No deterministic ${name} proc seed found.`);
}
function castAndFinish(name: string, startedAt: number) {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, { targetActorId: TARGET_ID, now: startedAt });
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(Math.max(0.1, (activeCast.completesAt - startedAt) / 1_000), activeCast.completesAt);
}
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === id
)).at(-1);
}
describe("WoW Druid proc runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["druid"]);
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-druid-proc-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 1_000_000 });
setMobPosition(TARGET_ID, [4, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
});
it("procs Solar Eclipse from Starfire and gives Wrath its source 30% damage bonus", () => {
const baseline = castAndFinish("Wrath", 1_000);
expect(baseline?.kind).toBe("damage");
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } }));
castAndFinish("Starfire", 5_000);
const solar = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar")
));
expect(solar?.expiresAt).toBe(23_500);
expect(useCombatStore.getState().cooldowns["wow335-druid-eclipse-solar"]).toBe(38_500);
const buffed = castAndFinish("Wrath", 10_000);
expect(buffed?.kind).toBe("damage");
expect(buffed?.rawAmount).toBe(Math.round(baseline!.rawAmount * 1.3));
});
it("procs Lunar Eclipse from Wrath and adds 30 percentage points to Starfire crit chance", () => {
const baselineEvent = castAndFinish("Starfire", 1_000);
expect(baselineEvent?.kind).toBe("damage");
const baselineCritChance = useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent;
expect(baselineCritChance).toBeDefined();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } }));
const wrathStart = findDruidProcStart("Wrath", 6_000, 0.6, "druid-eclipse");
castAndFinish("Wrath", wrathStart);
const lunar = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar")
));
expect(lunar).toBeDefined();
castAndFinish("Starfire", wrathStart + 2_100);
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent)
.toBe(baselineCritChance! + 30);
});
it("grants Nature's Grace after a direct healing critical and applies 20% cast haste for four seconds", () => {
const healingTouch = abilityId("Healing Touch");
let startedAt = 1_000;
while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) {
startedAt += 100;
}
useCombatStore.setState((state) => ({
health: Math.round(state.maxHealth / 2),
talentRanks: { ...state.talentRanks, "talent-789": 3, "talent-827": 3 },
auras: [...applyAura(state.auras, {
definition: {
id: "test-druid-guaranteed-spell-crit",
name: "Test Critical Chance",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now: startedAt,
}).auras],
}));
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }).ok).toBe(true);
const firstCast = useCombatStore.getState().activeCast;
expect(firstCast?.abilityId).toBe(healingTouch);
useCombatStore.getState().tick(0.1, firstCast!.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === healingTouch && event.critical
))).toBe(true);
const natureGrace = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-natures-grace")
));
expect(natureGrace?.expiresAt).toBe(firstCast!.completesAt + 4_000);
expect(resolveStatValue(0, [natureGrace!], "haste")).toBe(20);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
const manaAfterFirstHeal = useCombatStore.getState().resource;
const nextStart = firstCast!.completesAt + 1_500;
useCombatStore.setState((state) => ({
lastResourceSpentAt: { ...state.lastResourceSpentAt, mana: nextStart },
}));
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: nextStart }).ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(manaAfterFirstHeal);
const hastedCast = useCombatStore.getState().activeCast;
expect(hastedCast?.originalDurationMs).toBe(Math.round(3_000 / 1.2));
useCombatStore.getState().tick(0.1, hastedCast!.completesAt);
const overhealClearcasting = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
));
expect(overhealClearcasting?.expiresAt).toBe(hastedCast!.completesAt + 15_000);
expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"])
.toBe(hastedCast!.completesAt + 2_000);
});
it("procs Omen of Clarity from damage, makes the next eligible cast free, and respects its two-second ICD", () => {
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } }));
const moonfire = useCombatStore.getState().abilities.find((ability) => ability.name === "Moonfire");
expect(moonfire).toBeDefined();
const start = (() => {
for (let now = 1_000; now < 100_000; now += 1) {
const seed = `${PLAYER_AGGRO_ID}:${moonfire!.id}:${TARGET_ID}:${now}:direct`;
if (unit(`${seed}:outcome`) > 0.1 && unit(`${seed}:druid-omen-of-clarity`) < 0.06) return now;
}
throw new Error("No deterministic Omen of Clarity proc seed found.");
})();
const first = useCombatStore.getState().castAbility(moonfire!.id, { targetActorId: TARGET_ID, now: start });
expect(first.ok, JSON.stringify(first)).toBe(true);
const manaAfterFirst = useCombatStore.getState().resource;
const clearcasting = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
));
expect(clearcasting?.expiresAt).toBe(start + 15_000);
expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"]).toBe(start + 2_000);
const second = useCombatStore.getState().castAbility(moonfire!.id, {
targetActorId: TARGET_ID,
now: start + 1_500,
});
expect(second.ok, JSON.stringify(second)).toBe(true);
expect(useCombatStore.getState().resource).toBe(manaAfterFirst);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(false);
});
it("allows a successful healing spell that fully overheals to proc Omen of Clarity", () => {
const healingTouch = abilityId("Healing Touch");
useCombatStore.setState((state) => ({
health: state.maxHealth,
talentRanks: { ...state.talentRanks, "talent-827": 3 },
}));
let startedAt = 1_000;
while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) {
startedAt += 100;
}
const result = useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt });
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(healingTouch);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
const healingEvent = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "healing" && event.abilityId === healingTouch && event.occurredAt === activeCast!.completesAt
));
expect(healingEvent).toMatchObject({ rawAmount: expect.any(Number), effectiveAmount: 0 });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
});
it("allows landed player auto attacks to trigger Omen of Clarity", () => {
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } }));
let now = 1_000;
let seed = `druid-auto-attack:${now}`;
while (unit(`${seed}:druid-omen-of-clarity`) >= 0.06) {
now += 1;
seed = `druid-auto-attack:${now}`;
}
const result = useCombatStore.getState().damageMob(TARGET_ID, 100, now, undefined, "physical", {
attackKind: "melee",
seed,
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false },
});
expect(result.amount).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
function prepareSwiftmend(): AbilityDefinition {
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.setState((state) => ({ health: 1_000, resource: state.maxResource }));
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Swiftmend");
if (!ability) throw new Error("Druid Swiftmend is missing from the learned ability catalog.");
const talentId = abilityTalentNodeId(ability);
if (!talentId) throw new Error("Druid Swiftmend has no active talent node.");
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [talentId]: 1 } }));
return ability;
}
function hot(
sourceName: "Rejuvenation" | "Regrowth",
sourceActorId: string,
tickHealing: number,
remainingTicks: number,
nextTickAt: number,
id: string,
fixedAmount?: number,
): TimedCombatEffect {
const sourceAbility = useCombatStore.getState().abilities.find((ability) => ability.name === sourceName);
if (!sourceAbility) throw new Error(`Druid ${sourceName} is missing from the learned ability catalog.`);
return {
id,
kind: "hot",
sourceAbilityId: sourceAbility.id,
sourceActorId,
targetId: null,
coefficient: 0,
basePoints: tickHealing,
dieSides: 1,
pointsPerLevel: 0,
sourceLevel: 80,
...(fixedAmount === undefined ? {} : { fixedAmount }),
remainingTicks,
intervalMs: 3_000,
nextTickAt,
};
}
function castSwiftmend(ability: AbilityDefinition, now = 1_000) {
const result = useCombatStore.getState().castAbility(ability.id, { targetActorId: null, now });
expect(result.ok).toBe(true);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "healing" && candidate.abilityId === ability.id
)).at(-1);
if (!event || event.kind !== "healing") throw new Error("Swiftmend did not record its healing event.");
return event;
}
function expectCriticalAdjustedRawHealing(rawAmount: number, critical: boolean, baseAmount: number): void {
expect(rawAmount).toBe(baseAmount * (critical ? 1.5 : 1));
}
describe("WoW Druid active talent runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
it.each([
["Rejuvenation", 100, 5, 400],
["Regrowth", 150, 7, 900],
] as const)("Swiftmend uses its source 12/18-second amount for %s", (sourceName, tickHealing, remainingTicks, expected) => {
const swiftmend = prepareSwiftmend();
const sourceHot = hot(sourceName, PLAYER_AGGRO_ID, tickHealing, remainingTicks, 4_000, "swiftmend-source-hot");
useCombatStore.setState({ effects: [sourceHot] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, expected);
expect(useCombatStore.getState().effects).toEqual([]);
});
it("prefers its own Hot over a shorter foreign Hot", () => {
const swiftmend = prepareSwiftmend();
const foreignShort = hot("Rejuvenation", "party-druid-short", 25, 1, 1_500, "swiftmend-foreign-short", 25);
const casterHot = hot("Regrowth", PLAYER_AGGRO_ID, 100, 7, 16_000, "swiftmend-caster-long");
useCombatStore.setState({ effects: [foreignShort, casterHot] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 600);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([foreignShort.id]);
});
it("uses the shortest foreign Rejuvenation or Regrowth when no caster Hot exists", () => {
const swiftmend = prepareSwiftmend();
const longerRegrowth = hot("Regrowth", "party-druid-long", 50, 7, 4_000, "swiftmend-foreign-long", 50);
const shorterRejuvenation = hot("Rejuvenation", "party-druid-short", 100, 5, 4_000, "swiftmend-foreign-short", 100);
useCombatStore.setState({ effects: [longerRegrowth, shorterRejuvenation] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]);
});
});
+376
View File
@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { cooldownResetCandidates } from "./cooldownReset";
import { talentModifiersForRanks } from "./talentModifiers";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { usePartyStore } from "./partyStore";
describe("WoW 3.3.5 Hunter Readiness", () => {
it("preserves Readiness's Bestial Wrath cooldown exception", () => {
const hunterAbilities = WOW335_ABILITIES_BY_CLASS.hunter;
const readiness = hunterAbilities.find((ability) => ability.name === "Readiness");
expect(readiness).toBeDefined();
const reset = readiness!.effects.find((effect) => effect.kind === "cooldown-reset");
expect(reset).toMatchObject({
kind: "cooldown-reset",
mode: "reset",
excludeAbilityNames: ["Bestial Wrath"],
});
if (!reset || reset.kind !== "cooldown-reset") return;
const candidates = cooldownResetCandidates(
hunterAbilities,
readiness!.id,
reset.abilityName,
reset.excludeAbilityNames,
);
expect(candidates.some((ability) => ability.name === "Bestial Wrath")).toBe(false);
expect(candidates.some((ability) => ability.name === "Readiness")).toBe(false);
expect(candidates.some((ability) => ability.name === "Rapid Fire")).toBe(true);
});
it("does not turn The Beast Within's conditional tooltip into a permanent modifier", () => {
expect(talentModifiersForRanks({ "talent-1803": 1 }, "hunter")).toEqual([]);
expect(talentModifiersForRanks({ "talent-1803": 2 }, "hunter")).toEqual([]);
});
});
function hunterAbilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockHunterTalent(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
return ability!.id;
}
function hunterProcRoll(seed: string): number {
let hash = 2_166_136_261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function hunterProcTime(abilityId: string, petId: string, shouldProc: boolean, after: number): number {
for (let now = after; now < after + 60_000; now += 1) {
const rolls = hunterProcRoll(`${PLAYER_AGGRO_ID}:${abilityId}:${petId}:${now}:the-beast-within`);
if ((rolls < 0.5) === shouldProc) return now;
}
throw new Error("Could not find a deterministic rank-one proc test time.");
}
function registerHunterMob(id: string, position: readonly [number, number, number], maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
describe("WoW 3.3.5 Hunter pet mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["hunter"]);
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty(
"wow335-hunter-mechanics-tests",
1,
useCombatStore.getState().health,
);
});
it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => {
const readinessId = unlockHunterTalent("Readiness");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
const rapidFireId = hunterAbilityId("Rapid Fire");
useCombatStore.setState((state) => ({
cooldowns: {
...state.cooldowns,
[readinessId]: 0,
[bestialWrathId]: 90_000,
[rapidFireId]: 30_000,
},
}));
const result = useCombatStore.getState().castAbility(readinessId, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns[bestialWrathId]).toBe(90_000);
expect(useCombatStore.getState().cooldowns[rapidFireId]).toBe(0);
expect(useCombatStore.getState().cooldowns[readinessId]).toBeGreaterThan(1_000);
});
it("rejects Bestial Wrath without an active pet and does not proc The Beast Within", () => {
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
}));
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
const startingMana = useCombatStore.getState().resourcePools.mana;
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
expect(useCombatStore.getState().cooldowns[bestialWrathId] ?? 0).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
});
it("requires a live pet before accepting pet-command and Mend Pet casts", () => {
registerHunterMob("petless-target", [0, 0, 3]);
const petDependentCasts = [
{ id: unlockHunterTalent("Bestial Wrath"), targetActorId: undefined },
{ id: unlockHunterTalent("Intimidation"), targetActorId: "petless-target" },
{ id: hunterAbilityId("Kill Command"), targetActorId: "petless-target" },
{ id: hunterAbilityId("Mend Pet"), targetActorId: undefined },
];
const startingMana = useCombatStore.getState().resourcePools.mana;
for (const [index, cast] of petDependentCasts.entries()) {
const result = useCombatStore.getState().castAbility(cast.id, {
...(cast.targetActorId ? { targetActorId: cast.targetActorId } : {}),
now: 1_000 + index * 2_000,
});
expect(result, JSON.stringify(result)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
expect(useCombatStore.getState().cooldowns[cast.id] ?? 0).toBe(0);
}
});
it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => {
const callPetId = hunterAbilityId("Call Pet");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
}));
const callResult = useCombatStore.getState().castAbility(callPetId, undefined, 1_000);
expect(callResult, JSON.stringify(callResult)).toMatchObject({ ok: true });
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 3_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
const auras = useCombatStore.getState().auras;
const petAuras = auras.filter((aura) => aura.targetId === pet!.id && aura.definition.id.includes(":19574:"));
expect(petAuras).toHaveLength(3);
expect(auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":19574:"))).toBe(false);
const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"));
expect(beastWithinAuras).toHaveLength(3);
expect(beastWithinAuras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.2 }),
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.1 }),
]));
});
it("uses The Beast Within's 50% rank-one proc chance", () => {
const callPetId = hunterAbilityId("Call Pet");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 1 },
}));
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const noProcAt = hunterProcTime(bestialWrathId, pet!.id, false, 3_000);
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, noProcAt).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"))).toBe(false);
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [bestialWrathId]: 0 },
globalCooldownEndsAt: 0,
}));
const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000);
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"))).toBe(true);
});
it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => {
registerHunterMob("intimidation-target", [0, 0, 3]);
const callPetId = hunterAbilityId("Call Pet");
const intimidationId = unlockHunterTalent("Intimidation");
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const cast = useCombatStore.getState().castAbility(intimidationId, { targetActorId: "intimidation-target", now: 3_000 });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const intimidationAura = useCombatStore.getState().auras.find((aura) => (
aura.definition.id.includes(":19577:")
));
expect(intimidationAura?.targetId).toBe(pet!.id);
expect(useCombatStore.getState().mobs["intimidation-target"]?.statuses).toHaveLength(0);
useCombatStore.getState().tick(1, 4_000);
const target = useCombatStore.getState().mobs["intimidation-target"];
expect(target?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stun", sourceActorId: pet!.id, endsAt: 7_000 }),
]));
expect(target?.threatByActor[pet!.id]).toBe(580);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === intimidationAura?.instanceId)).toBe(false);
});
it("only enables Counterattack after a parry and consumes that readiness on use", () => {
const counterattackId = unlockHunterTalent("Counterattack");
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, parryRating: 100_000 },
}));
useCombatStore.getState().registerMob("counterattack-target", {
name: "counterattack-target",
maxHealth: 100_000,
xpReward: 0,
attacks: [DEFAULT_ENEMY_ATTACK],
});
setMobPosition("counterattack-target", [2, 0, 0]);
const unavailable = useCombatStore.getState().castAbility(counterattackId, {
targetActorId: "counterattack-target", now: 1_000,
});
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().damageMob("counterattack-target", 1, 1_100);
let parryAt: number | null = null;
for (let now = 1_450; now < 20_000; now += 100) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().lastCombatResult?.parried) {
parryAt = now;
break;
}
}
expect(parryAt).not.toBeNull();
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
))).toBe(true);
const available = useCombatStore.getState().castAbility(counterattackId, {
targetActorId: "counterattack-target", now: parryAt! + 100,
});
expect(available, JSON.stringify(available)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
))).toBe(false);
});
it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => {
registerHunterMob("kill-command-target", [0, 0, 3]);
const callPetId = hunterAbilityId("Call Pet");
const killCommandId = hunterAbilityId("Kill Command");
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const cast = useCombatStore.getState().castAbility(killCommandId, { targetActorId: "kill-command-target", now: 3_000 });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const aura = useCombatStore.getState().auras.find((entry) => (
entry.definition.utilityTags?.includes("wow335-hunter-kill-command")
));
expect(aura?.targetId).toBe(pet!.id);
expect(aura?.stacks).toBe(60);
expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false);
useCombatStore.getState().tick(1, 4_000);
expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.stacks).toBe(60);
});
it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => {
registerHunterMob("freezing-target", [0, 0, 10]);
useCombatStore.getState().registerMob("freezing-later", { name: "freezing-later", maxHealth: 100_000, xpReward: 0 });
setMobPosition("freezing-later", [0, 0, 12]);
const freezingArrowId = hunterAbilityId("Freezing Arrow");
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === freezingArrowId)!;
expect(ability.targeting).toMatchObject({ shape: "ground", groundRange: 40, radius: 2 });
const cast = useCombatStore.getState().castAbility(freezingArrowId, {
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("freezing");
setMobPosition("freezing-target", [0, 0, 1]);
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "freeze", endsAt: 22_000, breakOnDamage: true }),
]));
expect(useCombatStore.getState().mobs["freezing-later"]?.statuses).toHaveLength(0);
expect(useCombatStore.getState().areaEffects.some((area) => area.hunterTrap?.kind === "freezing")).toBe(false);
useCombatStore.getState().damageMob("freezing-target", 1, 2_001);
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
});
it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => {
registerHunterMob("explosive-center", [0, 0, 0]);
useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 });
setMobPosition("explosive-edge", [0, 0, 8]);
useCombatStore.getState().registerMob("explosive-outside", { name: "explosive-outside", maxHealth: 100_000, xpReward: 0 });
setMobPosition("explosive-outside", [0, 0, 11]);
const abilityId = hunterAbilityId("Explosive Trap");
const cast = useCombatStore.getState().castAbility(abilityId, {
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const beforeCenter = useCombatStore.getState().mobs["explosive-center"]!.health;
const beforeEdge = useCombatStore.getState().mobs["explosive-edge"]!.health;
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("explosive");
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(beforeCenter);
expect(useCombatStore.getState().mobs["explosive-edge"]!.health).toBeLessThan(beforeEdge);
expect(useCombatStore.getState().mobs["explosive-outside"]!.health).toBe(100_000);
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
occurredAt: 2_000,
kind: "damage",
abilityId,
targetActorId: "explosive-center",
school: "fire",
}),
]));
expect(useCombatStore.getState().areaEffects[0]).toMatchObject({ radius: 10, tickIntervalMs: 2_000 });
const afterImpact = useCombatStore.getState().mobs["explosive-center"]!.health;
useCombatStore.getState().tick(1, 4_000);
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(afterImpact);
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
occurredAt: 4_000,
kind: "damage",
abilityId,
targetActorId: "explosive-center",
school: "fire",
}),
]));
});
});
+125
View File
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { WOW335_MAGE_IMPACT_TAG } from "./wow335MageTalentRuntime";
const TARGET_ID = "mage-impact-target";
const PROC_SEED = "mage-impact-seed-0";
const NO_PROC_SEED = "mage-impact-no-proc-0";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function setImpactRank(rank: number): void {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-30": rank },
}));
}
function impactAura() {
return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_IMPACT_TAG));
}
function damageSpell(name: string, now: number, seed: string, rules = {}) {
const spell = useCombatStore.getState().abilities.find((ability) => ability.name === name);
expect(spell, name).toBeDefined();
return useCombatStore.getState().damageMob(TARGET_ID, 1_000, now, undefined,
name === "Frostbolt" ? "frost" : "fire", {
attackKind: "spell",
seed,
abilityId: spell!.id,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
...rules,
},
});
}
describe("WoW 3.3.5 Mage Impact runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["mage"]);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-mage-impact-tests", 1, useCombatStore.getState().health);
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 100_000 });
useCombatStore.getState().selectMob(TARGET_ID);
});
it("uses the active rank-three 4% proc on direct damaging spells without requiring a critical", () => {
setImpactRank(3);
const result = damageSpell("Fireball", 1_000, PROC_SEED);
expect(result.combatResult?.landed).toBe(true);
expect(result.combatResult?.critical).toBe(false);
expect(impactAura()?.expiresAt).toBe(11_000);
useCombatStore.getState().consumeActorAuraTag(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_IMPACT_TAG);
damageSpell("Fireball", 2_000, NO_PROC_SEED);
expect(impactAura()).toBeUndefined();
});
it("does not activate the child stun proc before rank three", () => {
for (const rank of [1, 2]) {
setImpactRank(rank);
damageSpell("Fireball", 1_000 + rank, PROC_SEED);
expect(impactAura()).toBeUndefined();
}
});
it("preserves the proc through a missed Fire Blast, then consumes it on a landed Fire Blast", () => {
setImpactRank(3);
damageSpell("Fireball", 1_000, PROC_SEED);
expect(impactAura()).toBeDefined();
const miss = damageSpell("Fire Blast", 2_000, NO_PROC_SEED, {
canMiss: true,
baseMissChancePercent: 100,
});
expect(miss.combatResult?.landed).toBe(false);
expect(impactAura()).toBeDefined();
expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses.some((status) => status.kind === "stun"))
.toBe(false);
const hit = damageSpell("Fire Blast", 3_000, NO_PROC_SEED);
expect(hit.combatResult?.landed).toBe(true);
expect(impactAura()).toBeUndefined();
expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses).toContainEqual(expect.objectContaining({
kind: "stun",
sourceAbilityId: abilityId("Fire Blast"),
endsAt: 6_000,
}));
});
it("consumes the Fire Blast proc charge against a stun-immune target", () => {
setImpactRank(3);
useCombatStore.getState().registerMob("mage-impact-immune", {
name: "Stun immune target",
maxHealth: 100_000,
mechanicImmunities: ["stun"],
});
damageSpell("Fireball", 1_000, PROC_SEED);
useCombatStore.getState().damageMob("mage-impact-immune", 1_000, 2_000, undefined, "fire", {
attackKind: "spell",
seed: NO_PROC_SEED,
abilityId: abilityId("Fire Blast"),
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
expect(impactAura()).toBeUndefined();
expect(useCombatStore.getState().mobs["mage-impact-immune"]?.statuses).toEqual([]);
});
});
+490
View File
@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { resolveDamageValue, resolveStatValue } from "./combatAuras";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
WOW335_MAGE_FINGERS_OF_FROST_TAG,
WOW335_MAGE_FOCUS_MAGIC_TAG,
WOW335_MAGE_HOT_STREAK_TAG,
WOW335_MAGE_IMPROVED_SCORCH_TAG,
} from "./wow335MageTalentRuntime";
function runtimeAbilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
describe("WoW 3.3.5 Mage runtime mappings", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["mage"]);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-mage-runtime-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
useCombatStore.getState().registerMob("cold-snap-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("cold-snap-target", [4, 0, 0]);
useCombatStore.getState().selectMob("cold-snap-target");
});
it("resets Mage Frost spell cooldowns and leaves Fire and Arcane cooldowns intact", () => {
const coldSnap = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Cold Snap");
expect(coldSnap?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Nova" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Ward" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Fire Ward" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Ice Block" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Deep Freeze" }),
]));
const cast = useCombatStore.getState().castAbility;
for (const [name, now] of [
["Frost Ward", 1_000],
["Fire Blast", 3_000],
["Arcane Power", 5_000],
["Frost Nova", 7_000],
] as const) {
if (name === "Arcane Power") unlockTalentAbility(name);
const result = cast(runtimeAbilityId(name), undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
}
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Ward")]).toBe(31_000);
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Fire Ward")]).toBe(31_000);
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Nova")]).toBe(32_000);
const coldSnapResult = cast(unlockTalentAbility("Cold Snap"), undefined, 9_000);
expect(coldSnapResult.ok, JSON.stringify(coldSnapResult)).toBe(true);
const cooldowns = useCombatStore.getState().cooldowns;
expect(cooldowns[runtimeAbilityId("Frost Ward")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Fire Ward")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Frost Nova")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Ice Block")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Deep Freeze")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Fire Blast")]).toBe(11_000);
expect(cooldowns[runtimeAbilityId("Arcane Power")]).toBe(125_000);
expect(cooldowns[runtimeAbilityId("Cold Snap")]).toBe(489_000);
});
it("applies Arcane Power's source 20% magic damage and mana cost modifiers", () => {
const arcanePower = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Power");
const modeledAura = arcanePower?.effects.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.id.endsWith(":arcane-power"));
expect(modeledAura).toHaveLength(1);
expect(modeledAura?.[0]).toMatchObject({
kind: "apply-aura",
recipient: "caster",
aura: {
durationMs: 15_000,
modifiers: [
{ kind: "damage", direction: "dealt", value: 0.2 },
{ kind: "resource", resource: "mana", property: "cost", value: 0.2 },
],
},
});
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
const baseManaCost = resolvedAbilityCosts(
fireBlast,
useCombatStore.getState().talentModifiers,
useCombatStore.getState(),
)[0]!.amount;
const arcanePowerId = unlockTalentAbility("Arcane Power");
const result = useCombatStore.getState().castAbility(arcanePowerId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const state = useCombatStore.getState();
const aura = state.auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.id.endsWith(":arcane-power"));
expect(aura).toBeDefined();
for (const school of ["fire", "frost", "arcane"] as const) {
expect(resolveDamageValue(100, [aura!], { direction: "dealt", school, attackKind: "spell" })).toBe(120);
}
const buffedManaCost = resolvedAbilityCosts(fireBlast, state.talentModifiers, state)[0]!.amount;
expect(buffedManaCost).toBe(Math.round(baseManaCost * 1.2));
});
it("applies Focus Magic to an ally and grants the Mage 3% spell crit for 10 seconds when the ally spell-crits", () => {
const focusMagic = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Focus Magic");
const targetBuff = focusMagic?.effects.find((effect) => effect.kind === "apply-aura");
expect(targetBuff).toMatchObject({
kind: "apply-aura",
aura: {
modifiers: [expect.objectContaining({
kind: "stat",
stat: "spell-critical-strike",
operation: "flat",
value: 3,
})],
},
});
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
const focusMagicId = unlockTalentAbility("Focus Magic");
const focusMagicCast = useCombatStore.getState().castAbility(focusMagicId, {
targetActorId: ally.id,
now: 1_000,
});
expect(focusMagicCast, JSON.stringify(focusMagicCast)).toMatchObject({ ok: true });
const allyAura = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-critical-strike"));
expect(allyAura, JSON.stringify(useCombatStore.getState().auras.map((aura) => ({
targetId: aura.targetId,
name: aura.definition.name,
modifiers: aura.definition.modifiers,
procs: aura.definition.procs,
})))).toBeDefined();
expect(allyAura?.definition.procs ?? []).toContainEqual(expect.objectContaining({
triggers: "crit",
attackKinds: ["spell"],
action: { kind: "custom", id: "wow335-mage-focus-magic-proc" },
}));
const fireballId = runtimeAbilityId("Fireball");
const allyCritical = useCombatStore.getState().damageMob("cold-snap-target", 100, 2_000, {
actorId: ally.id,
role: ally.role,
abilityId: fireballId,
threatScale: 1,
}, "fire", {
attackKind: "spell",
seed: "mage-focus-magic-ally-crit",
abilityId: fireballId,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
});
expect(allyCritical.combatResult?.critical).toBe(true);
const procAura = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FOCUS_MAGIC_TAG));
expect(procAura?.expiresAt).toBe(12_000);
expect(resolveStatValue(0, [procAura!], "spell-critical-strike")).toBe(3);
});
it("ticks rank-five Ignite for the DBC 40% burn total on two fixed Fire damage ticks", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-34": 5 },
}));
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const critical = useCombatStore.getState().damageMob("cold-snap-target", 100, 1_000, undefined, "fire", {
attackKind: "spell",
seed: "mage-ignite-rank-five-crit",
abilityId: fireball.id,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
});
expect(critical.combatResult?.critical).toBe(true);
// Spell effect ranks 11119 through 12848 encode an 8%-per-rank burn. The
// generated talent tooltip's 30%/healing-reduction wording conflicts with
// those rows; no healing modifier is inferred from the spell data.
const igniteTicks = useCombatStore.getState().effects.filter((effect) => (
effect.tag === "wow335-mage-ignite" && effect.targetId === "cold-snap-target"
));
expect(igniteTicks).toHaveLength(2);
expect(igniteTicks.map((effect) => effect.nextTickAt)).toEqual([3_000, 5_000]);
expect(igniteTicks.reduce((sum, effect) => sum + (effect.fixedAmount ?? 0), 0))
.toBe(Math.round(critical.amount * 0.4));
useCombatStore.getState().tick(0.1, 3_000);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite"))
.toHaveLength(1);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === fireball.id && event.occurredAt === 3_000
))).toBe(true);
useCombatStore.getState().tick(0.1, 5_000);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite"))
.toHaveLength(0);
});
it("maps Presence of Mind to a non-expiring next cast-time Mage spell aura", () => {
const presenceOfMind = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Presence of Mind");
expect(presenceOfMind?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: ["wow335-next-mage-instant"],
}),
}),
]));
});
it("keeps Presence of Mind through instant and channel casts, then makes the next eligible Mage cast instant", () => {
const presenceOfMindId = unlockTalentAbility("Presence of Mind");
expect(useCombatStore.getState().castAbility(presenceOfMindId, undefined, 1_000)).toMatchObject({ ok: true });
const hasPresenceOfMind = () => useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-next-mage-instant")
));
expect(hasPresenceOfMind()).toBe(true);
expect(useCombatStore.getState().castAbility(runtimeAbilityId("Fire Blast"), undefined, 2_000))
.toMatchObject({ ok: true });
expect(hasPresenceOfMind()).toBe(true);
const missiles = runtimeAbilityId("Arcane Missiles");
expect(useCombatStore.getState().castAbility(missiles, undefined, 4_000)).toMatchObject({ ok: true });
const channel = useCombatStore.getState().activeCast;
expect(channel?.mode).toBe("channel");
expect(hasPresenceOfMind()).toBe(true);
useCombatStore.getState().tick(0.1, channel!.completesAt);
expect(hasPresenceOfMind()).toBe(true);
const fireball = useCombatStore.getState().castAbility(runtimeAbilityId("Fireball"), undefined, 10_000);
expect(fireball.ok, JSON.stringify(fireball)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(hasPresenceOfMind()).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === runtimeAbilityId("Fireball")
))).toBe(true);
});
it("uses Arcane Blast child 36032 stacking values and consumes its aura after another Arcane damage cast", () => {
const arcaneBlastDefinition = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Blast");
const arcaneBlastAura = arcaneBlastDefinition?.effects.find((effect) => (
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("wow335-arcane-blast-stack")
));
expect(arcaneBlastAura).toMatchObject({
kind: "apply-aura",
recipient: "caster",
aura: {
disposition: "buff",
durationMs: 10_000,
maxStacks: 3,
stackBehavior: "add",
modifiers: [
{ kind: "damage", direction: "dealt", school: "arcane", operation: "percent", value: 0.15, perStack: true },
{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 2, perStack: true },
],
},
});
useCombatStore.setState((state) => ({
resource: 100_000,
maxResource: 100_000,
resourcePools: { ...state.resourcePools, mana: 100_000 },
maxResourcePools: { ...state.maxResourcePools, mana: 100_000 },
}));
const arcaneBlastId = runtimeAbilityId("Arcane Blast");
const arcaneBlast = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneBlastId)!;
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const baseArcaneBlastCost = resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount;
const baseFireballCost = resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount;
const castArcaneBlastAndFinish = (startedAt: number) => {
const result = useCombatStore.getState().castAbility(arcaneBlastId, undefined, startedAt);
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(arcaneBlastId);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
};
for (const startedAt of [1_000, 4_000, 7_000, 10_000]) castArcaneBlastAndFinish(startedAt);
const currentAura = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-arcane-blast-stack")
));
expect(currentAura()?.stacks).toBe(3);
expect(resolveDamageValue(100, [currentAura()!], {
direction: "dealt", school: "arcane", attackKind: "spell",
})).toBe(145);
expect(resolveDamageValue(100, [currentAura()!], {
direction: "dealt", school: "fire", attackKind: "spell",
})).toBe(100);
expect(resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount)
.toBe(Math.round(baseArcaneBlastCost * 7));
expect(resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount).toBe(baseFireballCost);
const fireballResult = useCombatStore.getState().castAbility(fireball.id, undefined, 13_000);
expect(fireballResult.ok, JSON.stringify(fireballResult)).toBe(true);
const fireballCast = useCombatStore.getState().activeCast;
expect(fireballCast?.abilityId).toBe(fireball.id);
useCombatStore.getState().tick(0.1, fireballCast!.completesAt);
expect(currentAura()?.stacks).toBe(3);
const arcaneBarrageId = unlockTalentAbility("Arcane Barrage");
const barrageResult = useCombatStore.getState().castAbility(arcaneBarrageId, undefined, 17_000);
expect(barrageResult.ok, JSON.stringify(barrageResult)).toBe(true);
expect(currentAura()).toBeUndefined();
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === arcaneBarrageId
))).toBe(true);
});
it("applies Improved Scorch's rank-three 30-second crit-vulnerability debuff", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-25": 3 },
}));
const scorch = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorch")!;
useCombatStore.getState().damageMob("cold-snap-target", 100, 5_000, undefined, "fire", {
attackKind: "spell",
seed: "mage-scorch-vulnerability",
abilityId: scorch.id,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
const vulnerability = useCombatStore.getState().auras.find((aura) => (
aura.targetId === "cold-snap-target"
&& aura.definition.utilityTags?.includes(WOW335_MAGE_IMPROVED_SCORCH_TAG)
));
expect(vulnerability?.expiresAt).toBe(35_000);
});
it("grants two 15-second Fingers of Frost charges and consumes one per successful Mage spell", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1853": 2 },
}));
const deepFreezeId = unlockTalentAbility("Deep Freeze");
expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!;
let procAt = 0;
for (let index = 0; index < 100; index += 1) {
procAt = 2_000 + index;
useCombatStore.getState().damageMob("cold-snap-target", 100, procAt, undefined, "frost", {
attackKind: "spell",
seed: `mage-fingers-of-frost-${index}`,
abilityId: frostbolt.id,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
if (useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)
))) break;
}
const fingers = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)
));
expect(fingers()?.stacks).toBe(2);
expect(fingers()?.expiresAt).toBe(procAt + 15_000);
const fireballId = runtimeAbilityId("Fireball");
expect(useCombatStore.getState().castAbility(fireballId, undefined, procAt + 1_000))
.toMatchObject({ ok: true });
const fireballCast = useCombatStore.getState().activeCast;
expect(fireballCast).not.toBeNull();
useCombatStore.getState().tick(0.1, fireballCast!.completesAt);
expect(fingers()?.stacks).toBe(1);
expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, fireballCast!.completesAt + 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "stun"))
.toBe(true);
expect(fingers()).toBeUndefined();
});
it("procs Hot Streak from two consecutive eligible fire spell crits and empowers one Pyroblast", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1850": 3 },
}));
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const pyroblastId = unlockTalentAbility("Pyroblast");
const pyroblast = useCombatStore.getState().abilities.find((ability) => ability.id === pyroblastId)!;
const rankedPyroblast = abilityAtLevel(pyroblast, useCombatStore.getState().level, 1);
const baseCost = resolvedAbilityCosts(rankedPyroblast, [], useCombatStore.getState())[0]!.amount;
const forceCrit = (now: number) => useCombatStore.getState().damageMob(
"cold-snap-target",
100,
now,
{
actorId: PLAYER_AURA_ENTITY_ID,
role: usePartyStore.getState().playerRole,
abilityId: fireball.id,
threatScale: 1,
},
"fire",
{
attackKind: "spell",
seed: `mage-hot-streak-${now}`,
abilityId: fireball.id,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
},
);
expect(forceCrit(1_000).combatResult?.critical).toBe(true);
expect(forceCrit(2_000).combatResult?.critical).toBe(true);
const stateWithHotStreak = useCombatStore.getState();
const hotStreak = stateWithHotStreak.auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)
));
expect(hotStreak?.expiresAt).toBe(12_000);
expect(resolvedAbilityCosts(rankedPyroblast, stateWithHotStreak.talentModifiers, stateWithHotStreak)[0]!.amount)
.toBe(Math.round(baseCost * 0.5));
const manaBefore = stateWithHotStreak.resourcePools.mana!;
const result = stateWithHotStreak.castAbility(pyroblast.id, undefined, 3_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(manaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(baseCost * 0.5));
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)
))).toBe(false);
});
it("lets Mage Frostbite freeze a target after landed Frost spell damage at its rank chance", () => {
const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!;
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const applySpellDamage = (abilityId: string, school: "fire" | "frost", now: number) => (
useCombatStore.getState().damageMob(
"cold-snap-target",
100,
now,
undefined,
school,
{ attackKind: "spell", seed: `mage-frostbite-${abilityId}-${now}`, abilityId, periodic: false },
)
);
applySpellDamage(frostbolt.id, "frost", 1_000);
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze"))
.toBe(false);
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-38": 3 } }));
for (let index = 0; index < 20; index += 1) {
applySpellDamage(fireball.id, "fire", 2_000 + index);
}
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze"))
.toBe(false);
let procAt = 0;
for (let index = 0; index < 200; index += 1) {
procAt = 3_000 + index;
applySpellDamage(frostbolt.id, "frost", procAt);
if (useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze")) break;
}
const freeze = useCombatStore.getState().mobs["cold-snap-target"]?.statuses.find((status) => (
status.kind === "freeze" && status.sourceAbilityId === frostbolt.id
));
expect(freeze?.endsAt).toBe(procAt + 4_000);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { AuraDefinition } from "./combatAuras";
export const WOW335_MAGE_HOT_STREAK_TAG = "wow335-mage-hot-streak";
export const WOW335_MAGE_FINGERS_OF_FROST_TAG = "wow335-mage-fingers-of-frost";
export const WOW335_MAGE_IMPROVED_SCORCH_TAG = "wow335-mage-improved-scorch";
export const WOW335_MAGE_IMPACT_TAG = "wow335-mage-impact-next-fire-blast";
export const WOW335_MAGE_FOCUS_MAGIC_TAG = "wow335-mage-focus-magic-proc";
export const WOW335_MAGE_HOT_STREAK_AURA: AuraDefinition = {
id: "wow335:mage:hot-streak",
name: "Hot Streak",
description: "Your next Pyroblast within 10 seconds is instant, deals 40% more damage, and costs 50% less mana.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [WOW335_MAGE_HOT_STREAK_TAG],
};
export const WOW335_MAGE_FINGERS_OF_FROST_AURA: AuraDefinition = {
id: "wow335:mage:fingers-of-frost",
name: "Fingers of Frost",
description: "Your next two Mage spells treat their target as Frozen for 15 seconds.",
disposition: "buff",
durationMs: 15_000,
maxStacks: 2,
initialStacks: 2,
stackBehavior: "replace",
utilityTags: [WOW335_MAGE_FINGERS_OF_FROST_TAG],
};
export const WOW335_MAGE_IMPROVED_SCORCH_AURA: AuraDefinition = {
id: "wow335:mage:improved-scorch",
name: "Improved Scorch",
description: "Increases spell critical strike chance against this target by 5% for 30 seconds.",
disposition: "debuff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
uniqueBySource: false,
utilityTags: [WOW335_MAGE_IMPROVED_SCORCH_TAG],
};
export const WOW335_MAGE_IMPACT_AURA: AuraDefinition = {
id: "wow335:mage:impact",
name: "Impact",
description: "Your next Fire Blast stuns its target for 3 seconds.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [WOW335_MAGE_IMPACT_TAG],
};
export const WOW335_MAGE_FOCUS_MAGIC_PROC_AURA: AuraDefinition = {
id: "wow335:mage:focus-magic-proc",
name: "Focus Magic",
description: "Your spell critical strike chance is increased by 3% for 10 seconds.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 3 }],
utilityTags: [WOW335_MAGE_FOCUS_MAGIC_TAG],
};
+552
View File
@@ -0,0 +1,552 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { applyAura } from "./combatAuras";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["paladin"]);
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-paladin-runtime-tests", 80, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
});
it("blocks silence with Aura Mastery for its sourced 10-second window, then allows silence after expiry", () => {
const mobId = "paladin-aura-mastery-silence";
useCombatStore.getState().registerMob(mobId, {
name: "Silence attacker",
maxHealth: 10_000,
attacks: [{
id: "silence",
name: "Silence",
delivery: "area",
target: "primary",
school: "shadow",
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 10_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 0,
effect: { kind: "control", mechanic: "silence", durationMs: 3_000 },
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Aura Mastery"), undefined, 1_000);
expect(activate).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.controlImmunities?.includes("silence")
&& aura.expiresAt === 11_000
))).toBe(true);
const partyMember = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === partyMember.id
&& aura.definition.controlImmunities?.includes("silence")
&& aura.expiresAt === 11_000
))).toBe(true);
useCombatStore.getState().advanceMobCombat(1_450);
expect(useCombatStore.getState().controlMechanic).toBeNull();
expect(useCombatStore.getState().schoolLockedUntil).toEqual({});
useCombatStore.getState().tick(0.1, 11_001);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.controlImmunities?.includes("silence")
))).toBe(false);
useCombatStore.getState().advanceMobCombat(11_450);
expect(useCombatStore.getState().controlMechanic).toBe("silence");
expect(useCombatStore.getState().controlledUntil).toBe(14_450);
});
it("lets Redemption target and revive a defeated party member at the rank's source health", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 0, status: "down" as const }
: candidate),
}));
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.getState().selectMember(member.id);
const cast = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
expect(cast).toMatchObject({ ok: true });
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.targetId).toBe(member.id);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(1_760);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "resurrection" && event.targetActorId === member.id
))).toBe(true);
});
it("blocks Redemption while any living enemy is engaged", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 0, status: "down" as const }
: candidate),
}));
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.getState().selectMember(member.id);
const targetId = "paladin-redemption-combat-gate";
useCombatStore.getState().registerMob(targetId, { name: "Engaged enemy", maxHealth: 10_000 });
setMobPosition(targetId, [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, engaged: true } },
}));
const result = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().activeCast).toBeNull();
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(0);
});
it("enforces Hammer of Wrath's 20% execute gate", () => {
const targetId = "paladin-hammer-gate-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 10_000 });
setMobPosition(targetId, [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, health: 2_001 } },
selectedTargetId: targetId,
}));
const result = useCombatStore.getState().castAbility(abilityId("Hammer of Wrath"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("requires a shield before Holy Shield can be activated", () => {
useCombatStore.setState({ equipment: {}, inventory: [] });
expect(useCombatStore.getState().abilities.find((candidate) => candidate.name === "Holy Shield")?.target).toBe("self");
const result = useCombatStore.getState().castAbility(unlockTalentAbility("Holy Shield"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("procs three 8-second Reckoning charges from melee hits and adds one landed autoattack per charge", () => {
setTalentRank("Reckoning", 5);
const mobId = "paladin-reckoning-trigger";
useCombatStore.getState().registerMob(mobId, {
name: "Reckoning attacker",
level: 80,
maxHealth: 100_000,
attacks: [{
id: "reckoning-melee",
name: "Melee attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 8,
cooldownMs: 500,
initialCooldownMs: [0, 0],
repeatCooldownMs: [500, 500],
damageMultiplier: 0.1,
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const ensureMeleeHit = applyAura(useCombatStore.getState().auras, {
definition: {
id: "test:ensure-reckoning-melee-hit",
name: "Ensure incoming melee hit",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }],
},
sourceId: mobId,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
}).auras;
useCombatStore.setState((state) => ({ auras: [...ensureMeleeHit] }));
let procAt: number | null = null;
for (let now = 1_450; now <= 30_000; now += 250) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
))) {
procAt = now;
break;
}
}
expect(procAt).not.toBeNull();
const reckoning = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
));
expect(reckoning).toMatchObject({ stacks: 3, expiresAt: procAt! + 8_000 });
useCombatStore.setState((state) => ({
autoAttackTargetId: mobId,
nextAutoAttackAt: procAt! + 100,
gearStats: { ...state.gearStats, hitRating: 1_000_000, expertiseRating: 1_000_000 },
}));
let autoAttackAt = procAt! + 100;
for (let charge = 2; charge >= 0; charge -= 1) {
useCombatStore.getState().tick(0.1, autoAttackAt);
const swingEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID
&& event.targetActorId === mobId
&& event.occurredAt === autoAttackAt
));
expect(swingEvents).toHaveLength(2);
const remaining = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
));
if (charge > 0) expect(remaining?.stacks).toBe(charge);
else expect(remaining).toBeUndefined();
autoAttackAt = useCombatStore.getState().nextAutoAttackAt;
}
});
it("reflects the sourced rank percentage of incoming critical damage with Eye for an Eye", () => {
const mobId = "paladin-eye-for-an-eye-crit";
useCombatStore.getState().registerMob(mobId, {
name: "Critical attacker",
level: 80,
maxHealth: 100_000,
damageMultiplier: 1,
attacks: [{
id: "eye-crit-18",
name: "Critical attack",
delivery: "area",
target: "primary",
school: "fire",
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 10_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 1,
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const ensureSpellHit = applyAura([], {
definition: {
id: "test:ensure-incoming-spell-hit",
name: "Ensure incoming spell hit",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 }],
},
sourceId: mobId,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
}).auras;
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1632": 3 },
auras: [...state.auras, ...ensureSpellHit],
}));
// This seeded instant spell attack rolls inside the crit slice of the
// equal-level attack table, so the test does not rely on chance.
useCombatStore.getState().advanceMobCombat(1_450);
useCombatStore.getState().advanceMobCombat(3_100);
const events = useCombatStore.getState().combatEvents;
const incoming = events.find((event) => event.kind === "damage"
&& event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID);
expect(incoming?.critical, JSON.stringify(incoming)).toBe(true);
const reflected = events.find((event) => event.kind === "damage"
&& event.targetActorId === mobId && event.abilityId === "talent-1632");
expect(reflected?.rawAmount).toBe(Math.round((incoming?.effectiveAmount ?? 0) * 0.15));
});
it("bounces Avenger's Shield to three total targets without damage falloff", () => {
const shieldId = unlockTalentAbility("Avenger's Shield");
const targetIds = [
"paladin-avenger-target",
"paladin-avenger-bounce-a",
"paladin-avenger-bounce-b",
"paladin-avenger-bounce-c",
"paladin-avenger-out-of-range",
];
for (const id of targetIds) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
}
setMobPosition(targetIds[0]!, [4, 0, 0]);
setMobPosition(targetIds[1]!, [10, 0, 0]);
setMobPosition(targetIds[2]!, [16, 0, 0]);
setMobPosition(targetIds[3]!, [22, 0, 0]);
setMobPosition(targetIds[4]!, [10, 0, 12]);
useCombatStore.getState().selectMob(targetIds[0]!);
const result = useCombatStore.getState().castAbility(shieldId, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(result.affectedMobIds).toEqual(targetIds.slice(0, 3));
expect(result.affectedMobIds).not.toContain(targetIds[3]);
expect(result.affectedMobIds).not.toContain(targetIds[4]);
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === shieldId
&& result.affectedMobIds.includes(event.targetActorId)
));
expect(damageEvents).toHaveLength(3);
const normalizedRawDamage = damageEvents.map((event) => Math.round(event.rawAmount / (event.critical ? 2 : 1)));
expect(new Set(normalizedRawDamage).size).toBe(1);
});
it("applies a Greater Blessing to the selected ally's class group", () => {
const members = usePartyStore.getState().members;
const target = members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate, index) => ({
...candidate,
classId: index < 2 ? "mage" : "priest",
})),
}));
for (const member of members) updatePartyRuntimePosition(member.id, 1, 0, 0);
usePartyStore.getState().selectMember(target.id);
const result = useCombatStore.getState().castAbility(abilityId("Greater Blessing of Kings"), undefined, 1_000);
expect(result).toMatchObject({ ok: true });
const blessedIds = new Set(useCombatStore.getState().auras
.filter((aura) => aura.definition.name.includes("Greater Blessing of Kings"))
.map((aura) => aura.targetId));
expect(blessedIds).toEqual(new Set([target.id, members[1]!.id]));
expect(blessedIds.has(PLAYER_AURA_ENTITY_ID)).toBe(false);
});
it("heals at most three party actors from Divine Storm damage", () => {
const members = usePartyStore.getState().members;
usePartyStore.setState((party) => ({
members: party.members.map((member) => ({ ...member, health: 1 })),
}));
members.forEach((member, index) => updatePartyRuntimePosition(member.id, index + 1, 0, 0));
useCombatStore.setState((state) => ({ health: Math.max(1, Math.round(state.maxHealth * 0.1)) }));
const targetId = "paladin-divine-storm-target";
const nearbyTargetId = "paladin-divine-storm-nearby-target";
const distantTargetId = "paladin-divine-storm-distant-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 });
useCombatStore.getState().registerMob(nearbyTargetId, { name: "Nearby Target", maxHealth: 100_000 });
useCombatStore.getState().registerMob(distantTargetId, { name: "Distant Target", maxHealth: 100_000 });
setMobPosition(targetId, [4, 0, 0]);
setMobPosition(nearbyTargetId, [7, 0, 0]);
setMobPosition(distantTargetId, [13, 0, 0]);
useCombatStore.setState((state) => ({ selectedTargetId: targetId }));
const divineStorm = unlockTalentAbility("Divine Storm");
const result = useCombatStore.getState().castAbility(divineStorm, { targetActorId: targetId, now: 1_000 });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(result.affectedMobIds).toEqual(expect.arrayContaining([targetId, nearbyTargetId]));
expect(result.affectedMobIds).not.toContain(distantTargetId);
const healEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === divineStorm && event.effectiveAmount > 0
));
expect(healEvents.length).toBeGreaterThan(0);
expect(healEvents.length).toBeLessThanOrEqual(3);
expect(healEvents.every((event) => event.rawAmount > 0)).toBe(true);
});
it("transfers direct healing to the active Beacon of Light target", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 100 }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const beaconResult = useCombatStore.getState().castAbility(unlockTalentAbility("Beacon of Light"), undefined, 1_000);
expect(beaconResult).toMatchObject({ ok: true });
const beaconCast = useCombatStore.getState().activeCast;
if (beaconCast) useCombatStore.getState().tick(0.1, beaconCast.completesAt);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("beacon-of-light")
))).toBe(true);
useCombatStore.getState().damagePlayer(5_000);
const beforeTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
const heal = useCombatStore.getState().castAbility(abilityId("Flash of Light"), undefined, 3_000);
expect(heal).toMatchObject({ ok: true });
const healCast = useCombatStore.getState().activeCast;
if (healCast) useCombatStore.getState().tick(0.1, healCast.completesAt);
const afterTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
expect(afterTransfer).toBeGreaterThan(beforeTransfer);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("beacon-of-light"))).toBe(true);
});
it("limits Divine Sacrifice to the remaining portion of its shared damage cap", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 0, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: candidate.maxHealth }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor?.definition.redirectDamageCapPercent).toBe(1.5);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
))).toBe(true);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
? { ...aura, redirectedDamage: cap - 20 }
: aura),
}));
const mobId = "paladin-divine-sacrifice-partial";
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
useCombatStore.getState().advanceMobCombat(1_450);
useCombatStore.getState().advanceMobCombat(3_350);
expect(playerBefore - useCombatStore.getState().health).toBe(20);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
});
it("stops Divine Sacrifice redirects after the shared damage cap is exhausted", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 0, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: candidate.maxHealth }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor).toBeDefined();
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
? { ...aura, redirectedDamage: cap }
: aura),
}));
const mobId = "paladin-divine-sacrifice-exhausted";
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
useCombatStore.getState().advanceMobCombat(1_450);
const resolvedDamage = useCombatStore.getState().advanceMobCombat(3_350);
expect(resolvedDamage, JSON.stringify(useCombatStore.getState().mobs[mobId])).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBe(playerBefore);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
});
it("does not redirect damage from an attack that targets the Paladin", () => {
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor).toBeDefined();
const mobId = "paladin-divine-sacrifice-self-hit";
useCombatStore.getState().registerMob(mobId, { name: "Self hit attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeLessThan(playerBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(0);
});
it("makes the next eligible Divine Favor heal critical and consumes its aura", () => {
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.getState().damagePlayer(Math.round(maxHealth * 0.4));
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Divine Favor"), undefined, 1_000);
expect(activate).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
))).toBe(true);
const flashOfLight = abilityId("Flash of Light");
const heal = useCombatStore.getState().castAbility(flashOfLight, undefined, 3_000);
expect(heal).toMatchObject({ ok: true });
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === flashOfLight && event.critical
))).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
))).toBe(false);
});
});
+672
View File
@@ -0,0 +1,672 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityAtLevel, abilitiesForClass, abilityTalentNodeId } from "./abilityCatalog";
import { resolvedAbilityCosts, useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { TALENT_NODES } from "./talentCatalog";
import { talentModifiersForRanks } from "./talentModifiers";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
function priestAbility(name: string) {
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!;
}
function unlock(name: string): void {
const nodeId = abilityTalentNodeId(priestAbility(name));
expect(nodeId, `${name} talent node`).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
}
function setPriestTalentRanks(ranks: Record<string, number>, criticalChance?: number): void {
const modifiers = talentModifiersForRanks(ranks, "priest");
useCombatStore.setState({
talentRanks: ranks,
talentModifiers: criticalChance === undefined ? modifiers : [...modifiers, {
kind: "critical-chance-percent",
amount: criticalChance,
unit: "percent",
scope: "your spells",
sourceTalentId: "test:priest-critical-chance",
sourceTalentName: "Test critical chance",
rank: 1,
description: "Test-only critical chance modifier.",
}],
});
}
function setMemberHealth(memberId: string, health: number, maxHealth: number): void {
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === memberId
? { ...member, health, maxHealth }
: member),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[memberId]: { ...state.actors[memberId]!, health, maxHealth, alive: health > 0 },
},
}));
}
function castFriendly(name: string, targetId: string, now: number) {
useCombatStore.getState().selectFriendlyActor(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
function castHostile(name: string, targetId: string, now: number) {
useCombatStore.getState().selectMob(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
describe("WoW Priest mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-priest-mechanics", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, [index + 2, 0, 0]);
});
});
it("uses self targeting for Desperate Prayer and dual relationship for Dispel Magic", () => {
const desperatePrayer = priestAbility("Desperate Prayer");
const dispelMagic = priestAbility("Dispel Magic");
const rank988 = abilityAtLevel(dispelMagic, 38);
expect(desperatePrayer.target).toBe("self");
expect(dispelMagic.target).toBe("any");
expect(rank988.dbcSpellId).toBe(988);
expect(rank988.effects).toContainEqual(expect.objectContaining({
kind: "dispel", mode: "cleanse", relationship: "target", categories: ["magic"], maxCount: 2,
}));
});
it("lets Dispel Magic rank 988 cleanse a party member's magic debuff", () => {
const member = usePartyStore.getState().members[0]!;
const carrier = priestAbility("Prayer of Fortitude");
expect(useCombatStore.getState().applyActorAura(PLAYER_AGGRO_ID, member.id, carrier, {
kind: "apply-aura",
aura: {
id: "test:priest-magic-debuff", name: "Test magic debuff", disposition: "debuff",
dispelCategory: "magic", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh",
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}, 1_000)).toBe(true);
expect(useCombatStore.getState().selectFriendlyActor(member.id)).toBe(true);
const result = useCombatStore.getState().castAbility(priestAbility("Dispel Magic").id, {
targetActorId: member.id, now: 2_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:priest-magic-debuff")).toBe(false);
});
it("casts Desperate Prayer on the priest and applies Pain Suppression's threat reduction to its ally", () => {
unlock("Desperate Prayer");
useCombatStore.getState().damagePlayer(500);
const playerBefore = useCombatStore.getState().health;
const desperatePrayer = useCombatStore.getState().castAbility(priestAbility("Desperate Prayer").id, [0, 0, 0], 1_000);
expect(desperatePrayer.ok, JSON.stringify(desperatePrayer)).toBe(true);
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
const member = usePartyStore.getState().members[0]!;
unlock("Pain Suppression");
useCombatStore.getState().registerMob("priest-threat-test", { name: "Threat test", maxHealth: 1_000, xpReward: 0 });
setMobPosition("priest-threat-test", [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "priest-threat-test": {
...state.mobs["priest-threat-test"]!, engaged: true,
threatByActor: { [member.id]: 100 }, targetActorId: member.id,
} },
}));
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Pain Suppression").id, {
targetActorId: member.id, now: 3_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().mobs["priest-threat-test"]?.threatByActor[member.id]).toBe(60);
});
it("applies Prayer of Fortitude to the priest and all party members", () => {
const result = useCombatStore.getState().castAbility(priestAbility("Prayer of Fortitude").id, [0, 0, 0], 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const recipients = useCombatStore.getState().auras
.filter((aura) => aura.definition.id.includes(":48162:"))
.map((aura) => aura.targetId);
expect(recipients).toContain(PLAYER_AURA_ENTITY_ID);
for (const member of usePartyStore.getState().members) expect(recipients).toContain(member.id);
});
it("reduces only magic damage while an eligible Test of Faith cast is active", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 1 };
let castId = 0;
const damageTaken = (rank: number, school: "physical" | "shadow", abilityName: string, expired = false): number => {
const now = Date.now();
const maxHealth = useCombatStore.getState().maxHealth;
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
const completesAt = expired ? now - 1 : now + 2_000;
useCombatStore.setState({
health: maxHealth,
shield: 0,
auras: [],
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: ++castId,
abilityId: priestAbility(abilityName).id,
targetId: null,
mode: "cast",
startedAt: now - 500,
completesAt,
originalDurationMs: 2_500,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: completesAt,
},
});
useCombatStore.getState().damagePlayer(1_000, school);
return maxHealth - useCombatStore.getState().health;
};
const baselineMagic = damageTaken(0, "shadow", "Flash Heal");
expect(damageTaken(1, "shadow", "Flash Heal")).toBe(Math.round(baselineMagic * 0.9));
expect(damageTaken(1, "physical", "Flash Heal")).toBe(damageTaken(0, "physical", "Flash Heal"));
expect(damageTaken(1, "shadow", "Prayer of Fortitude")).toBe(baselineMagic);
expect(damageTaken(1, "shadow", "Flash Heal", true)).toBe(baselineMagic);
expect(ranks[talent!.id]).toBe(1);
});
it("applies Test of Faith to magic hits resolved through enemy combat", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const incomingHit = (rank: number): number => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
useCombatStore.setState({
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: 1,
abilityId: priestAbility("Flash Heal").id,
targetId: null,
mode: "cast",
startedAt: 1_000,
completesAt: 8_000,
originalDurationMs: 7_000,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: 8_000,
},
});
const mobId = "priest-test-faith-magic-hit";
useCombatStore.getState().registerMob(mobId, {
name: "Test of Faith magic hit",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: "priest-test-shadow-cast",
name: "Shadow cast",
delivery: "projectile",
animation: "cast",
school: "shadow",
range: 30,
castTimeMs: 0,
}],
});
setMobPosition(mobId, [8, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const before = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
return before - useCombatStore.getState().health;
};
const baseline = incomingHit(0);
expect(baseline).toBeGreaterThan(0);
expect(incomingHit(1)).toBe(Math.round(baseline * 0.9));
});
it("applies Test of Faith only to spells whose target is at or below half health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 3 };
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "healing-percent", sourceTalentName: "Test of Faith",
}));
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "damage-percent", sourceTalentName: "Test of Faith",
}));
const member = usePartyStore.getState().members[0]!;
const prepareTarget = (health: number) => {
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health, maxHealth: 20_000 }
: candidate),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[member.id]: { ...state.actors[member.id]!, health, maxHealth: 20_000, alive: true },
},
}));
};
const castFlashHeal = (health: number, now: number, withTalent: boolean) => {
prepareTarget(health);
useCombatStore.setState({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
});
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Flash Heal").id, {
targetActorId: member.id, now,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "healing"
&& candidate.abilityId === priestAbility("Flash Heal").id
&& candidate.targetActorId === member.id
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const base = castFlashHeal(9_000, 1_000, false);
const boosted = castFlashHeal(9_000, 4_000, true);
const aboveThreshold = castFlashHeal(11_000, 7_000, true);
expect(boosted).toBeCloseTo(base * 1.12, 0);
expect(aboveThreshold).toBeCloseTo(base, 0);
const smite = priestAbility("Smite");
useCombatStore.getState().registerMob("priest-test-faith-damage", {
name: "Test of Faith target", maxHealth: 20_000, xpReward: 0,
});
setMobPosition("priest-test-faith-damage", [4, 0, 0]);
const castSmite = (health: number, now: number, withTalent: boolean) => {
useCombatStore.setState((state) => ({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
mobs: {
...state.mobs,
"priest-test-faith-damage": {
...state.mobs["priest-test-faith-damage"]!, health, maxHealth: 20_000, dead: false,
},
},
}));
useCombatStore.getState().selectMob("priest-test-faith-damage");
const result = useCombatStore.getState().castAbility(smite.id, [0, 0, 0], now);
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "damage"
&& candidate.abilityId === smite.id
&& candidate.targetActorId === "priest-test-faith-damage"
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const baseDamage = castSmite(9_000, 10_000, false);
const lowHealthDamage = castSmite(9_000, 13_000, true);
const healthyDamage = castSmite(11_000, 16_000, true);
expect(lowHealthDamage).toBeCloseTo(baseDamage * 1.12, 0);
expect(healthyDamage).toBeCloseTo(baseDamage, 0);
});
it("keeps proc-based Priest talent values out of passive talent modifiers", () => {
const procTalentNames = ["Holy Concentration", "Serendipity", "Shadow Weaving"];
const ranks = Object.fromEntries(procTalentNames.map((name) => {
const talent = TALENT_NODES.find((node) => node.name === name);
expect(talent, name).toBeDefined();
return [talent!.id, talent!.maxRank];
}));
const modifiers = talentModifiersForRanks(ranks, "priest");
for (const name of procTalentNames) {
expect(modifiers.filter((modifier) => modifier.sourceTalentName === name), name).toEqual([]);
}
});
it("limits Inner Focus to its three named spells and empowers Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus");
expect(talent).toBeDefined();
setPriestTalentRanks({ [talent!.id]: 1 }, 75);
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-damage", {
name: "Inner Focus target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-damage", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(priestAbility("Smite").id, {
targetActorId: "priest-inner-focus-damage", now: 1_001,
})).toMatchObject({ ok: false, reason: "not-enough-resource" });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(true);
const manaSpentAt = useCombatStore.getState().lastResourceSpentAt.mana;
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, member.maxHealth);
useCombatStore.getState().selectFriendlyActor(member.id);
const greaterHeal = priestAbility("Greater Heal");
const cast = useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: 1_002,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const activeCast = useCombatStore.getState().activeCast!;
expect(activeCast.completesAt - activeCast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.5));
useCombatStore.getState().tick((activeCast.completesAt - activeCast.startedAt) / 1_000, activeCast.completesAt);
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(manaSpentAt);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(false);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === greaterHeal.id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
});
it("gives Inner Focus's Mind Blast rider and reduces Power Word: Shield's Weakened Soul", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
setPriestTalentRanks({ [talent.id]: 1 });
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-mind-blast", {
name: "Inner Focus Mind Blast target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-mind-blast", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
const mindBlast = priestAbility("Mind Blast");
expect(useCombatStore.getState().castAbility(mindBlast.id, {
targetActorId: "priest-inner-focus-mind-blast", now: 1_001,
}).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt).toBe(1_001 + mindBlast.castTimeMs / 2);
useCombatStore.getState().tick(mindBlast.castTimeMs / 2_000, 1_001 + mindBlast.castTimeMs / 2);
expect(useCombatStore.getState().mobs["priest-inner-focus-mind-blast"]?.statuses).toContainEqual(expect.objectContaining({
kind: "slow",
magnitude: 0.7,
endsAt: 1_001 + mindBlast.castTimeMs / 2 + 5_000,
}));
const member = usePartyStore.getState().members[0]!;
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [priestAbility("Inner Focus").id]: 0 },
}));
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 8_000).ok).toBe(true);
expect(castFriendly("Power Word: Shield", member.id, 8_001).ok).toBe(true);
const weakenedSoul = useCombatStore.getState().auras.find((aura) => (
aura.targetId === member.id && aura.definition.id === "wow335:priest:weakened-soul"
));
expect(weakenedSoul?.expiresAt).toBe(28_001);
});
it.each([
{ rank: 1, percent: 0.04 },
{ rank: 2, percent: 0.07 },
{ rank: 3, percent: 0.1 },
])("grants Divine Aegis rank $rank from a critical Holy heal, including reduced overheal", ({ rank, percent }) => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
const ranks = { [talent.id]: rank };
setPriestTalentRanks(ranks, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
const result = castFriendly("Flash Heal", member.id, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === priestAbility("Flash Heal").id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
const expected = Math.min(2_000, Math.round((heal.effectiveAmount + heal.overheal * 0.25) * percent * 2));
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBe(expected);
expect(aegis?.expiresAt).toBe(heal.occurredAt + 6_000);
});
it("caps a target's Divine Aegis absorb at ten percent maximum health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 500);
expect(castFriendly("Flash Heal", member.id, 1_000).ok).toBe(true);
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeLessThanOrEqual(50);
});
it("makes Surge of Light's next Smite instant, free, stronger, and unable to crit", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-test", { name: "Surge target", maxHealth: 200_000, xpReward: 0 });
setMobPosition("priest-surge-test", [4, 0, 0]);
setPriestTalentRanks({}, -100);
expect(castHostile("Smite", "priest-surge-test", 1_000).ok).toBe(true);
const baseline = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(baseline.critical).toBe(false);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
const member = usePartyStore.getState().members[0]!;
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 6_000 + attempt * 4_000;
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Flash Heal").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const manaBefore = useCombatStore.getState().resource;
expect(castHostile("Smite", "priest-surge-test", procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
const surged = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(surged.critical).toBe(false);
expect(surged.rawAmount).toBe(Math.round(baseline.rawAmount * 1.6));
});
it("also triggers Surge of Light from critical direct damage and spends it on Flash Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-crit-test", {
name: "Surge critical target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("priest-surge-crit-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 1_000 + attempt * 4_000;
expect(castHostile("Smite", "priest-surge-crit-test", now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Smite").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
setPriestTalentRanks({}, 100);
const manaBefore = useCombatStore.getState().resource;
expect(castFriendly("Flash Heal", member.id, procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))).toBe(false);
});
it("stacks Serendipity to three charges and consumes all charges on Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Serendipity")!;
const ranks = { [talent.id]: 3 };
setPriestTalentRanks(ranks);
const member = usePartyStore.getState().members[0]!;
for (const now of [1_000, 5_000, 9_000]) {
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
}
const procAura = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"));
expect(procAura?.stacks).toBe(3);
const greaterHeal = abilityAtLevel(priestAbility("Greater Heal"), 80);
useCombatStore.setState((state) => ({
resource: 5_000,
maxResource: 5_000,
resourcePools: { ...state.resourcePools, mana: 5_000 },
maxResourcePools: { ...state.maxResourcePools, mana: 5_000 },
gearStats: { ...state.gearStats, spirit: 0, mp5: 0 },
}));
const baseCost = resolvedAbilityCosts(greaterHeal, [], useCombatStore.getState())
.find((cost) => cost.resource === "mana")!.amount;
const manaBefore = useCombatStore.getState().resource;
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(greaterHeal.id, { targetActorId: member.id, now: 14_000 });
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast!;
expect(cast.completesAt - cast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.55));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))?.stacks).toBe(3);
useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: cast.completesAt, completingCast: cast,
});
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))).toBe(false);
expect(manaBefore - useCombatStore.getState().resource).toBe(Math.round(baseCost * 0.55));
});
it("adds Shadow Weaving on Shadow damage and increases later ticks up to five stacks", () => {
const talent = TALENT_NODES.find((node) => node.name === "Shadow Weaving")!;
useCombatStore.getState().registerMob("priest-shadow-weaving-test", {
name: "Shadow Weaving target", maxHealth: 200_000, xpReward: 0,
});
setMobPosition("priest-shadow-weaving-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 3 }, -100);
expect(castHostile("Shadow Word: Pain", "priest-shadow-weaving-test", 1_000).ok).toBe(true);
useCombatStore.getState().tick(18, 19_000);
const ticks = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Shadow Word: Pain").id
&& event.targetActorId === "priest-shadow-weaving-test"
));
expect(ticks.length).toBeGreaterThanOrEqual(2);
expect(ticks[1]!.rawAmount).toBe(Math.round(ticks[0]!.rawAmount * 1.02));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-shadow-weaving"))?.stacks).toBe(5);
});
it("procs Holy Concentration on a critical heal and extends nearby Renew by at most six seconds", () => {
const talent = TALENT_NODES.find((node) => node.name === "Holy Concentration")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const [first, second] = usePartyStore.getState().members;
expect(castFriendly("Renew", first!.id, 1_000).ok).toBe(true);
expect(castFriendly("Renew", second!.id, 4_000).ok).toBe(true);
const beforeExtensions = new Map(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id)
.map((effect) => [effect.targetId ?? PLAYER_AURA_ENTITY_ID,
effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs]));
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 7_000).ok).toBe(true);
const concentration = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-holy-concentration"));
expect(concentration?.expiresAt).toBeGreaterThan(0);
expect(concentration?.definition.modifiers).toContainEqual(expect.objectContaining({
kind: "stat", stat: "spirit", operation: "percent", value: 0.3,
}));
let renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) {
const target = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
expect(effect.extensionTicks).toBe(1);
expect(effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs).toBe(beforeExtensions.get(target)! + 3_000);
}
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 11_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 15_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
});
it("gives the three Prayer buffs party scope and Pain Suppression its target threat reduction", () => {
for (const name of ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"]) {
const rank = abilityAtLevel(priestAbility(name), 80);
expect(rank.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"), name).toBe(true);
}
const painSuppression = abilityAtLevel(priestAbility("Pain Suppression"), 80);
expect(painSuppression.effects).toContainEqual({
kind: "threat", mode: "percent", amount: -40, recipient: "ability-target",
});
});
it("summons a nonattacking Lightwell and heals for six seconds per click", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toMatchObject({ clickCharges: 10, expiresAt: completion! + 180_000, attackDamage: 0 });
useCombatStore.getState().damagePlayer(500);
const healthBefore = useCombatStore.getState().health;
const clickAmount = useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
expect(clickAmount).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBe(healthBefore);
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
kind: "hot",
sourceAbilityId: ability.id,
remainingTicks: 6,
intervalMs: 1_000,
fixedAmount: expect.any(Number),
nextTickAt: completion! + 1_001,
}));
useCombatStore.getState().tick(6, completion! + 6_001);
expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore);
expect(useCombatStore.getState().summons.find((summon) => summon.id === well!.id)?.clickCharges).toBe(9);
for (let click = 1; click < 10; click += 1) {
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1 + click);
}
expect(useCombatStore.getState().summons.some((summon) => summon.id === well!.id)).toBe(false);
expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 20)).toBe(0);
});
});
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityAtLevel, abilitiesForClass } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const PRAYER_RANKS = [
{ level: 68, spellId: 33076, healAmount: 800 },
{ level: 74, spellId: 48112, healAmount: 905 },
{ level: 79, spellId: 48113, healAmount: 1043 },
] as const;
function prayerOfMendingAbility() {
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === "Prayer of Mending");
expect(ability, "Prayer of Mending").toBeDefined();
return ability!;
}
function damageActor(actorId: string, now: number): number {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
for (let attempt = 0; attempt < 12; attempt += 1) {
const mobId = `pom-attacker-${actorId}-${now}-${attempt}`;
const combat = useCombatStore.getState();
combat.registerMob(mobId, { name: "Prayer of Mending attacker", maxHealth: 1_000, xpReward: 0 });
setMobPosition(mobId, [2, 0, 0]);
combat.damageMob(mobId, 1, now, {
actorId,
role: member?.role ?? "healer",
abilityId: "pom-test-threat",
});
useCombatStore.setState((state) => ({
mobs: state.mobs[mobId]
? { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, nextAttackAt: now + 450 } }
: state.mobs,
}));
const damage = useCombatStore.getState().advanceMobCombat(now + 450);
useCombatStore.getState().resetMobForNavigation(mobId, true, now + 450);
if (damage > 0) return damage;
}
throw new Error(`No enemy attack landed on ${actorId} after 12 attempts`);
}
describe("WoW Priest Prayer of Mending", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-prayer-of-mending", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, [index + 1, 0, 0]);
});
});
it("maps every player-facing rank to its DBC base heal and five damage charges", () => {
const base = prayerOfMendingAbility();
for (const expected of PRAYER_RANKS) {
const rank = abilityAtLevel(base, expected.level);
expect(rank.dbcSpellId).toBe(expected.spellId);
expect(rank.effects).toHaveLength(1);
const aura = rank.effects.find((effect) => effect.kind === "apply-aura");
expect(aura?.kind).toBe("apply-aura");
if (aura?.kind !== "apply-aura") continue;
expect(aura.aura.procs?.[0]).toMatchObject({
triggers: "damage-taken",
charges: 5,
action: { kind: "custom", id: "wow335-prayer-of-mending", data: { healAmount: expected.healAmount } },
});
}
});
it("heals the aura holder and transfers through injured allies for five total activations", () => {
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
const initialTarget = members[0]!;
const memberHealth = [1_900, 1_800, 1_700, 1_600];
const injuredMembers = members.map((member, index) => ({
...member,
maxHealth: 2_000,
health: memberHealth[index] ?? 1_500,
}));
usePartyStore.setState({ members: injuredMembers });
useCombatStore.setState((state) => ({
actors: {
...state.actors,
...Object.fromEntries(injuredMembers.flatMap((member) => {
const actor = state.actors[member.id];
return actor ? [[member.id, {
...actor,
health: member.health,
maxHealth: member.maxHealth,
alive: member.health > 0,
}]] : [];
})),
},
}));
useCombatStore.setState((state) => ({ maxHealth: 2_000, health: 1_980 }));
const cast = combat.castAbility(prayerOfMendingAbility().id, {
targetActorId: initialTarget.id,
now: 1_000,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:48113:prayer-of-mending"))
.toMatchObject({ targetId: initialTarget.id, procStates: [{ remainingCharges: 5 }] });
let auraTarget = initialTarget.id;
let now = 2_000;
for (let activation = 1; activation <= 5; activation += 1) {
const previousTarget = auraTarget;
damageActor(previousTarget, now);
now += 1_000;
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === prayerOfMendingAbility().id
));
expect(healingEvents).toHaveLength(activation);
const active = useCombatStore.getState().auras.find((aura) => (
aura.definition.id === "wow335:48113:prayer-of-mending"
));
if (activation === 5) {
expect(active).toBeUndefined();
break;
}
expect(active).toBeDefined();
expect(active?.targetId).not.toBe(previousTarget);
const combatState = useCombatStore.getState();
const injured = [
...(combatState.health > 0 && combatState.health < combatState.maxHealth
? [{ id: PLAYER_AGGRO_ID, health: combatState.health, maxHealth: combatState.maxHealth }]
: []),
...usePartyStore.getState().members.flatMap((member) => {
const actor = combatState.actors[member.id];
return actor && actor.health > 0 && actor.health < actor.maxHealth
? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }]
: [];
}),
].filter((candidate) => candidate.id !== previousTarget)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
expect(active?.targetId).toBe(injured[0]?.id);
auraTarget = active!.targetId;
}
});
it("does not spend a charge for unrelated outgoing damage", () => {
const member = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().castAbility(prayerOfMendingAbility().id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
useCombatStore.getState().registerMob("pom-unrelated", { name: "Unrelated target", maxHealth: 1_000 });
setMobPosition("pom-unrelated", [4, 0, 0]);
useCombatStore.getState().damageMob("pom-unrelated", 1, 2_000);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.definition.id === "wow335:48113:prayer-of-mending"
));
expect(aura).toMatchObject({ targetId: member.id, procStates: [{ remainingCharges: 5 }] });
});
});
+212
View File
@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
describe("WoW Rogue class mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-rogue-mechanics-tests", 1,
useCombatStore.getState().health);
});
const ability = (name: string) => {
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(found, `missing Rogue ability ${name}`).toBeDefined();
return found!;
};
const learnAbilityTalent = (name: string) => {
const nodeId = abilityTalentNodeId(ability(name));
expect(nodeId, `${name} should have a source talent node`).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
};
const addTarget = (id: string, position: readonly [number, number, number] = [3, 0, 0]) => {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
};
it("gates stealth openers and allows Pick Pocket through Shadow Dance", () => {
addTarget("rogue-opener-target");
const backstab = ability("Backstab");
const pocket = ability("Pick Pocket");
expect(useCombatStore.getState().castAbility(backstab.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility(pocket.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
learnAbilityTalent("Shadow Dance");
const dance = ability("Shadow Dance");
expect(useCombatStore.getState().castAbility(dance.id, undefined, 2_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility(pocket.id, undefined, 5_000);
expect(result).toMatchObject({ ok: true, abilityId: pocket.id });
expect(useCombatStore.getState().feedback).toMatchObject({ kind: "loot", message: expect.stringContaining("succeeds") });
});
it.each([["Ambush", 2], ["Garrote", 1], ["Cheap Shot", 2]] as const)(
"grants Initiative's source-ranked extra combo point on %s",
(openerName, baseComboPoints) => {
addTarget("rogue-initiative-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-245": 3 },
}));
expect(useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility(ability(openerName).id, undefined, 5_000);
expect(result).toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(baseComboPoints + 1);
expect(useCombatStore.getState().comboTargetId).toBe("rogue-initiative-target");
},
);
it("grants Ruthlessness's finisher point and observes its 9-second cooldown", () => {
addTarget("rogue-ruthlessness-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-273": 3 },
comboPoints: 3,
comboTargetId: "rogue-ruthlessness-target",
}));
const eviscerate = ability("Eviscerate");
expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(1);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-rogue-ruthlessness-cooldown",
) && aura.expiresAt === 10_000)).toBe(true);
useCombatStore.setState({ comboPoints: 2, comboTargetId: "rogue-ruthlessness-target" });
expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(0);
});
it("grants Honor Among Thieves' rank-3 direct-damage combo point no more than once every 3 seconds", () => {
addTarget("rogue-honor-among-thieves-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-2078": 3 },
resource: 1_000,
maxResource: 1_000,
}));
const sinisterStrike = ability("Sinister Strike");
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(2);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-honor-among-thieves-cooldown")
&& aura.expiresAt === 4_000)).toBe(true);
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(3);
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 4_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(5);
});
it("redirects only threat generated after Tricks of the Trade is cast", () => {
addTarget("rogue-tricks-target", [8, 0, 0]);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [2, 0, 0]);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
"rogue-tricks-target": {
...state.mobs["rogue-tricks-target"]!,
engaged: true,
threatByActor: { [PLAYER_AGGRO_ID]: 70 },
},
},
}));
const tricks = ability("Tricks of the Trade");
const result = useCombatStore.getState().castAbility(tricks.id, { targetActorId: member.id, now: 1_000 });
expect(result).toMatchObject({ ok: true, abilityId: tricks.id });
expect(useCombatStore.getState().mobs["rogue-tricks-target"]?.threatByActor)
.toEqual({ [PLAYER_AGGRO_ID]: 70 });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-rogue-tricks-damage-buff")
))).toBe(true);
useCombatStore.getState().damageMob("rogue-tricks-target", 100, 2_000);
const threat = useCombatStore.getState().mobs["rogue-tricks-target"]!.threatByActor;
expect(threat[PLAYER_AGGRO_ID]).toBe(70);
expect(threat[member.id]).toBeGreaterThan(0);
});
it("executes five two-weapon Killing Spree assaults against a single reachable target", () => {
addTarget("rogue-killing-spree-target", [3, 0, 0]);
learnAbilityTalent("Killing Spree");
const spree = ability("Killing Spree");
const result = useCombatStore.getState().castAbility(spree.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: spree.id });
const strikes = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.targetActorId === "rogue-killing-spree-target" && event.abilityId === spree.id
));
expect(strikes).toHaveLength(10);
expect(useCombatStore.getState().mobs["rogue-killing-spree-target"]?.health).toBeLessThan(100_000);
expect(useCombatStore.getState().playerPosition).not.toEqual([0, 0, 0]);
});
it("Shadowsteps behind its target and applies the registered triggered rider", () => {
addTarget("rogue-shadowstep-target", [8, 0, 0]);
const shadowstep = ability("Shadowstep");
const result = useCombatStore.getState().castAbility(shadowstep.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: shadowstep.id });
expect(useCombatStore.getState().playerPosition).toEqual([8, 0, -1.5]);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-shadowstep-rider"))).toBe(true);
});
it("keeps the Riposte cast gated until a parry grants its ready state", () => {
addTarget("rogue-riposte-target");
learnAbilityTalent("Riposte");
const riposte = ability("Riposte");
expect(useCombatStore.getState().castAbility(riposte.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready"))).toBe(false);
useCombatStore.setState((state) => ({
auras: [...state.auras, {
instanceId: "test-riposte-ready",
definition: {
id: "wow335:rogue:riposte-ready",
name: "Riposte Ready",
disposition: "buff" as const,
durationMs: 5_000,
maxStacks: 1,
stackBehavior: "refresh" as const,
utilityTags: ["wow335-rogue-riposte-ready"],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
appliedAt: 1_500,
expiresAt: 6_500,
stacks: 1,
absorbs: [],
procStates: [],
}],
}));
expect(useCombatStore.getState().castAbility(riposte.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-rogue-riposte-ready",
))).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
describe("WoW Rogue Vanish and Preparation runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-rogue-vanish-preparation-tests", 1,
useCombatStore.getState().health);
});
it.each(["root", "slow"] as const)("Vanish grants stealth, clears %s, and does not relocate the rogue", (mechanic) => {
useCombatStore.getState().registerMob("vanish-target", { name: "vanish-target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("vanish-target", [8, 0, 0]);
useCombatStore.getState().selectMob("vanish-target");
useCombatStore.setState({ controlledUntil: 15_000, controlMechanic: mechanic });
const result = useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-vanish" });
const state = useCombatStore.getState();
expect(state.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("stealth")
&& aura.expiresAt === null)).toBe(true);
expect(state).toMatchObject({ controlledUntil: 0, controlMechanic: null, playerPosition: [0, 0, 0] });
});
it("Preparation resets only its five source-listed cooldowns", () => {
const abilities = useCombatStore.getState().abilities;
const byName = (name: string) => {
const ability = abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!;
};
const resetNames = ["Evasion", "Sprint", "Vanish", "Cold Blood", "Shadowstep"];
const resetIds = resetNames.map((name) => byName(name).id);
const unrelatedId = byName("Kick").id;
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-284": 1 },
cooldowns: {
...state.cooldowns,
...Object.fromEntries([...resetIds, unrelatedId].map((id) => [id, 60_000])),
},
}));
const result = useCombatStore.getState().castAbility("wow335-rogue-preparation", undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-preparation" });
const cooldowns = useCombatStore.getState().cooldowns;
for (const id of resetIds) expect(cooldowns[id], id).toBe(0);
expect(cooldowns[unrelatedId]).toBe(60_000);
expect(cooldowns["wow335-rogue-preparation"]).toBe(601_000);
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function shamanAbility(name: string) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, `Shaman ${name}`).toBeDefined();
return ability!;
}
function setTalentRank(talentId: string, rank: number): void {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talentId]: rank },
}));
}
function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void {
const result = useCombatStore.getState().castAbility(
abilityId,
targetActorId !== undefined ? { targetActorId, now } : { now },
);
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
}
function setupShaman(): void {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shaman-conditional-talent-tests", 80, useCombatStore.getState().health);
useCombatStore.getState().registerMob("shaman-conditional-target", {
name: "Shaman conditional talent target",
level: 80,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition("shaman-conditional-target", [8, 0, 0]);
useCombatStore.getState().selectMob("shaman-conditional-target");
}
describe("WoW Shaman conditional talent mechanics", () => {
beforeEach(setupShaman);
it("grants two 30% mana-cost reductions after a critical elemental spell", () => {
setTalentRank("talent-574", 2);
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
}));
const lightningBolt = shamanAbility("Lightning Bolt");
castAndFinish(lightningBolt.id, undefined, 1_000);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical
))).toBe(true);
const clearcasting = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting")
));
expect(clearcasting()?.stacks).toBe(2);
const healingWave = shamanAbility("Healing Wave");
const before = useCombatStore.getState();
const fullCost = resolvedAbilityCosts(healingWave, before.talentModifiers, { ...before, auras: [] })
.find((cost) => cost.resource === "mana")?.amount;
expect(fullCost).toBeGreaterThan(0);
const discountedCost = Math.round(fullCost! * 0.7);
expect(resolvedAbilityCosts(healingWave, before.talentModifiers, before)
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
castAndFinish(healingWave.id, null, 10_000);
expect(clearcasting()?.stacks).toBe(1);
const oneChargeState = useCombatStore.getState();
expect(resolvedAbilityCosts(healingWave, oneChargeState.talentModifiers, oneChargeState)
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
castAndFinish(healingWave.id, null, 20_000);
expect(clearcasting()).toBeUndefined();
});
it("uses Reverberation's rank chance and 20% bonus on the next different Shock", () => {
setTalentRank("talent-575", 3);
const earthShock = shamanAbility("Earth Shock");
const frostShock = shamanAbility("Frost Shock");
castAndFinish(earthShock.id, undefined, 1_000);
const initialReverberation = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
));
expect(initialReverberation?.expiresAt).toBe(9_000);
expect(initialReverberation?.definition.utilityTags).toContain(`wow335-shaman-reverberation:from:${earthShock.id}`);
castAndFinish(frostShock.id, undefined, 6_000);
const frostShockHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === frostShock.id
));
expect(frostShockHit?.effectiveAmount).toBeGreaterThan(0);
const refreshedReverberation = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
));
expect(refreshedReverberation?.definition.utilityTags)
.toContain(`wow335-shaman-reverberation:from:${frostShock.id}`);
const buffedRawDamage = frostShockHit?.rawAmount;
setupShaman();
const baselineBolt = shamanAbility("Lightning Bolt");
const baselineFrostShock = shamanAbility("Frost Shock");
castAndFinish(baselineBolt.id, undefined, 1_000);
castAndFinish(baselineFrostShock.id, undefined, 6_000);
const baselineHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === baselineFrostShock.id
));
expect(baselineHit?.rawAmount).toBeGreaterThan(0);
expect(buffedRawDamage).toBe(Math.round((baselineHit?.rawAmount ?? 0) * 1.2));
});
});
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const HEALING_STREAM_RANKS = [
{ level: 20, spellId: 5394, heal: 6 },
{ level: 30, spellId: 6375, heal: 8 },
{ level: 40, spellId: 6377, heal: 10 },
{ level: 50, spellId: 10462, heal: 12 },
{ level: 60, spellId: 10463, heal: 14 },
{ level: 69, spellId: 25567, heal: 18 },
{ level: 71, spellId: 58755, heal: 20 },
{ level: 76, spellId: 58756, heal: 23 },
{ level: 80, spellId: 58757, heal: 25 },
] as const;
function healingStreamAbility() {
const ability = abilitiesForClass("shaman").find((candidate) => candidate.name === "Healing Stream Totem");
expect(ability, "Healing Stream Totem").toBeDefined();
return ability!;
}
describe("WoW Shaman Healing Stream Totem", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-healing-stream-test", 80, useCombatStore.getState().health);
});
it("uses each source rank’s 2-second heal amount", () => {
const base = healingStreamAbility();
for (const expected of HEALING_STREAM_RANKS) {
const ranked = abilityAtLevel(base, expected.level);
const summon = ranked.effects.find((effect) => effect.kind === "summon");
expect(ranked.dbcSpellId).toBe(expected.spellId);
expect(summon?.kind).toBe("summon");
if (summon?.kind !== "summon") continue;
expect(summon.healingPulse).toEqual({ amount: expected.heal, intervalMs: 2_000, radius: 10 });
expect(summon.durationMs).toBe(300_000);
}
});
it("heals every nearby party member and never attacks the selected enemy", () => {
const state = useCombatStore.getState();
const members = usePartyStore.getState().members;
useCombatStore.setState((current) => ({ health: current.maxHealth - 25 }));
usePartyStore.setState((current) => ({
members: current.members.map((member) => ({ ...member, health: member.maxHealth - 25 })),
}));
members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, index === 0 ? [5, 0, 0] : index === 1 ? [9, 0, 0] : [10.1 + index, 0, 0]);
});
useCombatStore.getState().registerMob("healing-stream-nearby-enemy", { name: "Nearby enemy", maxHealth: 10_000 });
setMobPosition("healing-stream-nearby-enemy", [4, 0, 0]);
useCombatStore.getState().selectMob("healing-stream-nearby-enemy");
const cast = useCombatStore.getState().castAbility(healingStreamAbility().id, undefined, 1_000);
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const totem = useCombatStore.getState().summons.find((summon) => (
summon.sourceAbilityId === healingStreamAbility().id
));
expect(totem).toBeDefined();
expect(totem?.kind).toBe("totem");
expect(totem?.maxHealth).toBe(5);
expect(totem?.attackDamage).toBe(0);
expect(totem?.expiresAt).toBe(301_000);
useCombatStore.getState().tick(0, 3_000);
const after = useCombatStore.getState();
expect(after.health).toBe(after.maxHealth);
expect(usePartyStore.getState().members[0]?.health).toBe(usePartyStore.getState().members[0]?.maxHealth);
expect(usePartyStore.getState().members[1]?.health).toBe(usePartyStore.getState().members[1]?.maxHealth);
expect(usePartyStore.getState().members[2]?.health).toBe((usePartyStore.getState().members[2]?.maxHealth ?? 0) - 25);
expect(after.mobs["healing-stream-nearby-enemy"]?.health).toBe(10_000);
expect(after.combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === healingStreamAbility().id)).toHaveLength(3);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { usePartyStore } from "./partyStore";
function shamanAbility(name: string) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, `Shaman ${name}`).toBeDefined();
return ability!;
}
function finishCast(abilityId: string, targetId: string | undefined, startedAt: number): void {
const requestTargetId = targetId === PLAYER_AURA_ENTITY_ID ? null : targetId;
const result = useCombatStore.getState().castAbility(
abilityId,
requestTargetId !== undefined
? { targetActorId: requestTargetId, now: startedAt }
: { now: startedAt },
);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
}
describe("WoW Shaman source-backed proc talents", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shaman-proc-tests", 80, useCombatStore.getState().health);
for (const [index, member] of usePartyStore.getState().members.entries()) {
registerPartyRuntimePosition(member.id, [4 + index, 0, 0]);
}
});
it.each([
[1, 500],
[2, 1_000],
[3, 1_500],
])("reduces the shared Shock cooldown by %i rank(s) (%i ms) with Reverberation", (rank, reductionMs) => {
const flameShock = shamanAbility("Flame Shock");
const shockAbilities = useCombatStore.getState().abilities.filter((ability) => (
ability.cooldownGroup === "shaman-shock"
));
expect(shockAbilities.length).toBeGreaterThan(1);
const target = `reverberation-target-${rank}`;
useCombatStore.getState().registerMob(target, {
name: "Reverberation target",
level: 80,
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition(target, [8, 0, 0]);
useCombatStore.getState().selectMob(target);
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-575": rank },
}));
const result = useCombatStore.getState().castAbility(flameShock.id, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
const expectedReadyAt = 1_000 + 6_000 - reductionMs;
for (const ability of shockAbilities) {
expect(useCombatStore.getState().cooldowns[ability.id], ability.name).toBe(expectedReadyAt);
}
});
it("uses the ranked Ancestral Healing reduction and heals the lowest-health ally from Ancestral Awakening", () => {
const members = usePartyStore.getState().members;
const mostInjured = members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === mostInjured.id
? { ...member, health: Math.round(member.maxHealth * 0.4) }
: member),
}));
useCombatStore.setState((state) => ({
health: state.maxHealth - 1,
gearStats: { ...state.gearStats, criticalStrikeRating: 100_000 },
talentRanks: { ...state.talentRanks, "talent-2061": 3, "talent-581": 3 },
}));
const heal = shamanAbility("Healing Wave");
finishCast(heal.id, PLAYER_AURA_ENTITY_ID, 1_000);
const events = useCombatStore.getState().combatEvents;
const directHeal = events.find((event) => event.kind === "healing" && event.abilityId === heal.id);
expect(directHeal?.critical).toBe(true);
const awakeningHeal = events.find((event) => (
event.kind === "healing" && event.abilityId === "wow335-shaman-ancestral-awakening"
));
expect(awakeningHeal).toMatchObject({ targetActorId: mostInjured.id, school: "nature" });
expect(awakeningHeal?.rawAmount).toBe(Math.round((directHeal?.rawAmount ?? 0) * 0.3));
expect(usePartyStore.getState().members.find((member) => member.id === mostInjured.id)?.health)
.toBeGreaterThan(Math.round(mostInjured.maxHealth * 0.4));
const ancestralHealing = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-shaman-ancestral-healing")
));
expect(ancestralHealing).toMatchObject({ expiresAt: directHeal!.occurredAt + 15_000 });
expect(ancestralHealing?.definition.modifiers).toContainEqual({
kind: "damage",
direction: "taken",
school: "physical",
operation: "percent",
value: -0.1,
});
});
it("reduces Elemental Mastery and Thunderstorm cooldowns on damaging Lightning Bolt hits", () => {
const state = useCombatStore.getState();
const lightningBolt = shamanAbility("Lightning Bolt");
const sourceCooldownAbilities = ["Elemental Mastery", "Thunderstorm"].map((name) => (
WOW335_ABILITIES_BY_CLASS.shaman.find((ability) => ability.name === name)
));
expect(sourceCooldownAbilities.every(Boolean)).toBe(true);
const cooldownAbilities = sourceCooldownAbilities as NonNullable<typeof sourceCooldownAbilities[number]>[];
const target = "shamanism-target";
useCombatStore.getState().registerMob(target, { name: "Shamanism target", level: 80, maxHealth: 100_000, xpReward: 0 });
setMobPosition(target, [8, 0, 0]);
useCombatStore.getState().selectMob(target);
useCombatStore.setState((current) => ({
talentRanks: { ...current.talentRanks, "talent-2252": 3 },
abilities: [...current.abilities, ...cooldownAbilities.filter((ability) => (
!current.abilities.some((candidate) => candidate.id === ability.id)
))],
cooldowns: {
...current.cooldowns,
[cooldownAbilities[0]!.id]: 20_000,
[cooldownAbilities[1]!.id]: 20_000,
},
}));
const result = useCombatStore.getState().castAbility(lightningBolt.id, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === lightningBolt.id && event.effectiveAmount > 0
))).toBe(true);
expect(useCombatStore.getState().cooldowns[cooldownAbilities[0]!.id]).toBe(19_000);
expect(useCombatStore.getState().cooldowns[cooldownAbilities[1]!.id]).toBe(19_000);
});
});
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function unlockShamanTalent(abilityName: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === abilityName);
expect(ability, abilityName).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, abilityName).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
describe("WoW Shaman Shamanistic Rage source proc", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shamanistic-rage-runtime-tests", 1, useCombatStore.getState().health);
useCombatStore.getState().registerMob("shamanistic-rage-target", { name: "Shamanistic Rage target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("shamanistic-rage-target", [2, 0, 0]);
useCombatStore.getState().selectMob("shamanistic-rage-target");
});
it("restores 15% attack power as mana on landed melee hits for the 15-second aura window", () => {
const rageId = unlockShamanTalent("Shamanistic Rage");
const rageAbility = useCombatStore.getState().abilities.find((candidate) => candidate.id === rageId);
expect(rageAbility?.dbcSpellId).toBe(30823);
useCombatStore.setState((state) => ({
resource: 300,
resourcePools: { ...state.resourcePools, mana: 300 },
}));
const rageCast = useCombatStore.getState().castAbility(rageId, undefined, 1_000);
expect(rageCast.ok).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "wow335-shamanistic-rage-mana")
));
expect(aura).toBeDefined();
expect(aura?.expiresAt).toBe(16_000);
for (let now = 3_000; now < 16_000; now += 2_500) {
useCombatStore.setState({ autoAttackTargetId: "shamanistic-rage-target", nextAutoAttackAt: now });
useCombatStore.getState().tick(0.1, now);
if (useCombatStore.getState().combatEvents.some((event) => (
event.kind === "resource" && event.abilityId === rageId && event.effectiveAmount > 0
))) break;
}
const afterMelee = useCombatStore.getState();
expect(afterMelee.resourcePools.mana).toBeGreaterThan(0);
expect(afterMelee.combatEvents.some((event) => (
event.kind === "resource"
&& event.abilityId === rageId
&& event.effectiveAmount > 0
))).toBe(true);
const rageRestoresBeforeExpiry = afterMelee.combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === rageId
)).length;
useCombatStore.setState({
autoAttackTargetId: "shamanistic-rage-target",
nextAutoAttackAt: 20_000,
});
useCombatStore.getState().tick(0.1, 20_000);
expect(useCombatStore.getState().auras.some((entry) => entry.instanceId === aura?.instanceId)).toBe(false);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === rageId
))).toHaveLength(rageRestoresBeforeExpiry);
});
});
+238
View File
@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { applyAura } from "./combatAuras";
import { PLAYER_AGGRO_ID } from "./aggro";
import { usePartyStore } from "./partyStore";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
function castAndFinish(name: string, now: number): number {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(0.1, activeCast.completesAt);
return activeCast.completesAt;
}
return now;
}
function addNearbyMob(id: string, x: number, maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth });
setMobPosition(id, [x, 0, 0]);
}
function deterministicUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
describe("WoW 3.3.5 Warlock mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-mechanics-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
addNearbyMob("warlock-target", 4);
useCombatStore.getState().selectMob("warlock-target");
});
it("requires and consumes the caster's Immolate for Conflagrate", () => {
const target = useCombatStore.getState().mobs["warlock-target"]!;
const conflagrateId = unlockTalentAbility("Conflagrate");
const unavailable = useCombatStore.getState().castAbility(conflagrateId, undefined, 1_000);
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
const immolateCompleteAt = castAndFinish("Immolate", 3_000);
const immolateId = abilityId("Immolate");
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
))).toBe(true);
const conflagrate = useCombatStore.getState().castAbility(conflagrateId, undefined, immolateCompleteAt + 100);
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
expect(conflagrate.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
))).toBe(false);
});
it("models Shadowflame's missing periodic child so Conflagrate can consume it", () => {
const shadowflameCompleteAt = castAndFinish("Shadowflame", 3_000);
const shadowflameId = abilityId("Shadowflame");
const activeShadowflame = useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId && effect.targetId === "warlock-target"
));
expect(activeShadowflame).toMatchObject({ kind: "dot", intervalMs: 2_000, remainingTicks: 4, damageSchool: "fire" });
const conflagrate = useCombatStore.getState().castAbility(
unlockTalentAbility("Conflagrate"), undefined, shadowflameCompleteAt + 2_000,
);
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
expect(conflagrate.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId
))).toBe(false);
});
it("ramps Curse of Agony ticks through half, full, then one-and-a-half damage", () => {
const curseId = abilityId("Curse of Agony");
const completeAt = castAndFinish("Curse of Agony", 1_000);
for (let tick = 1; tick <= 12; tick += 1) {
useCombatStore.getState().tick(0.1, completeAt + tick * 2_000);
}
const amounts = useCombatStore.getState().combatEvents
.filter((event) => event.kind === "damage" && event.abilityId === curseId && event.targetActorId === "warlock-target")
.map((event) => event.effectiveAmount);
expect(amounts).toHaveLength(12);
expect(new Set(amounts.slice(0, 4)).size).toBe(1);
expect(new Set(amounts.slice(4, 8)).size).toBe(1);
expect(new Set(amounts.slice(8, 12)).size).toBe(1);
expect(Math.abs(amounts[4]! - 2 * amounts[0]!)).toBeLessThanOrEqual(2);
expect(Math.abs(amounts[8]! - 3 * amounts[0]!)).toBeLessThanOrEqual(3);
});
it("uses Seed's DBC threshold and rank-matched area explosion on damage", () => {
const source = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Seed of Corruption");
expect(source?.ranks?.map((rank) => rank.spellId)).toEqual([27_243, 47_835, 47_836]);
addNearbyMob("seed-neighbor", 8);
const completeAt = castAndFinish("Seed of Corruption", 1_000);
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
.toMatchObject({ warlockSeed: { threshold: 1_518, explosionSpellId: 47_834, accumulatedDamage: 0 } });
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
useCombatStore.getState().damageMob("warlock-target", 1_517, completeAt + 100, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBe(neighborHealth);
useCombatStore.getState().damageMob("warlock-target", 1, completeAt + 200, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
.toBe(false);
});
it("detonates Seed when its carrier dies before the damage threshold", () => {
addNearbyMob("seed-neighbor", 8);
addNearbyMob("seed-carrier", 6, 1_000);
useCombatStore.getState().selectMob("seed-carrier");
const completeAt = castAndFinish("Seed of Corruption", 1_000);
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
useCombatStore.getState().damageMob("seed-carrier", 1_000, completeAt + 100, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-carrier"]?.dead).toBe(true);
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
});
it("procs Backlash only from landed physical attacks, with its source duration and internal cooldown", () => {
setTalentRank("Backlash", 3);
const maxHealth = useCombatStore.getState().maxHealth;
const readyTag = "wow335-warlock-backlash-ready";
const cooldownTag = "wow335-warlock-backlash-cooldown";
useCombatStore.getState().damagePlayer(1, "shadow", 80, 20_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(readyTag))).toBe(false);
const procAt = Array.from({ length: 500 }, (_, offset) => 30_000 + offset)
.find((now) => deterministicUnit(
`${PLAYER_AGGRO_ID}:external:physical:${now}:${maxHealth}:wow335-warlock-backlash`,
) < 0.25);
expect(procAt, "a deterministic source-rank Backlash proc seed").toBeDefined();
useCombatStore.setState({ health: maxHealth });
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt!);
const ready = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag));
expect(ready?.expiresAt).toBe(procAt! + 12_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(cooldownTag)
&& aura.expiresAt === procAt! + 8_000)).toBe(true);
useCombatStore.setState({ health: maxHealth });
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt! + 1);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag))?.expiresAt)
.toBe(procAt! + 12_000);
});
it("makes Backlash's next Shadow Bolt instant, half-cost at rank 3, and consumes its proc", () => {
setTalentRank("Backlash", 3);
const playerId = PLAYER_AURA_ENTITY_ID;
const shadowBolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt")!;
const manaBefore = useCombatStore.getState().resourcePools.mana!;
const normalCast = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 1_000);
expect(normalCast.ok, JSON.stringify(normalCast)).toBe(true);
const normalActiveCast = useCombatStore.getState().activeCast;
expect(normalActiveCast?.abilityId).toBe(shadowBolt.id);
useCombatStore.getState().tick(0.1, normalActiveCast!.completesAt);
const normalManaSpent = manaBefore - useCombatStore.getState().resourcePools.mana!;
expect(normalManaSpent).toBeGreaterThan(0);
const backlashCastAt = normalActiveCast!.completesAt + 1_000;
const readyAura = applyAura([], {
definition: {
id: "wow335:warlock:backlash-ready",
name: "Backlash",
disposition: "buff",
durationMs: 12_000,
utilityTags: ["wow335-warlock-backlash-ready"],
},
sourceId: playerId,
targetId: playerId,
now: backlashCastAt,
}).auras;
const afterNormal = useCombatStore.getState();
useCombatStore.setState({
auras: readyAura,
activeCast: null,
globalCooldownEndsAt: 0,
resource: afterNormal.maxResource,
resourcePools: { ...afterNormal.resourcePools, mana: afterNormal.maxResourcePools.mana! },
});
const procManaBefore = useCombatStore.getState().resourcePools.mana!;
const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, backlashCastAt);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(procManaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(normalManaSpent * 0.5));
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-warlock-backlash-ready",
))).toBe(false);
expect(result.damage).toBeGreaterThan(0);
});
});
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function soulshatterId(): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Soulshatter");
expect(ability, "Soulshatter").toBeDefined();
return ability!.id;
}
function setPlayerThreat(id: string, amount: number): void {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[id]: {
...state.mobs[id],
threatByActor: { ...state.mobs[id].threatByActor, [PLAYER_AGGRO_ID]: amount },
},
},
}));
}
describe("WoW 3.3.5 Warlock Soulshatter runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-soulshatter-tests", 1,
useCombatStore.getState().health);
});
it("cuts threat in half inside the caster's 50-yard radius and leaves outside enemies alone", () => {
useCombatStore.getState().registerMob("soulshatter-inside", { name: "Inside", maxHealth: 10_000 });
useCombatStore.getState().registerMob("soulshatter-outside", { name: "Outside", maxHealth: 10_000 });
setMobPosition("soulshatter-inside", [20, 0, 0]);
setMobPosition("soulshatter-outside", [60, 0, 0]);
setPlayerThreat("soulshatter-inside", 100);
setPlayerThreat("soulshatter-outside", 100);
const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: ["soulshatter-inside"] });
expect(useCombatStore.getState().mobs["soulshatter-inside"].threatByActor[PLAYER_AGGRO_ID]).toBe(50);
expect(useCombatStore.getState().mobs["soulshatter-outside"].threatByActor[PLAYER_AGGRO_ID]).toBe(100);
});
it("does not reduce distant enemies when nobody is within the caster-centered radius", () => {
useCombatStore.getState().registerMob("soulshatter-far", { name: "Far", maxHealth: 10_000 });
setMobPosition("soulshatter-far", [60, 0, 0]);
setPlayerThreat("soulshatter-far", 100);
const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: [] });
expect(useCombatStore.getState().mobs["soulshatter-far"].threatByActor[PLAYER_AGGRO_ID]).toBe(100);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { talentModifiersForRanks } from "./talentModifiers";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
function addTarget(id = "warlock-proc-target"): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [4, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function castAndFinish(name: string, now: number): number {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(0.1, activeCast.completesAt);
return activeCast.completesAt;
}
return now;
}
describe("WoW 3.3.5 Warlock talent procs", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-proc-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
addTarget();
});
it("heals the active pet for 15% of effective spell damage with Fel Synergy", () => {
setTalentRank("Fel Synergy", 2);
castAndFinish("Summon Imp", 1_000);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.id === pet!.id
? { ...summon, health: 1, nextActionAt: Number.MAX_SAFE_INTEGER }
: summon),
}));
const target = useCombatStore.getState().mobs["warlock-proc-target"]!;
const playerPet = useCombatStore.getState().summons.find((summon) => summon.id === pet!.id)!;
const completesAt = castAndFinish("Shadow Bolt", 12_000);
const damage = target.health - useCombatStore.getState().mobs[target.id]!.health;
const updatedPet = useCombatStore.getState().summons.find((summon) => summon.id === playerPet.id)!;
expect(damage).toBeGreaterThan(0);
expect(updatedPet.health).toBe(Math.min(updatedPet.maxHealth, 1 + Math.round(damage * 0.15)));
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.targetActorId === playerPet.id && event.occurredAt === completesAt
))).toBe(true);
});
it("heals from eligible direct Destruction hits and observes Soul Leech's three-second cooldown", () => {
setTalentRank("Soul Leech", 3);
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState((state) => ({
health: state.maxHealth - 1_000,
}));
const target = useCombatStore.getState().mobs["warlock-proc-target"]!;
const completesAt = castAndFinish("Shadow Bolt", 1_000);
const effectiveDamage = target.health - useCombatStore.getState().mobs[target.id]!.health;
const expectedHeal = Math.min(Math.round(effectiveDamage * 0.15), Math.round(maxHealth * 0.15));
expect(useCombatStore.getState().health).toBe(maxHealth - 1_000 + expectedHeal);
const initialSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === abilityId("Shadow Bolt")
));
expect(initialSoulLeechEvents).toHaveLength(1);
expect(initialSoulLeechEvents[0]?.effectiveAmount).toBe(expectedHeal);
castAndFinish("Searing Pain", completesAt + 100);
const followupSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === abilityId("Searing Pain")
));
expect(followupSoulLeechEvents).toHaveLength(0);
});
it("applies Improved Shadow Bolt vulnerability only after a critical Shadow Bolt", () => {
setTalentRank("Improved Shadow Bolt", 5);
const firstCompleteAt = castAndFinish("Shadow Bolt", 1_000);
const shadowBoltId = abilityId("Shadow Bolt");
const firstHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === shadowBoltId
));
expect(firstHit?.critical).toBe(false);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === "warlock-proc-target"
&& aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt")
))).toBe(false);
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
}));
const completesAt = castAndFinish("Shadow Bolt", firstCompleteAt + 100);
const criticalHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === shadowBoltId && event.occurredAt === completesAt
));
expect(criticalHit?.critical).toBe(true);
const targetAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === "warlock-proc-target"
&& aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt")
));
expect(targetAura).toMatchObject({ expiresAt: completesAt + 30_000 });
});
it("keeps conditional Warlock procs out of global scalar modifiers", () => {
const improvedShadowBolt = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Improved Shadow Bolt")!;
const modifiers = talentModifiersForRanks({
"talent-1002": 2,
"talent-1878": 3,
[improvedShadowBolt.id]: 5,
}, "warlock");
expect(modifiers.some((modifier) => modifier.sourceTalentName === "Nightfall")).toBe(false);
expect(modifiers.some((modifier) => modifier.sourceTalentName === "Eradication")).toBe(false);
expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Improved Shadow Bolt"))
.toEqual([expect.objectContaining({ kind: "damage-percent", amount: 15 })]);
});
it("keeps Backlash's next-cast damage and cost conditional while applying its passive critical chance", () => {
const modifiers = talentModifiersForRanks({ "talent-1817": 3 }, "warlock");
expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Backlash"))
.toEqual([expect.objectContaining({ kind: "critical-chance-percent", amount: 3 })]);
});
it("procs rank-scaled Eradication from Corruption damage and applies haste to cast time", () => {
setTalentRank("Eradication", 3);
let nextCastAt = 1_000;
let procAt: number | null = null;
for (let application = 0; application < 3 && procAt === null; application += 1) {
const completesAt = castAndFinish("Corruption", nextCastAt);
const dot = useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === abilityId("Corruption")
));
expect(dot, "Corruption periodic effect").toBeDefined();
for (let tick = 1; tick <= dot!.remainingTicks && procAt === null; tick += 1) {
nextCastAt = completesAt + tick * dot!.intervalMs;
useCombatStore.getState().tick(0.1, nextCastAt);
if (useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-warlock-eradication")
&& (aura.expiresAt === null || aura.expiresAt > nextCastAt)
))) procAt = nextCastAt;
}
nextCastAt += 1_000;
}
expect(procAt).not.toBeNull();
const shadowBoltId = abilityId("Shadow Bolt");
const shadowBolt = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)!;
const sourceCastTimeMs = shadowBolt.ranks?.at(-1)?.castTimeMs ?? shadowBolt.castTimeMs;
const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, procAt! + 100);
expect(result.ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt! - (procAt! + 100))
.toBe(Math.round(sourceCastTimeMs / 1.2));
});
});
+269
View File
@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId } from "./abilityCatalog";
import { resolveStatValue } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function unlockWarriorTalent(abilityId: string): void {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId);
expect(ability, abilityId).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, abilityId).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
}
function registerTarget(id: string, position: readonly [number, number, number], maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
setMobPosition(id, position);
}
describe("WoW 3.3.5 Warrior source-row mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warrior-runtime-tests", 1, useCombatStore.getState().health);
});
it("casts Bloodthirst from 23881 and heals the warrior on the next landed melee hit", () => {
unlockWarriorTalent("wow335-warrior-bloodthirst");
registerTarget("bloodthirst-target", [2, 0, 0]);
const state = useCombatStore.getState();
const startingHealth = state.maxHealth - 500;
useCombatStore.setState((current) => ({
health: startingHealth,
resource: 100,
resourcePools: { ...current.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-bloodthirst", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(80);
expect(useCombatStore.getState().cooldowns["wow335-warrior-bloodthirst"]).toBe(5_000);
const healAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "wow335-trigger-spell"
&& proc.action.data?.spellId === 23880)
));
expect(healAura).toBeDefined();
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
const afterMeleeHit = useCombatStore.getState();
expect(afterMeleeHit.health).toBeGreaterThan(startingHealth);
expect(afterMeleeHit.auras.find((aura) => aura.instanceId === healAura?.instanceId)?.procStates[0]?.remainingCharges)
.toBe(2);
expect(afterMeleeHit.combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === "wow335-trigger-23880"
))).toBe(false);
});
it("arms five Sweeping Strikes charges and copies each landed melee hit to the nearest nearby enemy", () => {
unlockWarriorTalent("wow335-warrior-sweeping-strikes");
registerTarget("sweeping-primary", [2, 0, 0], 1_000_000);
registerTarget("sweeping-secondary", [3, 0, 0], 1_000_000);
registerTarget("sweeping-distant", [10, 0, 0], 1_000_000);
useCombatStore.getState().selectMob("sweeping-primary");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-sweeping-strikes", undefined, 1_000).ok).toBe(true);
const armedAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-sweeping-strikes")
));
expect(armedAura?.procStates[0]?.remainingCharges).toBe(5);
expect(useCombatStore.getState().mobs["sweeping-primary"].health).toBe(1_000_000);
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
const afterHit = useCombatStore.getState();
expect(afterHit.mobs["sweeping-primary"].health).toBeLessThan(1_000_000);
expect(afterHit.mobs["sweeping-secondary"].health).toBeLessThan(1_000_000);
expect(afterHit.mobs["sweeping-distant"].health).toBe(1_000_000);
expect(afterHit.auras.find((aura) => aura.instanceId === armedAura?.instanceId)?.procStates[0]?.remainingCharges)
.toBe(4);
});
it.each([
[1, 1_000],
[2, 2_000],
])("reduces Whirlwind cooldown by %i rank(s) (%i ms) with Improved Whirlwind", (rank, reductionMs) => {
registerTarget(`improved-whirlwind-target-${rank}`, [2, 0, 0]);
useCombatStore.getState().selectMob(`improved-whirlwind-target-${rank}`);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, "talent-1655": rank },
}));
const whirlwind = useCombatStore.getState().abilities.find((ability) => ability.name === "Whirlwind");
expect(whirlwind).toBeDefined();
expect(whirlwind?.cooldownGroup).toBeUndefined();
const mortalStrike = useCombatStore.getState().abilities.find((ability) => ability.name === "Mortal Strike");
expect(mortalStrike).toBeDefined();
unlockWarriorTalent(mortalStrike!.id);
const unrelatedResult = useCombatStore.getState().castAbility(mortalStrike!.id, undefined, 1_000);
expect(unrelatedResult.ok, JSON.stringify(unrelatedResult)).toBe(true);
expect(useCombatStore.getState().cooldowns[mortalStrike!.id]).toBe(7_000);
const result = useCombatStore.getState().castAbility(whirlwind!.id, undefined, 3_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().cooldowns[whirlwind!.id]).toBe(3_000 + 10_000 - reductionMs);
});
it("requires Execute targets below 20% health and applies ranked damage instead of killing outright", () => {
registerTarget("execute-target", [2, 0, 0], 100_000);
useCombatStore.getState().selectMob("execute-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const targetAtThreshold = useCombatStore.getState().mobs["execute-target"];
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "execute-target": { ...targetAtThreshold, health: 20_000 } },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 1_000)).toMatchObject({
ok: false,
reason: "condition-not-met",
});
expect(useCombatStore.getState().mobs["execute-target"].health).toBe(20_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "execute-target": { ...state.mobs["execute-target"], health: 19_000 } },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 3_000).ok).toBe(true);
const afterExecute = useCombatStore.getState();
expect(afterExecute.mobs["execute-target"].health).toBeGreaterThan(0);
expect(afterExecute.mobs["execute-target"].health).toBeLessThan(19_000);
expect(afterExecute.resourcePools.rage).toBe(85);
expect(afterExecute.combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === "wow335-warrior-execute"
))).toBe(true);
});
it("stacks Improved Execute's rank-3 critical chance after casts below 20% health", () => {
registerTarget("execute-critical-target", [2, 0, 0], 1_000_000);
useCombatStore.getState().selectMob("execute-critical-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, "talent-1542": 3 },
mobs: {
...state.mobs,
"execute-critical-target": {
...state.mobs["execute-critical-target"],
health: 190_000,
},
},
}));
for (const [index, now] of [1_000, 3_000, 5_000, 7_000].entries()) {
const result = useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, now);
expect(result.ok, JSON.stringify(result)).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.id === "wow335:warrior:improved-execute-critical"
));
expect(aura?.stacks).toBe(Math.min(index + 1, 3));
expect(aura?.expiresAt).toBe(now + 5_000);
expect(resolveStatValue(0, aura ? [aura] : [], "critical-strike")).toBe(Math.min(index + 1, 3) * 10);
}
});
it("applies Battle Stance's source-row armor penetration to physical attacks", () => {
const runAttack = (withBattleStance: boolean): number => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warrior-armor-penetration-tests", 1, useCombatStore.getState().health);
registerTarget("armor-penetration-target", [2, 0, 0], 1_000_000);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
mobs: {
...state.mobs,
"armor-penetration-target": { ...state.mobs["armor-penetration-target"], armor: 6_000 },
},
}));
useCombatStore.getState().selectMob("armor-penetration-target");
if (withBattleStance) {
const stance = useCombatStore.getState().castAbility("wow335-warrior-battle-stance", undefined, 1_000);
expect(stance, JSON.stringify(stance)).toMatchObject({ ok: true });
}
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "damage" && event.abilityId === "warrior-heroic-strike"
))?.effectiveAmount ?? 0;
};
const withoutStance = runAttack(false);
const withStance = runAttack(true);
expect(withoutStance).toBeGreaterThan(0);
expect(withStance).toBeGreaterThan(withoutStance);
});
it.each([
["wow335-warrior-battle-shout", "Battle Shout"],
["wow335-warrior-commanding-shout", "Commanding Shout"],
])("applies %s to party members inside its source 30-yard radius", (abilityId, abilityName) => {
const [nearbyMember, distantMember] = usePartyStore.getState().members;
expect(nearbyMember).toBeDefined();
expect(distantMember).toBeDefined();
registerPartyRuntimePosition(nearbyMember!.id, [5, 0, 0]);
registerPartyRuntimePosition(distantMember!.id, [40, 0, 0]);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const auraTargets = useCombatStore.getState().auras
.filter((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith(`${abilityName} (DBC`))
.map((aura) => aura.targetId);
expect(auraTargets).toContain(PLAYER_AURA_ENTITY_ID);
expect(auraTargets).toContain(nearbyMember!.id);
expect(auraTargets).not.toContain(distantMember!.id);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.startsWith(abilityName))).toBe(true);
});
it.each([
["warrior-heroic-strike", "talent-124", 1, 2, 15],
["warrior-heroic-strike", "talent-124", 3, 6, 15],
["wow335-warrior-execute", "talent-1542", 1, 2, 15],
["wow335-warrior-execute", "talent-1542", 3, 6, 15],
])("applies the rank-scaled flat Rage discount to %s", (abilityId, talentId, rank, reduction, baseCost) => {
registerTarget(`flat-rage-cost-${abilityId}-${rank}`, [2, 0, 0], 100_000);
useCombatStore.getState().selectMob(`flat-rage-cost-${abilityId}-${rank}`);
if (abilityId === "wow335-warrior-execute") {
const target = useCombatStore.getState().mobs[`flat-rage-cost-${abilityId}-${rank}`];
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [target.id]: { ...target, health: 19_000 } },
}));
}
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, [talentId]: rank },
}));
const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(100 - baseCost + reduction);
});
});
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
describe("WoW Druid Nature's Swiftness", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-druid-nature-swiftness-test", 80, useCombatStore.getState().health);
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 1 }
: candidate),
}));
registerPartyRuntimePosition(member.id, [5, 0, 0]);
usePartyStore.getState().selectMember(member.id);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
talentRanks: { ...state.talentRanks, "talent-831": 1 },
}));
});
it("makes the next eligible Druid Nature heal instant until it is successfully cast", () => {
const state = useCombatStore.getState();
const swift = state.abilities.find((ability) => ability.name === "Nature's Swiftness")!;
const heal = state.abilities.find((ability) => ability.name === "Healing Touch")!;
expect(swift).toBeDefined();
expect(heal).toBeDefined();
const activation = state.castAbility(swift.id, undefined, 1_000);
expect(activation.ok, JSON.stringify(activation)).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AGGRO_ID
&& candidate.definition.utilityTags?.includes("wow335-next-nature-instant")
));
expect(aura?.definition.durationMs).toBeNull();
const cast = useCombatStore.getState().castAbility(heal.id, [0, 0, 0], 1_001);
expect(cast.ok, JSON.stringify(cast)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().auras.some((candidate) => (
candidate.definition.utilityTags?.includes("wow335-next-nature-instant")
))).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === heal.id && event.sourceActorId === PLAYER_AGGRO_ID
))).toBe(true);
});
});
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const NATURE_SWIFTNESS_ID = "wow335-shaman-nature-s-swiftness";
const ELEMENTAL_MASTERY_ID = "wow335-shaman-elemental-mastery";
const NON_NATURE_SPELL_ID = "shaman-flame-shock";
const HEALING_WAVE_ID = "shaman-healing-wave";
describe("WoW Shaman Nature's Swiftness runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-nature-swiftness-test", 80, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
});
it("makes the next cast-time Nature spell instant, preserves the proc through Flame Shock, and shares the 3-minute cooldown", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-591": 1, "talent-573": 1 },
}));
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 1 }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
registerPartyRuntimePosition(member.id, [5, 0, 0]);
useCombatStore.getState().registerMob("nature-swiftness-target", { name: "Target", maxHealth: 10_000 });
setMobPosition("nature-swiftness-target", [10, 0, 0]);
useCombatStore.getState().selectMob("nature-swiftness-target");
const activation = useCombatStore.getState().castAbility(NATURE_SWIFTNESS_ID, undefined, 1_000);
expect(activation.ok, JSON.stringify(activation)).toBe(true);
expect(useCombatStore.getState().cooldowns[ELEMENTAL_MASTERY_ID]).toBe(181_000);
expect(useCombatStore.getState().castAbility(ELEMENTAL_MASTERY_ID, undefined, 2_000))
.toMatchObject({ ok: false, reason: "cooldown" });
expect(useCombatStore.getState().castAbility(NON_NATURE_SPELL_ID, undefined, 2_000)).toMatchObject({ ok: true });
const natureSwiftnessAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("wow335-next-nature-instant")
));
expect(natureSwiftnessAura).toBeDefined();
expect(natureSwiftnessAura?.definition.durationMs).toBeNull();
expect(natureSwiftnessAura?.definition.modifiers ?? []).not.toContainEqual(
expect.objectContaining({ kind: "stat", stat: "haste" }),
);
const healed = useCombatStore.getState().castAbility(HEALING_WAVE_ID, [0, 0, 0], 4_000);
expect(healed.ok, JSON.stringify(healed)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("wow335-next-nature-instant")
))).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.abilityId === HEALING_WAVE_ID
&& event.targetActorId === member.id
))).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
describe("WoW Shaman Tidal Force", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-tidal-force-test", 80, useCombatStore.getState().health);
const members = usePartyStore.getState().members;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 1 })),
}));
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [4 + index, 0, 0]));
useCombatStore.setState((state) => ({
health: 1,
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
talentRanks: { ...state.talentRanks, "talent-582": 1, "talent-591": 1 },
}));
useCombatStore.getState().registerMob("tidal-force-target", { name: "Target", maxHealth: 1_000_000 });
setMobPosition("tidal-force-target", [8, 0, 0]);
useCombatStore.getState().selectMob("tidal-force-target");
if (members[0]) usePartyStore.getState().selectMember(members[0].id);
});
it("adds 60% crit to its three healing spells and spends one charge for each critical heal", () => {
const tidalForceId = abilityId("Tidal Force");
const natureSwiftnessId = abilityId("Nature's Swiftness");
const flameShockId = abilityId("Flame Shock");
const chainHealId = abilityId("Chain Heal");
expect(useCombatStore.getState().castAbility(tidalForceId, undefined, 1_000).ok).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AGGRO_ID
&& candidate.definition.utilityTags?.includes("wow335-tidal-force")
));
expect(aura?.stacks).toBe(3);
expect(aura?.definition.durationMs).toBe(2_000);
expect(useCombatStore.getState().castAbility(flameShockId, undefined, 1_001).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === flameShockId && event.critical
))).toBe(true);
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks)
.toBe(3);
const now = 2_502;
expect(useCombatStore.getState().castAbility(natureSwiftnessId, undefined, now).ok).toBe(true);
const instantHeal = useCombatStore.getState().castAbility(chainHealId, undefined, now + 1);
expect(instantHeal.ok, JSON.stringify(instantHeal)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
const criticalChainHeals = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === chainHealId && event.critical
));
expect(criticalChainHeals.length).toBeGreaterThanOrEqual(3);
expect(useCombatStore.getState().auras.some((candidate) => (
candidate.definition.utilityTags?.includes("wow335-tidal-force")
))).toBe(false);
});
});
+29
View File
@@ -79,10 +79,39 @@ function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon;
</group>;
}
function LightwellMember({ summon }: { readonly summon: CombatSummon }) {
const click = (event: { stopPropagation: () => void }) => {
event.stopPropagation();
useCombatStore.getState().clickLightwell(summon.id);
};
return <group name={`lightwell:${summon.id}`} position={summon.position}>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.025, 0]} onPointerDown={click}>
<ringGeometry args={[0.35, 0.52, 24]} />
<meshBasicMaterial color="#c7a76a" transparent opacity={0.28} depthWrite={false} />
</mesh>
<mesh position={[0, 0.16, 0]} onPointerDown={click}>
<cylinderGeometry args={[0.24, 0.3, 0.24, 12]} />
<meshStandardMaterial color="#786b53" roughness={0.8} />
</mesh>
<mesh position={[0, 0.46, 0]} onPointerDown={click}>
<cylinderGeometry args={[0.075, 0.11, 0.42, 10]} />
<meshStandardMaterial color="#9f8e6c" roughness={0.72} />
</mesh>
<mesh position={[0, 0.76, 0]} onPointerDown={click}>
<sphereGeometry args={[0.17, 12, 10]} />
<meshStandardMaterial color="#ddca9b" emissive="#9a7541" emissiveIntensity={0.32} roughness={0.5} />
</mesh>
<pointLight position={[0, 0.76, 0]} color="#c7a76a" intensity={0.22} distance={1.6} />
</group>;
}
/** Player and companion combat summons live outside the static dungeon population. */
export function CombatSummonPopulation({ active }: { readonly active: boolean }) {
const summons = useCombatStore((state) => state.summons);
return <group name="combat-summon-population">
{summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-lightwell"
&& summon.clickCharges && summon.health > 0)
.map((summon) => <LightwellMember key={summon.id} summon={summon} />)}
{summons.filter((summon) => summon.creatureId === SHADOWFIEND_CREATURE_ID && summon.health > 0)
.map((summon) => <ShadowfiendMember key={summon.id} summon={summon} active={active} />)}
</group>;
+3
View File
@@ -11,6 +11,7 @@ import {
import { Canvas, useThree } from "@react-three/fiber";
import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput";
import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation";
@@ -321,6 +322,8 @@ export function GameScene() {
return (
<div
className="scene-shell"
tabIndex={-1}
onPointerDownCapture={() => { if (useBiteSizedLayoutsStore.getState().editorMode) focusBiteSizedEditorScene(); }}
onPointerDown={beginMouseLook}
onLostPointerCapture={endMouseLook}
onContextMenu={(event) => event.preventDefault()}
+1 -1
View File
@@ -833,7 +833,7 @@ function LoadedOriginalCombatEffect({
{graph.kind === "runewaker-ros-particle-archive" ? (
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
phase={event.phase} quality={quality} active={active} eventId={event.id}
playbackRef={staticMeshPlaybackRef} />
playbackRef={staticMeshPlaybackRef} opacity={tuning.opacity} />
) : graph.kind === "wow-m2-static-mesh" ? (
<OriginalStaticMesh
graph={graph}
+15 -11
View File
@@ -156,7 +156,7 @@ function spawn(emitter: RuneEmitterController, position: RuneVector3,
}
const RuneEmitter = memo(function RuneEmitter({
graph, phase, layer, texture, budget, active, eventId, timelineRef,
graph, phase, layer, texture, budget, active, eventId, timelineRef, opacity,
}: {
readonly graph: RunewakerParticleGraph;
readonly phase: CombatPresentationPhase;
@@ -166,6 +166,7 @@ const RuneEmitter = memo(function RuneEmitter({
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const geometry = useMemo(() => {
@@ -261,7 +262,7 @@ const RuneEmitter = memo(function RuneEmitter({
particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared,
particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared);
colors.setXYZW(index, color[0], color[1], color[2],
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha)));
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha * opacity)));
sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]),
Math.max(0.001, scale * layer.sprite.quadScale[1]));
rotations.setX(index, layer.sprite.orientToTravel
@@ -276,7 +277,7 @@ const RuneEmitter = memo(function RuneEmitter({
});
const RuneEntityEmitter = memo(function RuneEntityEmitter({
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef,
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef, opacity,
}: {
readonly graph: RunewakerParticleGraph;
readonly emitter: RuneEmitterController;
@@ -286,6 +287,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const pool = useMemo(() => {
@@ -365,7 +367,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
mesh.scale.setScalar(Math.max(0.001, scale));
mesh.rotation.y = next.orientation;
mesh.material.color.setRGB(color[0], color[1], color[2]);
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3]));
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3] * opacity));
mesh.visible = scale > 0.001 && mesh.material.opacity > 0.002;
}
});
@@ -376,7 +378,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
});
const RuneGroundDecal = memo(function RuneGroundDecal({
decal, alpha, color, texture, active, timelineRef,
decal, alpha, color, texture, active, timelineRef, opacity,
}: {
readonly decal: RuneDecalController;
readonly alpha: RuneGlobalAlphaController;
@@ -384,6 +386,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
readonly texture: Texture;
readonly active: boolean;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const meshRef = useRef<Mesh<PlaneGeometry, MeshBasicMaterial>>(null);
const geometry = useMemo(() => new PlaneGeometry(2, 2), []);
@@ -405,10 +408,10 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
const time = timelineRef.current.elapsedMs / 1_000;
const radius = runeSample(decal.tracks[2], time, 0);
const rotation = runeSample(decal.tracks[1], time, 0);
const opacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const sourceOpacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const tint = runeSample(color.tracks[0], time, [1, 1, 1, 1]);
material.color.setRGB(tint[0], tint[1], tint[2]);
material.opacity = Math.max(0, Math.min(1, opacity * tint[3]));
material.opacity = Math.max(0, Math.min(1, sourceOpacity * tint[3] * opacity));
mesh.scale.set(radius, radius, 1);
mesh.rotation.z = rotation;
mesh.visible = time <= alpha.startTime + alpha.duration && material.opacity > 0.002;
@@ -421,7 +424,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
});
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
graph, textures, phase, quality, active, eventId, playbackRef,
graph, textures, phase, quality, active, eventId, playbackRef, opacity = 1,
}: {
readonly graph: RunewakerParticleGraph;
readonly textures: ReadonlyMap<string, Texture>;
@@ -430,6 +433,7 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
readonly active: boolean;
readonly eventId: number;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity?: number;
}) {
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
const motion = useMemo(() => runeMotion(graph, phase), [graph, phase]);
@@ -457,18 +461,18 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length))));
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} graph={graph} phase={phase}
layer={layer} texture={texture}
budget={budget} active={active} eventId={eventId + index * 271}
budget={budget} active={active} eventId={eventId + index * 271} opacity={opacity}
timelineRef={playbackRef} />;
})}
{graph.runeMesh && runeEntity && runeEmitter && textures.get(graph.runeMesh.textureAssetUrl)
? <RuneEntityEmitter graph={graph} emitter={runeEmitter} visualizer={runeEntity}
texture={textures.get(graph.runeMesh.textureAssetUrl)!}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617} opacity={opacity}
timelineRef={playbackRef} />
: null}
{decal && decalAlpha && decalColor && textures.get(decal.textureAssetUrl)
? <RuneGroundDecal decal={decal} alpha={decalAlpha} color={decalColor}
texture={textures.get(decal.textureAssetUrl)!} active={active} timelineRef={playbackRef} />
texture={textures.get(decal.textureAssetUrl)!} active={active} opacity={opacity} timelineRef={playbackRef} />
: null}
</group>;
});
+28
View File
@@ -50,6 +50,22 @@ function enqueue(
}
describe("combat-effect runtime budget", () => {
it("softens Flash Heal's impact without shrinking its cast visual", () => {
for (const abilityId of ["priest-flash-heal", "wow335-priest-flash-heal"]) {
expect(combatEffectVisualTuning(event(1, { abilityId })))
.toEqual({ scale: 0.38, opacity: 0.22 });
expect(combatEffectVisualTuning(event(2, { abilityId, phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
}
});
it("keeps Arcane Explosion's large cast sphere readable", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-mage-arcane-explosion", phase: "cast-start" })))
.toEqual({ scale: 0.65, opacity: 0.5 });
expect(combatEffectVisualTuning(event(2, { abilityId: "wow335-mage-arcane-explosion", phase: "impact" })))
.toEqual({ scale: 1, opacity: 1 });
});
it("contains the Dispel Magic impact without shrinking Mass Dispel", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-priest-dispel-magic" })))
.toEqual({ scale: 0.45, opacity: 0.4 });
@@ -59,6 +75,18 @@ describe("combat-effect runtime budget", () => {
.toEqual({ scale: 1, opacity: 1 });
});
it("softens the RoM Mage electric impacts without changing cast cues", () => {
for (const [sourceSpellId, expected] of [
[491168, { scale: 0.45, opacity: 0.32 }],
[491169, { scale: 0.6, opacity: 0.45 }],
] as const) {
expect(combatEffectVisualTuning(event(1, { source: "runewaker", sourceSpellId })))
.toEqual(expected);
expect(combatEffectVisualTuning(event(2, { source: "runewaker", sourceSpellId, phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
}
});
it("delays source-backed target impacts until their projectiles arrive", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
+12 -3
View File
@@ -17,13 +17,22 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32,
};
/** Keep the single-target Dispel Magic flash within the target silhouette. */
/** Keep bright single-target Priest impacts within the target silhouette. */
export function combatEffectVisualTuning(
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
event: Pick<CombatPresentationEvent, "abilityId" | "phase" | "source" | "sourceSpellId">,
): { readonly scale: number; readonly opacity: number } {
if ((event.abilityId === "priest-flash-heal" || event.abilityId === "wow335-priest-flash-heal")
&& event.phase === "impact") {
return { scale: 0.6, opacity: 0.4 };
return { scale: 0.38, opacity: 0.22 };
}
if (event.abilityId === "wow335-mage-arcane-explosion" && event.phase === "cast-start") {
return { scale: 0.65, opacity: 0.5 };
}
if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491168) {
return { scale: 0.45, opacity: 0.32 };
}
if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491169) {
return { scale: 0.6, opacity: 0.45 };
}
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
? { scale: 0.45, opacity: 0.4 }
+2
View File
@@ -9,6 +9,7 @@ import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
import { FrontSurface } from "./FrontSurface";
import { releasePointerLock } from "../game/inputManager";
import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import "./biteSizedEditor.css";
@@ -96,6 +97,7 @@ export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
{!collapsed && <div className="bite-sized-editor__body">
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
{inGame && <div><button onClick={focusBiteSizedEditorScene}>Walk in scene</button><p className="bite-sized-editor__hint">Click the world or Walk in scene, then use WASD. Hold right mouse to look; Space jumps. Panel controls pause movement.</p></div>}
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
{spot && <>