Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface DeathKnightRuneState {
|
||||
readonly baseType: Exclude<DeathKnightRuneType, "death">;
|
||||
readonly type: DeathKnightRuneType;
|
||||
readonly readyAt: number;
|
||||
readonly deathRuneExpiresAt?: number;
|
||||
}
|
||||
|
||||
export interface CombatActorCastState {
|
||||
|
||||
@@ -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
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
} }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface RomSourceComponent {
|
||||
}
|
||||
|
||||
export interface RomGeneratedAbilityLike {
|
||||
readonly classId?: string;
|
||||
readonly sourceSkillId?: number;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }] });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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] });
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 && <>
|
||||
|
||||
Reference in new issue
Block a user