Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8cb8a510 | ||
|
|
9be6f54c52 | ||
|
|
6a716fb8ca | ||
|
|
b3e0445e9c | ||
|
|
72a67ac1e3 | ||
|
|
89b6f4f79d | ||
|
|
3687c423e2 |
No files matched your search
@@ -0,0 +1,37 @@
|
|||||||
|
# Death Pact and Blood Gorged finding corrections
|
||||||
|
|
||||||
|
Tracked in [shared issue #19](https://git.whoagland.com/phenom/healer-man/issues/19).
|
||||||
|
Coordinator: Root `/root`, session `01a12399-cfab-7c72-8455-e0e4cb7e156c`.
|
||||||
|
|
||||||
|
The local Death Pact row (48743) supplies a 40% heal, a 100-yard minion-selection
|
||||||
|
radius, a 40 runic-power cost, a 120-second cooldown and a 1.5-second GCD.
|
||||||
|
Its hostile instant-kill conversion and tooltip-derived flat heal were wrong.
|
||||||
|
The explicit sacrifice effect now consumes exactly one living, unexpired undead
|
||||||
|
minion owned by the caster inside that radius and heals only that caster.
|
||||||
|
The heal cannot crit. Missing minions fail before resource, cooldown or GCD use.
|
||||||
|
Sacrifice produces a death record rather than damage-meter or experience credit.
|
||||||
|
Player and companion dispatch share minion selection and death-state mutation.
|
||||||
|
|
||||||
|
The [AzerothCore Death Pact handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp)
|
||||||
|
corroborates living, owned undead selection within 100 yards and a single victim.
|
||||||
|
This runtime admits only the two undead ghoul entries it already models through
|
||||||
|
Raise Dead (26125) and Army of the Dead (24207). Unknown summon families are not
|
||||||
|
assumed undead. Other undead minion types require a modeled source identity.
|
||||||
|
Automated non-healer sacrifices use the existing defensive health threshold;
|
||||||
|
this is simulator AI policy, not a spell-source trigger.
|
||||||
|
|
||||||
|
Blood Gorged's five local ranks contain only learn-spell effects (36), linking
|
||||||
|
the conditional damage and armor-penetration children. The catalog no longer
|
||||||
|
invents an active attack from that passive tooltip. Talent wrappers with only
|
||||||
|
learn effects and no activation cost, cast time or cooldown remain passive;
|
||||||
|
active and mixed-effect spells remain executable. The generator preserves the
|
||||||
|
same classification for future imports. Existing sourced Blood Gorged damage
|
||||||
|
and armor handling remains in `wow335DeathKnightBloodGorgedRuntime.ts`; see
|
||||||
|
`docs/WOW_DEATH_KNIGHT_BLOOD_GORGED.md` for its health and family-mask boundaries.
|
||||||
|
|
||||||
|
Focused validation covers real Raise Dead then Death Pact casts, hostile trash
|
||||||
|
and bosses, owner isolation, dead/expired/distant/non-undead minions, exact
|
||||||
|
cost/cooldown/GCD, overheal and noncritical event accounting, actual automatic
|
||||||
|
companion dispatch, and all five Blood Gorged wrapper ranks. Existing player and
|
||||||
|
companion Blood Gorged health/mask tests preserve the legitimate passive bonus.
|
||||||
|
The historical 873-cell campaign and source snapshots are unchanged.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Issue 18: maintenance cast selection
|
||||||
|
|
||||||
|
The benchmark controller scores a generic candidate list alongside the shared
|
||||||
|
rotation. A maintained aura rejected by the shared selector must also be
|
||||||
|
removed from that fallback list, or the next benchmark decision can cast it
|
||||||
|
again.
|
||||||
|
|
||||||
|
Current, unexpired auras are passed to the selector with their actual target
|
||||||
|
and aura ID. The selector suppresses the same applied aura only on the target
|
||||||
|
the candidate would use. An aura on another actor does not block the cast, and
|
||||||
|
a different aura in the same exclusive group remains eligible to replace it.
|
||||||
|
Expired and dispelled auras are absent from the active snapshot. Periodic
|
||||||
|
effects retain their source, target, and remaining-duration checks. An attack
|
||||||
|
or heal that also applies an aura remains eligible for its primary effect.
|
||||||
|
|
||||||
|
Focused regressions cover Protection Paladin L60 Greater Blessing of Sanctuary,
|
||||||
|
RoM Scout L30 Detection, target-scoped aura selection, and exclusive-group
|
||||||
|
replacement. Run them with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./node_modules/.bin/vitest run src/game/benchmark/benchmarkController.maintenance.test.ts src/game/combatRotation.test.ts --reporter=dot
|
||||||
|
```
|
||||||
@@ -396,7 +396,11 @@ try {
|
|||||||
// with the same name on the class skill line. That proc has no player
|
// with the same name on the class skill line. That proc has no player
|
||||||
// activation mechanics, so retain it for the spellbook but classify it
|
// activation mechanics, so retain it for the spellbook but classify it
|
||||||
// as passive instead of exposing it as a castable action.
|
// as passive instead of exposing it as a castable action.
|
||||||
const passive = passiveTalentNames.has(name) && ranks.every((spell) => (
|
const passive = (passiveTalentNames.has(name) || (Boolean(talentNodeId)
|
||||||
|
&& ranks.every((spell) => {
|
||||||
|
const effects = spell.effects.filter((effect) => effect.effectType !== 0);
|
||||||
|
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
|
||||||
|
}))) && ranks.every((spell) => (
|
||||||
spell.castTimeMs === 0
|
spell.castTimeMs === 0
|
||||||
&& spell.recoveryTimeMs === 0
|
&& spell.recoveryTimeMs === 0
|
||||||
&& spell.categoryRecoveryTimeMs === 0
|
&& spell.categoryRecoveryTimeMs === 0
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export type AbilityEffect =
|
|||||||
| ({ 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: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
| ({ 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: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
|
||||||
|
| { readonly kind: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number }
|
||||||
| {
|
| {
|
||||||
readonly kind: "summon";
|
readonly kind: "summon";
|
||||||
readonly creatureId: number;
|
readonly creatureId: number;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
|||||||
for (const effect of ability.effects) {
|
for (const effect of ability.effects) {
|
||||||
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
||||||
if (effect.kind === "execute") categories.add("execute");
|
if (effect.kind === "execute") categories.add("execute");
|
||||||
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
|
if (effect.kind === "heal" || effect.kind === "hot" || effect.kind === "sacrifice-minion") categories.add("heal");
|
||||||
if (effect.kind === "shield") categories.add("mitigation");
|
if (effect.kind === "shield") categories.add("mitigation");
|
||||||
if (effect.kind === "taunt") categories.add("taunt");
|
if (effect.kind === "taunt") categories.add("taunt");
|
||||||
if (effect.kind === "interrupt") categories.add("interrupt");
|
if (effect.kind === "interrupt") categories.add("interrupt");
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilityAtLevel, abilitiesForClass } from "../abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||||
|
import { useCombatStore } from "../combatStore";
|
||||||
|
import { clearMobRuntimeRegistry } from "../mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry } from "../partyRuntimeRegistry";
|
||||||
|
import { BenchmarkController } from "./benchmarkController";
|
||||||
|
import { BENCHMARK_SCENARIOS } from "./benchmarkScenarios";
|
||||||
|
|
||||||
|
const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 };
|
||||||
|
|
||||||
|
describe("benchmark maintenance aura fallback", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-admit an active self buff after shared rotation rejects it", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||||
|
const sanctuary = abilityAtLevel(
|
||||||
|
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
useCombatStore.setState({ abilities: [sanctuary] });
|
||||||
|
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||||
|
&& aura.definition.id === sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||||
|
|
||||||
|
const decision = new BenchmarkController().act(2_000, "tank", scenario, false);
|
||||||
|
|
||||||
|
expect(decision.cast).toBe(false);
|
||||||
|
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||||
|
=== sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the same maintenance cast after its aura expires or is removed", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||||
|
const sanctuary = abilityAtLevel(
|
||||||
|
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
const auraId = sanctuary.effects.find((effect) => effect.kind === "apply-aura")!.aura.id;
|
||||||
|
useCombatStore.setState({ abilities: [sanctuary] });
|
||||||
|
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
auras: state.auras.map((aura) => aura.definition.id === auraId ? { ...aura, expiresAt: 1_999 } : aura),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(new BenchmarkController().act(2_000, "tank", scenario, false).cast).toBe(true);
|
||||||
|
|
||||||
|
useCombatStore.setState({ auras: [] });
|
||||||
|
expect(new BenchmarkController().act(4_000, "tank", scenario, false).cast).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-admit an active RoM self debuff through generic candidate scoring", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", statRuleset: "rom", role: "damage", level: 30 });
|
||||||
|
const detection = abilityAtLevel(
|
||||||
|
abilitiesForClass("rom-scout").find((ability) => ability.id === "rom-scout-primary-8")!,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
useCombatStore.setState({ abilities: [detection] });
|
||||||
|
expect(useCombatStore.getState().castAbility(detection.id, { now: 100 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||||
|
&& aura.definition.id === detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||||
|
|
||||||
|
const decision = new BenchmarkController().act(2_000, "damage", scenario, false);
|
||||||
|
|
||||||
|
expect(decision.cast).toBe(false);
|
||||||
|
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||||
|
=== detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
|||||||
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
|
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
|
||||||
import {
|
import {
|
||||||
COMBAT_ROTATION_VERSION,
|
COMBAT_ROTATION_VERSION,
|
||||||
|
abilityAuraMaintainedOnTarget,
|
||||||
evaluateCombatRotation,
|
evaluateCombatRotation,
|
||||||
nativeEarlyPriestHealReserveBlocksAbility,
|
nativeEarlyPriestHealReserveBlocksAbility,
|
||||||
type RotationContext,
|
type RotationContext,
|
||||||
@@ -52,6 +53,15 @@ interface ScoredAbility {
|
|||||||
readonly friendlyTarget: FriendlyTarget | null;
|
readonly friendlyTarget: FriendlyTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoredAbilityAuraTargetId(candidate: ScoredAbility, combat: CombatState): string | null {
|
||||||
|
const { ability, friendlyTarget } = candidate;
|
||||||
|
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||||
|
return friendlyTarget?.id ?? combat.selectedFriendlyActorId ?? PLAYER_AGGRO_ID;
|
||||||
|
}
|
||||||
|
if (ability.target === "hostile" || ability.target === "any") return combat.selectedTargetId;
|
||||||
|
return PLAYER_AGGRO_ID;
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||||
"already-casting",
|
"already-casting",
|
||||||
"global-cooldown",
|
"global-cooldown",
|
||||||
@@ -287,13 +297,10 @@ export class BenchmarkController {
|
|||||||
maintainedAbilityIds: new Set([
|
maintainedAbilityIds: new Set([
|
||||||
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
|
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
|
||||||
.map((effect) => effect.sourceAbilityId),
|
.map((effect) => effect.sourceAbilityId),
|
||||||
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
|
|
||||||
&& (aura.expiresAt === null || aura.expiresAt > now))
|
|
||||||
.flatMap((aura) => availableAbilities
|
|
||||||
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
|
||||||
&& effect.aura.id === aura.definition.id))
|
|
||||||
.map((ability) => ability.id)),
|
|
||||||
]),
|
]),
|
||||||
|
maintainedAuras: combat.auras
|
||||||
|
.filter((aura) => aura.expiresAt === null || aura.expiresAt > now)
|
||||||
|
.map((aura) => ({ auraId: aura.definition.id, targetId: aura.targetId })),
|
||||||
maintainedPeriodicEffects: combat.effects.map((effect) => ({
|
maintainedPeriodicEffects: combat.effects.map((effect) => ({
|
||||||
abilityId: effect.sourceAbilityId,
|
abilityId: effect.sourceAbilityId,
|
||||||
sourceActorId: effect.sourceActorId,
|
sourceActorId: effect.sourceActorId,
|
||||||
@@ -315,9 +322,11 @@ export class BenchmarkController {
|
|||||||
};
|
};
|
||||||
const sharedDecision = evaluateCombatRotation(rotationContext);
|
const sharedDecision = evaluateCombatRotation(rotationContext);
|
||||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||||
const ability = candidates[index].ability;
|
const candidate = candidates[index];
|
||||||
|
const ability = candidate.ability;
|
||||||
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|
||||||
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
|
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))
|
||||||
|
|| abilityAuraMaintainedOnTarget(ability, rotationContext, scoredAbilityAuraTargetId(candidate, combat))) {
|
||||||
candidates.splice(index, 1);
|
candidates.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface StructuredCombatEvent {
|
|||||||
readonly sourceActorId: string;
|
readonly sourceActorId: string;
|
||||||
readonly targetActorId: string;
|
readonly targetActorId: string;
|
||||||
readonly abilityId?: string;
|
readonly abilityId?: string;
|
||||||
|
/** Source ability that caused an interruption; abilityId remains the canceled cast. */
|
||||||
|
readonly causeAbilityId?: string;
|
||||||
readonly school?: DamageSchool;
|
readonly school?: DamageSchool;
|
||||||
readonly rawAmount: number;
|
readonly rawAmount: number;
|
||||||
readonly effectiveAmount: number;
|
readonly effectiveAmount: number;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
abilityIsPrecombatPreparation,
|
abilityIsPrecombatPreparation,
|
||||||
abilityHasRotationSemantics,
|
abilityHasRotationSemantics,
|
||||||
|
abilityAuraMaintainedOnTarget,
|
||||||
CLASSIC_ROTATION_PROFILES,
|
CLASSIC_ROTATION_PROFILES,
|
||||||
COMBAT_ROTATION_VERSION,
|
COMBAT_ROTATION_VERSION,
|
||||||
evaluateCombatRotation,
|
evaluateCombatRotation,
|
||||||
@@ -764,6 +765,52 @@ describe("shared combat rotations", () => {
|
|||||||
.toBe(lightningBolt.id);
|
.toBe(lightningBolt.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches maintained auras by recipient and exact effect while allowing a group override", () => {
|
||||||
|
const auraAbility = (id: string, auraId: string, recipient: "caster" | "ability-target" = "caster") => semanticAbility(id, [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: auraId,
|
||||||
|
name: id,
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: 300_000,
|
||||||
|
exclusiveGroup: "test:stance",
|
||||||
|
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }],
|
||||||
|
},
|
||||||
|
recipient,
|
||||||
|
sourceEffectType: 6,
|
||||||
|
sourceAuraType: 22,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}], { target: "self" });
|
||||||
|
const stance = auraAbility("stance", "stance-aura");
|
||||||
|
const replacementStance = auraAbility("replacement-stance", "replacement-aura");
|
||||||
|
const allyAura = {
|
||||||
|
...auraAbility("ally-aura", "ally-aura-id", "ability-target"),
|
||||||
|
target: "friendly" as const,
|
||||||
|
};
|
||||||
|
const activeOnSelf = context({
|
||||||
|
selfId: "player",
|
||||||
|
maintainedAuras: [{ auraId: "stance-aura", targetId: "player" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(abilityAuraMaintainedOnTarget(stance, activeOnSelf, "player")).toBe(true);
|
||||||
|
expect(abilityAuraMaintainedOnTarget(allyAura, activeOnSelf, "ally")).toBe(false);
|
||||||
|
expect(abilityAuraMaintainedOnTarget(stance, {
|
||||||
|
...activeOnSelf,
|
||||||
|
maintainedAuras: [{ auraId: "stance-aura", targetId: "ally" }],
|
||||||
|
}, "player")).toBe(false);
|
||||||
|
expect(abilityAuraMaintainedOnTarget(allyAura, {
|
||||||
|
...activeOnSelf,
|
||||||
|
maintainedAuras: [{ auraId: "ally-aura-id", targetId: "player" }],
|
||||||
|
}, "player")).toBe(true);
|
||||||
|
expect(abilityAuraMaintainedOnTarget(replacementStance, activeOnSelf, "player")).toBe(false);
|
||||||
|
expect(abilityAuraMaintainedOnTarget({
|
||||||
|
...stance,
|
||||||
|
effects: [...stance.effects, { kind: "damage", coefficient: 1 }],
|
||||||
|
}, activeOnSelf, "player")).toBe(false);
|
||||||
|
expect(abilityAuraMaintainedOnTarget(stance, { ...activeOnSelf, maintainedAuras: [] }, "player")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("maintains Earth Shield on a valid full-health ally", () => {
|
it("maintains Earth Shield on a valid full-health ally", () => {
|
||||||
const earthShield = ability("shaman", "Earth Shield");
|
const earthShield = ability("shaman", "Earth Shield");
|
||||||
const shaman = context({
|
const shaman = context({
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export interface RotationContext {
|
|||||||
readonly targetId: string;
|
readonly targetId: string;
|
||||||
readonly remainingMs: number;
|
readonly remainingMs: number;
|
||||||
}[];
|
}[];
|
||||||
|
/** Active applied auras with their actual recipient, filtered for expiry. */
|
||||||
|
readonly maintainedAuras?: readonly {
|
||||||
|
readonly auraId: string;
|
||||||
|
readonly targetId: string;
|
||||||
|
}[];
|
||||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||||
readonly hasActivePet?: boolean;
|
readonly hasActivePet?: boolean;
|
||||||
@@ -378,6 +383,36 @@ function targetForAbility(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this applied-aura action is already active on the recipient it would use. */
|
||||||
|
export function abilityAuraMaintainedOnTarget(
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
context: RotationContext,
|
||||||
|
targetId: string | null,
|
||||||
|
): boolean {
|
||||||
|
// An aura can be a side effect of an attack. Keep those casts available for
|
||||||
|
// their damage or healing; this guard only handles maintenance actions.
|
||||||
|
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot"
|
||||||
|
|| effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield"
|
||||||
|
|| effect.kind === "finisher-damage" || effect.kind === "execute")) return false;
|
||||||
|
const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura");
|
||||||
|
if (auraEffects.length === 0) return false;
|
||||||
|
const maintainedAuras = context.maintainedAuras;
|
||||||
|
if (maintainedAuras !== undefined) {
|
||||||
|
return auraEffects.some((effect) => {
|
||||||
|
const recipientId = effect.recipient === "caster"
|
||||||
|
? context.selfId ?? null
|
||||||
|
: effect.recipient === "pet" ? null : targetId;
|
||||||
|
return recipientId !== null && maintainedAuras.some((aura) => (
|
||||||
|
aura.targetId === recipientId && aura.auraId === effect.aura.id
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Older selector callers only expose caster-owned exclusive groups. Keep
|
||||||
|
// their conservative behavior until they provide recipient-scoped auras.
|
||||||
|
return auraEffects.some((effect) => Boolean(effect.aura.exclusiveGroup
|
||||||
|
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||||
|
}
|
||||||
|
|
||||||
interface ScoredRotationAbility {
|
interface ScoredRotationAbility {
|
||||||
readonly ability: AbilityDefinition;
|
readonly ability: AbilityDefinition;
|
||||||
readonly score: number;
|
readonly score: number;
|
||||||
@@ -410,13 +445,20 @@ function scoreAbility(
|
|||||||
&& ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot");
|
&& ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot");
|
||||||
const coaPeriodic = context.maintainedPeriodicEffects !== undefined && Boolean(context.selfId)
|
const coaPeriodic = context.maintainedPeriodicEffects !== undefined && Boolean(context.selfId)
|
||||||
&& usesCoaTargetedPeriodicMaintenance(ability, context);
|
&& usesCoaTargetedPeriodicMaintenance(ability, context);
|
||||||
|
const maintenanceTargetId = targetForAbility(ability, context, lowest)
|
||||||
|
?? (ability.target === "self" ? context.selfId ?? null : null);
|
||||||
const periodicMaintained = coaPeriodic
|
const periodicMaintained = coaPeriodic
|
||||||
? coaPeriodicActionMaintainedOnTarget(ability, context, targetForAbility(ability, context, lowest))
|
? coaPeriodicActionMaintainedOnTarget(ability, context, maintenanceTargetId)
|
||||||
: !nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false);
|
: !nativePeriodic && (context.maintainedPeriodicEffects !== undefined
|
||||||
|
? context.maintainedPeriodicEffects.some((effect) => (
|
||||||
|
effect.abilityId === ability.id
|
||||||
|
&& effect.sourceActorId === context.selfId
|
||||||
|
&& effect.targetId === maintenanceTargetId
|
||||||
|
&& effect.remainingMs > 0
|
||||||
|
))
|
||||||
|
: (context.maintainedAbilityIds?.has(ability.id) ?? false));
|
||||||
const maintained = periodicMaintained
|
const maintained = periodicMaintained
|
||||||
|| ability.effects.some((effect) => effect.kind === "apply-aura"
|
|| abilityAuraMaintainedOnTarget(ability, context, maintenanceTargetId);
|
||||||
&& Boolean(effect.aura.exclusiveGroup
|
|
||||||
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
|
||||||
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
|
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
|
||||||
const petSummon = isPetSummon(ability);
|
const petSummon = isPetSummon(ability);
|
||||||
const offensiveSummon = isOffensiveSummon(ability);
|
const offensiveSummon = isOffensiveSummon(ability);
|
||||||
@@ -679,6 +721,12 @@ function scoreAbility(
|
|||||||
score += injured.length ? -3_000 : 300;
|
score += injured.length ? -3_000 : 300;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion")) {
|
||||||
|
// Automated minion sacrifices are emergency self-heals; the cast gate
|
||||||
|
// still requires a living owned source minion before resources are spent.
|
||||||
|
if (selfHealth >= cooldownPolicy.generic.defensiveHealthPercent / 100) return null;
|
||||||
|
score += 140_000;
|
||||||
|
}
|
||||||
if (categories.has("mitigation")) {
|
if (categories.has("mitigation")) {
|
||||||
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
|
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
|
||||||
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
|
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
|
||||||
|
|||||||
@@ -1536,7 +1536,7 @@ describe("combat store", () => {
|
|||||||
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
|
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
|
||||||
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
|
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
|
||||||
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
||||||
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||||
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
|
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+152
-9
@@ -1,3 +1,4 @@
|
|||||||
|
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||||
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
||||||
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
|
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
|
||||||
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
|
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
|
||||||
@@ -461,6 +462,7 @@ import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem
|
|||||||
import {
|
import {
|
||||||
PLAYER_EQUIPMENT_OWNER_ID,
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
activeEquipmentStatsForClass,
|
activeEquipmentStatsForClass,
|
||||||
|
classCanEquipItem,
|
||||||
equipInventoryItem,
|
equipInventoryItem,
|
||||||
normalizeEquipment,
|
normalizeEquipment,
|
||||||
unequipInventorySlot,
|
unequipInventorySlot,
|
||||||
@@ -1712,6 +1714,7 @@ export interface CombatState {
|
|||||||
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
|
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
|
||||||
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
|
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
|
||||||
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
|
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
|
||||||
|
sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>, origin: CombatPosition, now?: number) => boolean;
|
||||||
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
|
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
|
||||||
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
|
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
|
||||||
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
|
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
|
||||||
@@ -1822,6 +1825,30 @@ let summonSequence = 0;
|
|||||||
let areaEffectSequence = 0;
|
let areaEffectSequence = 0;
|
||||||
let combatEventSequence = 0;
|
let combatEventSequence = 0;
|
||||||
|
|
||||||
|
function sacrificeMinionInWork(
|
||||||
|
work: Pick<MutableCastWork, "summons" | "actors" | "auras" | "effects" | "combatEvents">,
|
||||||
|
ownerActorId: string,
|
||||||
|
abilityId: string,
|
||||||
|
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
|
||||||
|
origin: CombatPosition,
|
||||||
|
now: number,
|
||||||
|
): boolean {
|
||||||
|
const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now);
|
||||||
|
if (!minion) return false;
|
||||||
|
work.summons = work.summons.map((summon) => summon.id === minion.id
|
||||||
|
? { ...summon, health: 0, targetId: null, cast: null } : summon);
|
||||||
|
const actor = work.actors[minion.id];
|
||||||
|
if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null };
|
||||||
|
work.auras = work.auras.filter((aura) => aura.targetId !== minion.id);
|
||||||
|
work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id);
|
||||||
|
recordCombatEventInWork(work, {
|
||||||
|
occurredAt: now, kind: "death", sourceActorId: ownerActorId,
|
||||||
|
targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0,
|
||||||
|
overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function initialSummonCombatRuntime(): Pick<CombatSummon,
|
function initialSummonCombatRuntime(): Pick<CombatSummon,
|
||||||
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
||||||
> {
|
> {
|
||||||
@@ -1908,7 +1935,7 @@ function recordCombatEvent(
|
|||||||
return appendCombatEvent(events, input, combatEventSequence);
|
return appendCombatEvent(events, input, combatEventSequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
|
function recordCombatEventInWork(work: Pick<MutableCastWork, "combatEvents">, input: CombatEventInput): void {
|
||||||
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
|
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1973,10 +2000,16 @@ function emitPlayerAbilityPresentation(
|
|||||||
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
|
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
function emitPlayerAbilityCancellation(
|
||||||
|
abilityId: string,
|
||||||
|
occurredAt: number,
|
||||||
|
origin: CombatPosition,
|
||||||
|
targetId: string | null = null,
|
||||||
|
targetPosition: CombatPosition | null = null,
|
||||||
|
): void {
|
||||||
const ability = abilityById(abilityId);
|
const ability = abilityById(abilityId);
|
||||||
if (!ability) return;
|
if (!ability) return;
|
||||||
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, targetId, targetPosition ?? origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitPlayerAbilityResolutionPresentation(
|
function emitPlayerAbilityResolutionPresentation(
|
||||||
@@ -2384,6 +2417,33 @@ function playerEquippedMeleeWeapon(
|
|||||||
return item?.weaponType ? item.stats.meleeWeapon : null;
|
return item?.weaponType ? item.stats.meleeWeapon : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playerHasExplicitWoWWarriorDualWield(
|
||||||
|
state: Pick<CombatState, "classId" | "level" | "statRuleset" | "equipment" | "inventory">,
|
||||||
|
): boolean {
|
||||||
|
if (state.classId !== "warrior" || state.level < 20
|
||||||
|
|| normalizeCharacterStatRuleset(state.statRuleset, state.classId) !== "wow") return false;
|
||||||
|
|
||||||
|
const itemForSlot = (slot: "main-hand" | "off-hand"): InventoryItem | null => {
|
||||||
|
const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot];
|
||||||
|
return assignedId ? state.inventory.find((item) => item.instanceId === assignedId) ?? null : null;
|
||||||
|
};
|
||||||
|
const mainHand = itemForSlot("main-hand");
|
||||||
|
const offHand = itemForSlot("off-hand");
|
||||||
|
const isClassLegalMeleeWeapon = (item: InventoryItem | null, allowedSlots: readonly string[]): boolean => Boolean(
|
||||||
|
item
|
||||||
|
&& allowedSlots.includes(item.inventorySlot)
|
||||||
|
&& item.armorType !== "shield"
|
||||||
|
&& item.weaponType
|
||||||
|
&& item.stats.meleeWeapon
|
||||||
|
&& classCanEquipItem(state.classId, item, { level: state.level })
|
||||||
|
);
|
||||||
|
|
||||||
|
// WoW Warrior spell 674 is learned at level 20. Only actual, legal one-hand
|
||||||
|
// assignments count; generated heirloom stats never grant a second swing.
|
||||||
|
return isClassLegalMeleeWeapon(mainHand, ["one-hand", "main-hand"])
|
||||||
|
&& isClassLegalMeleeWeapon(offHand, ["one-hand", "off-hand"]);
|
||||||
|
}
|
||||||
|
|
||||||
function playerEquippedRangedWeapon(
|
function playerEquippedRangedWeapon(
|
||||||
state: Pick<CombatState,
|
state: Pick<CombatState,
|
||||||
"classId" | "secondaryClassId" | "specializationId" | "gearRole" | "level" | "equipment" | "inventory"
|
"classId" | "secondaryClassId" | "specializationId" | "gearRole" | "level" | "equipment" | "inventory"
|
||||||
@@ -7656,6 +7716,7 @@ function damageMobInWork(
|
|||||||
primarySaintsBlessingMultiplierAlreadyApplied = false,
|
primarySaintsBlessingMultiplierAlreadyApplied = false,
|
||||||
directPhysicalArmorAttackerLevel?: number,
|
directPhysicalArmorAttackerLevel?: number,
|
||||||
attackHand?: import("./combatEvents").StructuredCombatEvent["attackHand"],
|
attackHand?: import("./combatEvents").StructuredCombatEvent["attackHand"],
|
||||||
|
reportedAbilityId?: string,
|
||||||
): DamageResult {
|
): DamageResult {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||||
@@ -7745,12 +7806,14 @@ function damageMobInWork(
|
|||||||
const primaryApplied = secondaryDamage
|
const primaryApplied = secondaryDamage
|
||||||
? Math.min(applied, Math.max(0, Math.round(primaryRemainingDamage)))
|
? Math.min(applied, Math.max(0, Math.round(primaryRemainingDamage)))
|
||||||
: applied;
|
: applied;
|
||||||
|
// This override labels the primary event only; the ThreatSource stays authoritative for mechanics.
|
||||||
|
const combatEventAbilityId = reportedAbilityId ?? source.abilityId;
|
||||||
recordCombatEventInWork(work, {
|
recordCombatEventInWork(work, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
kind: "damage",
|
kind: "damage",
|
||||||
sourceActorId: source.actorId,
|
sourceActorId: source.actorId,
|
||||||
targetActorId: id,
|
targetActorId: id,
|
||||||
...(source.abilityId ? { abilityId: source.abilityId } : {}),
|
...(combatEventAbilityId ? { abilityId: combatEventAbilityId } : {}),
|
||||||
school,
|
school,
|
||||||
...(attackHand ? { attackHand } : {}),
|
...(attackHand ? { attackHand } : {}),
|
||||||
rawAmount: combatResult?.rawDamage !== undefined
|
rawAmount: combatResult?.rawDamage !== undefined
|
||||||
@@ -9439,6 +9502,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork(
|
|||||||
source,
|
source,
|
||||||
school,
|
school,
|
||||||
work.gearStats.spellPenetration,
|
work.gearStats.spellPenetration,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9552,6 +9621,7 @@ function resolveAndDamageMobInWork(
|
|||||||
criticalChanceBonusPercent = 0,
|
criticalChanceBonusPercent = 0,
|
||||||
weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS,
|
weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS,
|
||||||
selectedDbcSpellId?: number,
|
selectedDbcSpellId?: number,
|
||||||
|
reportedAbilityId?: string,
|
||||||
): DamageResult {
|
): DamageResult {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||||
@@ -9919,6 +9989,7 @@ function resolveAndDamageMobInWork(
|
|||||||
: sourceWeaponChannel === "off-hand" ? "offhand"
|
: sourceWeaponChannel === "off-hand" ? "offhand"
|
||||||
: sourceWeaponChannel === "ranged" ? "ranged" : undefined
|
: sourceWeaponChannel === "ranged" ? "ranged" : undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
reportedAbilityId,
|
||||||
);
|
);
|
||||||
maybeProcShamanFlurryInWork(
|
maybeProcShamanFlurryInWork(
|
||||||
work,
|
work,
|
||||||
@@ -13665,6 +13736,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
||||||
else if (ability.name === "Enraged Regeneration"
|
else if (ability.name === "Enraged Regeneration"
|
||||||
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
|
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
|
||||||
|
else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
|
||||||
|
&& !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met";
|
||||||
else if (state.classId === "hunter" && ability.name === "Revive Pet"
|
else if (state.classId === "hunter" && ability.name === "Revive Pet"
|
||||||
&& !state.summons.some((summon) => summon.health <= 0
|
&& !state.summons.some((summon) => summon.health <= 0
|
||||||
&& (summon.id.startsWith("player-pet-")
|
&& (summon.id.startsWith("player-pet-")
|
||||||
@@ -16879,6 +16952,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (effect.kind === "sacrifice-minion") {
|
||||||
|
if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue;
|
||||||
|
const amount = Math.round(resolveAuraHealingValue(
|
||||||
|
resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth),
|
||||||
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"),
|
||||||
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken",
|
||||||
|
));
|
||||||
|
const healed = healPlayerInWork(work, amount);
|
||||||
|
work.totalHealing += healed;
|
||||||
|
recordCombatEventInWork(work, {
|
||||||
|
occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID,
|
||||||
|
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool,
|
||||||
|
rawAmount: amount, effectiveAmount: healed, overheal: amount - healed,
|
||||||
|
absorbed: 0, critical: false,
|
||||||
|
});
|
||||||
|
addHealingThreatInWork(work, castThreatSource, healed, now);
|
||||||
} else if (effect.kind === "execute") {
|
} else if (effect.kind === "execute") {
|
||||||
for (const id of affectedMobIds) {
|
for (const id of affectedMobIds) {
|
||||||
const mob = work.mobs[id];
|
const mob = work.mobs[id];
|
||||||
@@ -18676,6 +18765,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
return applied;
|
return applied;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => {
|
||||||
|
const state = get();
|
||||||
|
const work = { summons: [...state.summons], actors: { ...state.actors },
|
||||||
|
auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] };
|
||||||
|
if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false;
|
||||||
|
set(work);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
healActor: (id, amount, request) => {
|
healActor: (id, amount, request) => {
|
||||||
const now = request.now ?? combatNow();
|
const now = request.now ?? combatNow();
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -20568,7 +20666,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt });
|
if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt });
|
||||||
}
|
}
|
||||||
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
||||||
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
let playerControl: {
|
||||||
|
readonly mechanic: EnemyControlMechanic;
|
||||||
|
readonly endsAt: number;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly causeAbilityId: string;
|
||||||
|
} | null = null;
|
||||||
const memberControls: Array<{
|
const memberControls: Array<{
|
||||||
readonly memberId: string;
|
readonly memberId: string;
|
||||||
readonly mechanic: EnemyControlMechanic;
|
readonly mechanic: EnemyControlMechanic;
|
||||||
@@ -21452,7 +21555,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now);
|
const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now);
|
||||||
if (!control) continue;
|
if (!control) continue;
|
||||||
if (actorId === PLAYER_AGGRO_ID) {
|
if (actorId === PLAYER_AGGRO_ID) {
|
||||||
if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control;
|
if (!playerControl || control.endsAt >= playerControl.endsAt) {
|
||||||
|
playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id };
|
||||||
|
}
|
||||||
} else memberControls.push({ memberId: actorId, ...control });
|
} else memberControls.push({ memberId: actorId, ...control });
|
||||||
}
|
}
|
||||||
} else if (effect.kind === "control") {
|
} else if (effect.kind === "control") {
|
||||||
@@ -21513,7 +21618,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
: auraMasteryDurationMs;
|
: auraMasteryDurationMs;
|
||||||
const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs };
|
const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs };
|
||||||
if (actorId === PLAYER_AGGRO_ID) {
|
if (actorId === PLAYER_AGGRO_ID) {
|
||||||
if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control;
|
if (!playerControl || control.endsAt >= playerControl.endsAt) {
|
||||||
|
playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else memberControls.push({ memberId: actorId, ...control });
|
else memberControls.push({ memberId: actorId, ...control });
|
||||||
namedAuras = [...applyCombatAura(namedAuras, {
|
namedAuras = [...applyCombatAura(namedAuras, {
|
||||||
@@ -21724,11 +21831,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
const controlInterruptedCast = playerControl && state.activeCast
|
||||||
|
? { cast: state.activeCast, control: playerControl }
|
||||||
|
: null;
|
||||||
if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) {
|
if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) {
|
||||||
set({
|
set({
|
||||||
mobs,
|
mobs,
|
||||||
enemyPeriodicEffects,
|
enemyPeriodicEffects,
|
||||||
auras: namedAuras,
|
auras: namedAuras,
|
||||||
|
...(controlInterruptedCast ? {
|
||||||
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||||
|
occurredAt: now,
|
||||||
|
kind: "interrupt",
|
||||||
|
sourceActorId: controlInterruptedCast.control.sourceActorId,
|
||||||
|
targetActorId: controlInterruptedCast.cast.targetId ?? PLAYER_AURA_ENTITY_ID,
|
||||||
|
abilityId: controlInterruptedCast.cast.abilityId,
|
||||||
|
causeAbilityId: controlInterruptedCast.control.causeAbilityId,
|
||||||
|
school: controlInterruptedCast.cast.school,
|
||||||
|
rawAmount: 0,
|
||||||
|
effectiveAmount: 0,
|
||||||
|
overheal: 0,
|
||||||
|
absorbed: 0,
|
||||||
|
critical: false,
|
||||||
|
outcome: `control:${controlInterruptedCast.control.mechanic}`,
|
||||||
|
}),
|
||||||
|
} : {}),
|
||||||
...(playerControl
|
...(playerControl
|
||||||
? {
|
? {
|
||||||
...(state.activeCast
|
...(state.activeCast
|
||||||
@@ -21748,6 +21875,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (controlInterruptedCast) {
|
||||||
|
emitPlayerAbilityCancellation(
|
||||||
|
controlInterruptedCast.cast.abilityId,
|
||||||
|
now,
|
||||||
|
state.playerPosition,
|
||||||
|
controlInterruptedCast.cast.targetId,
|
||||||
|
controlInterruptedCast.cast.targetId
|
||||||
|
? getMobPosition(controlInterruptedCast.cast.targetId)
|
||||||
|
?? actorPosition(state, controlInterruptedCast.cast.targetId)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
let appliedDamage = 0;
|
let appliedDamage = 0;
|
||||||
for (const hit of incomingHits) {
|
for (const hit of incomingHits) {
|
||||||
if (hit.actorId === PLAYER_AURA_ENTITY_ID
|
if (hit.actorId === PLAYER_AURA_ENTITY_ID
|
||||||
@@ -24337,13 +24476,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom"
|
const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom"
|
||||||
? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats,
|
? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats,
|
||||||
state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined;
|
state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined;
|
||||||
|
const explicitWarriorDualWield = playerHasExplicitWoWWarriorDualWield(state);
|
||||||
const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue"
|
const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue"
|
||||||
|| state.classId === "shaman" || state.classId === "death-knight"
|
|| state.classId === "shaman" || state.classId === "death-knight" || explicitWarriorDualWield
|
||||||
? playerEquippedMeleeWeapon(state, "off-hand")
|
? playerEquippedMeleeWeapon(state, "off-hand")
|
||||||
: null;
|
: null;
|
||||||
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue"
|
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue"
|
||||||
|| state.classId === "shaman"
|
|| state.classId === "shaman"
|
||||||
|| (state.classId === "death-knight" && Boolean(offHandWeapon));
|
|| (state.classId === "death-knight" && Boolean(offHandWeapon))
|
||||||
|
|| explicitWarriorDualWield;
|
||||||
const mainHandWeapon = supportsPlayerOffhandSwings
|
const mainHandWeapon = supportsPlayerOffhandSwings
|
||||||
? playerEquippedMeleeWeapon(state, "main-hand")
|
? playerEquippedMeleeWeapon(state, "main-hand")
|
||||||
: null;
|
: null;
|
||||||
@@ -24407,6 +24548,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
{ isAutoAttack: true, isDualWielding },
|
{ isAutoAttack: true, isDualWielding },
|
||||||
0,
|
0,
|
||||||
mainHandSpeedMs,
|
mainHandSpeedMs,
|
||||||
|
undefined,
|
||||||
|
"player-auto-attack",
|
||||||
);
|
);
|
||||||
const runeWeaponMainHand = state.classId === "death-knight"
|
const runeWeaponMainHand = state.classId === "death-knight"
|
||||||
&& result.combatResult?.landed
|
&& result.combatResult?.landed
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||||
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
||||||
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||||
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
||||||
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|
|||||||
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
|
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
|
||||||
}
|
}
|
||||||
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
|
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
|
||||||
|
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
|
||||||
|
&& !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false;
|
||||||
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
|
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
|
||||||
member.classId,
|
member.classId,
|
||||||
member.secondaryClassId,
|
member.secondaryClassId,
|
||||||
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
|
|||||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||||
friendlyTargets,
|
friendlyTargets,
|
||||||
maintainedAbilityIds,
|
maintainedAbilityIds,
|
||||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].includes(effect.kind)
|
||||||
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
||||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||||
@@ -4587,6 +4590,17 @@ function executePartyAbility(
|
|||||||
ability.id,
|
ability.id,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
} else if (effect.kind === "sacrifice-minion") {
|
||||||
|
if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue;
|
||||||
|
const result = useCombatStore.getState().healActor(member.id,
|
||||||
|
Math.round(member.maxHealth * effect.percentMaxHealth), {
|
||||||
|
sourceActorId: member.id, abilityId: ability.id, school: spellSchool,
|
||||||
|
criticalChance: 0, now,
|
||||||
|
});
|
||||||
|
effectiveAmount += result.effectiveAmount;
|
||||||
|
directHealingRawAmount += result.rawAmount;
|
||||||
|
combat = useCombatStore.getState();
|
||||||
|
runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]);
|
||||||
} else if (effect.kind === "execute") {
|
} else if (effect.kind === "execute") {
|
||||||
for (const hostileTargetId of hostileTargets) {
|
for (const hostileTargetId of hostileTargets) {
|
||||||
const target = useCombatStore.getState().mobs[hostileTargetId];
|
const target = useCombatStore.getState().mobs[hostileTargetId];
|
||||||
@@ -5147,7 +5161,7 @@ function executePartyAbility(
|
|||||||
}
|
}
|
||||||
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
|
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
|
||||||
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
|
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
|
||||||
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
|
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) {
|
||||||
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
|
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
|
||||||
}
|
}
|
||||||
if (suddenDoomTrigger && depth === 0) {
|
if (suddenDoomTrigger && depth === 0) {
|
||||||
|
|||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
|
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||||
|
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||||
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
|
import type { InventoryItem } from "./lootTypes";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||||
|
import {
|
||||||
|
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||||
|
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
|
||||||
|
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
|
||||||
|
|
||||||
|
const TARGET_ID = "player-auto-event-lineage-target";
|
||||||
|
|
||||||
|
let presentationEvents: CombatPresentationEvent[] = [];
|
||||||
|
let publishedCombatEvents: StructuredCombatEvent[] = [];
|
||||||
|
let unsubscribePresentation = () => {};
|
||||||
|
let unsubscribeCombatEvents = () => {};
|
||||||
|
|
||||||
|
function weapon(
|
||||||
|
instanceId: string,
|
||||||
|
inventorySlot: InventoryItem["inventorySlot"],
|
||||||
|
overrides: Partial<Pick<InventoryItem, "inventorySlot" | "armorType" | "weaponType">> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
contentCategory: "wow" as const,
|
||||||
|
instanceId,
|
||||||
|
itemId: 1,
|
||||||
|
itemKey: instanceId,
|
||||||
|
name: instanceId,
|
||||||
|
quality: "uncommon" as const,
|
||||||
|
inventorySlot: overrides.inventorySlot ?? inventorySlot,
|
||||||
|
armorType: overrides.armorType ?? null,
|
||||||
|
weaponType: overrides.weaponType === undefined ? "dagger" : overrides.weaponType,
|
||||||
|
itemLevel: 1,
|
||||||
|
sourceItemLevel: 1,
|
||||||
|
stats: {
|
||||||
|
...EMPTY_ITEM_STATS,
|
||||||
|
hitRating: 10_000,
|
||||||
|
meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 },
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
sourceBossKey: "test",
|
||||||
|
sourceBossName: "Test",
|
||||||
|
acquiredAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function warriorAutoAttackEvents(options: {
|
||||||
|
level?: number;
|
||||||
|
mainHand?: InventoryItem;
|
||||||
|
offHand?: InventoryItem;
|
||||||
|
assignMainHand?: boolean;
|
||||||
|
assignOffHand?: boolean;
|
||||||
|
virtualDualWieldHeirloom?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const mainHand = options.mainHand ?? weapon("warrior-main-hand", "one-hand", { weaponType: "dagger" });
|
||||||
|
const offHand = options.offHand ?? weapon("warrior-off-hand", "off-hand", { weaponType: "dagger" });
|
||||||
|
const assignments = {
|
||||||
|
...(options.assignMainHand === false ? {} : { "main-hand": mainHand.instanceId }),
|
||||||
|
...(options.assignOffHand === false ? {} : { "off-hand": offHand.instanceId }),
|
||||||
|
};
|
||||||
|
useCombatStore.getState().initializeCharacter({
|
||||||
|
classId: "warrior",
|
||||||
|
level: options.level ?? 20,
|
||||||
|
statRuleset: "wow",
|
||||||
|
specializationId: options.virtualDualWieldHeirloom ? "warrior:fury" : undefined,
|
||||||
|
inventory: [mainHand, offHand],
|
||||||
|
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: assignments },
|
||||||
|
});
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||||
|
name: "Player auto event lineage target",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob(TARGET_ID);
|
||||||
|
useCombatStore.setState({
|
||||||
|
autoAttackTargetId: TARGET_ID,
|
||||||
|
nextAutoAttackAt: 1_000,
|
||||||
|
nextOffhandAutoAttackAt: 1_000,
|
||||||
|
});
|
||||||
|
useCombatStore.getState().tick(0, 1_000);
|
||||||
|
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||||
|
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||||
|
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||||
|
&& (event.attackHand === "mainhand" || event.attackHand === "offhand")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||||
|
name: "Player auto event lineage target",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob(TARGET_ID);
|
||||||
|
presentationEvents = [];
|
||||||
|
publishedCombatEvents = [];
|
||||||
|
unsubscribePresentation = subscribeCombatPresentation((message) => {
|
||||||
|
if (message.kind === "event") presentationEvents.push(message.event);
|
||||||
|
});
|
||||||
|
unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribePresentation();
|
||||||
|
unsubscribeCombatEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player auto-attack event lineage", () => {
|
||||||
|
it("enables WoW Warrior off-hand swings only with two assigned legal one-hand weapons at level 20", () => {
|
||||||
|
const validWarriorSwings = warriorAutoAttackEvents();
|
||||||
|
expect(validWarriorSwings).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||||
|
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
|
||||||
|
]));
|
||||||
|
expect(validWarriorSwings).toHaveLength(2);
|
||||||
|
|
||||||
|
const belowTrainingLevel = warriorAutoAttackEvents({ level: 19 });
|
||||||
|
expect(belowTrainingLevel.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const noMainHandAssignment = warriorAutoAttackEvents({ assignMainHand: false });
|
||||||
|
expect(noMainHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const noOffHandAssignment = warriorAutoAttackEvents({ assignOffHand: false });
|
||||||
|
expect(noOffHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const noExplicitLoadout = warriorAutoAttackEvents({ assignMainHand: false, assignOffHand: false });
|
||||||
|
expect(noExplicitLoadout.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const virtualDualWieldHeirloom = warriorAutoAttackEvents({
|
||||||
|
assignMainHand: false,
|
||||||
|
assignOffHand: false,
|
||||||
|
virtualDualWieldHeirloom: true,
|
||||||
|
});
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
const generatedLoadout = resolvedEquipmentLoadout(state.equipment, state.inventory, {
|
||||||
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
ownerKind: "player",
|
||||||
|
classId: state.classId,
|
||||||
|
secondaryClassId: state.secondaryClassId,
|
||||||
|
specializationId: state.specializationId,
|
||||||
|
role: state.gearRole,
|
||||||
|
level: state.level,
|
||||||
|
});
|
||||||
|
expect(state.specializationId).toBe("warrior:fury");
|
||||||
|
expect(generatedLoadout["off-hand"]).toMatchObject({ inventorySlot: "off-hand", weaponType: "dagger" });
|
||||||
|
expect(virtualDualWieldHeirloom.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shields, two-hand mains, and class-illegal off-hand weapons", () => {
|
||||||
|
const shield = weapon("warrior-offhand-shield", "off-hand", { armorType: "shield", weaponType: "dagger" });
|
||||||
|
const shieldedSwings = warriorAutoAttackEvents({ offHand: shield });
|
||||||
|
expect(shieldedSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const twoHandMain = weapon("warrior-two-hand-main", "two-hand", { weaponType: "dagger" });
|
||||||
|
const twoHandSwings = warriorAutoAttackEvents({ mainHand: twoHandMain });
|
||||||
|
expect(twoHandSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
|
||||||
|
const illegalOffHand = weapon("warrior-illegal-offhand", "off-hand", { weaponType: "wand" });
|
||||||
|
const illegalSwings = warriorAutoAttackEvents({ offHand: illegalOffHand });
|
||||||
|
expect(illegalSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins both hand events to their releases without relabeling the triggering melee ability", () => {
|
||||||
|
const mainHand = weapon("event-lineage-main-hand", "one-hand");
|
||||||
|
const offHand = weapon("event-lineage-off-hand", "off-hand");
|
||||||
|
useCombatStore.getState().initializeCharacter({
|
||||||
|
classId: "rogue",
|
||||||
|
level: 80,
|
||||||
|
inventory: [mainHand, offHand],
|
||||||
|
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: {
|
||||||
|
"main-hand": mainHand.instanceId,
|
||||||
|
"off-hand": offHand.instanceId,
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||||
|
name: "Player auto event lineage target",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob(TARGET_ID);
|
||||||
|
const triggeringAbility = useCombatStore.getState().abilities.find((ability) => ability.name === "Sinister Strike");
|
||||||
|
expect(triggeringAbility).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().castAbility(triggeringAbility!.id, undefined, 1_000).ok).toBe(true);
|
||||||
|
const healthBeforeSwings = useCombatStore.getState().mobs[TARGET_ID]!.health;
|
||||||
|
|
||||||
|
useCombatStore.getState().tick(0, 1_000);
|
||||||
|
|
||||||
|
const swingEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||||
|
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||||
|
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||||
|
&& (event.attackHand === "mainhand" || event.attackHand === "offhand")
|
||||||
|
));
|
||||||
|
expect(swingEvents).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||||
|
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
|
||||||
|
]));
|
||||||
|
expect(swingEvents).toHaveLength(2);
|
||||||
|
expect(swingEvents.reduce((sum, event) => sum + event.effectiveAmount, 0))
|
||||||
|
.toBe(healthBeforeSwings - useCombatStore.getState().mobs[TARGET_ID]!.health);
|
||||||
|
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: PLAYER_AGGRO_ID,
|
||||||
|
targetActorId: TARGET_ID,
|
||||||
|
abilityId: triggeringAbility!.id,
|
||||||
|
}));
|
||||||
|
expect(presentationEvents).toContainEqual(expect.objectContaining({
|
||||||
|
occurredAt: 1_000,
|
||||||
|
phase: "release",
|
||||||
|
targetActorId: TARGET_ID,
|
||||||
|
abilityId: "player-auto-attack",
|
||||||
|
}));
|
||||||
|
expect(presentationEvents).toContainEqual(expect.objectContaining({
|
||||||
|
occurredAt: 1_000,
|
||||||
|
phase: "release",
|
||||||
|
targetActorId: TARGET_ID,
|
||||||
|
abilityId: "player-auto-attack-offhand",
|
||||||
|
}));
|
||||||
|
expect(publishedCombatEvents.filter((event) => event.kind === "damage"
|
||||||
|
&& event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === TARGET_ID
|
||||||
|
&& (event.abilityId === "player-auto-attack" || event.abilityId === "player-auto-attack-offhand")))
|
||||||
|
.toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Dancing Rune Weapon's mirrored damage under its proc identity", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||||
|
name: "Player auto event lineage target",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||||
|
const aura = applyCombatAura(useCombatStore.getState().auras, {
|
||||||
|
definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
|
||||||
|
sourceId: PLAYER_AGGRO_ID,
|
||||||
|
targetId: PLAYER_AGGRO_ID,
|
||||||
|
now: 1_000,
|
||||||
|
});
|
||||||
|
useCombatStore.setState({
|
||||||
|
auras: [...aura.auras],
|
||||||
|
autoAttackTargetId: TARGET_ID,
|
||||||
|
nextAutoAttackAt: 1_000,
|
||||||
|
});
|
||||||
|
const healthBeforeSwing = useCombatStore.getState().mobs[TARGET_ID]!.health;
|
||||||
|
|
||||||
|
useCombatStore.getState().tick(0, 1_000);
|
||||||
|
|
||||||
|
const playerDamage = useCombatStore.getState().combatEvents.filter((event) => (
|
||||||
|
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||||
|
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||||
|
));
|
||||||
|
expect(playerDamage).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||||
|
expect.objectContaining({
|
||||||
|
abilityId: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(playerDamage.find((event) => event.abilityId
|
||||||
|
=== WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).not.toHaveProperty("attackHand");
|
||||||
|
expect(playerDamage).toHaveLength(2);
|
||||||
|
expect(playerDamage.reduce((sum, event) => sum + event.effectiveAmount, 0))
|
||||||
|
.toBe(healthBeforeSwing - useCombatStore.getState().mobs[TARGET_ID]!.health);
|
||||||
|
expect(presentationEvents.filter((event) => event.phase === "release"
|
||||||
|
&& event.abilityId === "player-auto-attack")).toHaveLength(1);
|
||||||
|
expect(presentationEvents.some((event) => event.phase === "release"
|
||||||
|
&& event.abilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
|
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
||||||
|
|
||||||
|
const TARGET_ID = "player-control-cancellation-target";
|
||||||
|
const CONTROL_MOB_ID = "player-control-cancellation-source";
|
||||||
|
|
||||||
|
let presentationEvents: CombatPresentationEvent[] = [];
|
||||||
|
let publishedCombatEvents: StructuredCombatEvent[] = [];
|
||||||
|
let unsubscribePresentation = () => {};
|
||||||
|
let unsubscribeCombatEvents = () => {};
|
||||||
|
|
||||||
|
function prepareCharacter(classId: "priest" | "warlock"): void {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId, level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||||
|
name: "Player control cancellation target",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob(TARGET_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
prepareCharacter("priest");
|
||||||
|
presentationEvents = [];
|
||||||
|
publishedCombatEvents = [];
|
||||||
|
unsubscribePresentation = subscribeCombatPresentation((message) => {
|
||||||
|
if (message.kind === "event") presentationEvents.push(message.event);
|
||||||
|
});
|
||||||
|
unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribePresentation();
|
||||||
|
unsubscribeCombatEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startTargetingCast(abilityId: string, now = 1_000) {
|
||||||
|
const result = useCombatStore.getState().castAbility(abilityId, {
|
||||||
|
targetActorId: TARGET_ID,
|
||||||
|
origin: [0, 0, 0],
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId, targetId: TARGET_ID });
|
||||||
|
return useCombatStore.getState().activeCast!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliverControl(now: number): void {
|
||||||
|
if (!useCombatStore.getState().mobs[CONTROL_MOB_ID]) {
|
||||||
|
const attack: EnemyAttackDefinition = {
|
||||||
|
id: "lineage-control-stun",
|
||||||
|
name: "Lineage Control Stun",
|
||||||
|
spellId: 987_654,
|
||||||
|
delivery: "projectile",
|
||||||
|
target: "primary",
|
||||||
|
school: "shadow",
|
||||||
|
animation: "cast",
|
||||||
|
castTimeMs: 0,
|
||||||
|
range: 30,
|
||||||
|
cooldownMs: 4_000,
|
||||||
|
initialCooldownMs: [0, 0],
|
||||||
|
repeatCooldownMs: [4_000, 4_000],
|
||||||
|
damageMultiplier: 0,
|
||||||
|
effect: { kind: "control", mechanic: "stun", durationMs: 2_000 },
|
||||||
|
};
|
||||||
|
useCombatStore.getState().registerMob(CONTROL_MOB_ID, {
|
||||||
|
name: "Lineage control source",
|
||||||
|
level: 80,
|
||||||
|
maxHealth: 1_000_000,
|
||||||
|
xpReward: 0,
|
||||||
|
attacks: [attack],
|
||||||
|
});
|
||||||
|
setMobPosition(CONTROL_MOB_ID, [2, 0, 1]);
|
||||||
|
useCombatStore.getState().damageMob(CONTROL_MOB_ID, 1, now);
|
||||||
|
}
|
||||||
|
useCombatStore.getState().advanceMobCombat(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interruptionsFor(abilityId: string): StructuredCombatEvent[] {
|
||||||
|
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||||
|
event.kind === "interrupt" && event.abilityId === abilityId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("player cast cancellation from enemy control", () => {
|
||||||
|
it("records one stun interruption with the source, original target, and canceled cast identity", () => {
|
||||||
|
const cast = startTargetingCast("priest-smite");
|
||||||
|
|
||||||
|
deliverControl(1_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(useCombatStore.getState().controlMechanic).toBe("stun");
|
||||||
|
expect(interruptionsFor(cast.abilityId)).toEqual([expect.objectContaining({
|
||||||
|
occurredAt: 1_000,
|
||||||
|
sourceActorId: CONTROL_MOB_ID,
|
||||||
|
targetActorId: cast.targetId,
|
||||||
|
abilityId: cast.abilityId,
|
||||||
|
outcome: "control:stun",
|
||||||
|
causeAbilityId: "lineage-control-stun",
|
||||||
|
})]);
|
||||||
|
expect(presentationEvents.filter((event) => event.phase === "cast-cancel"
|
||||||
|
&& event.abilityId === cast.abilityId)).toEqual([expect.objectContaining({
|
||||||
|
targetActorId: cast.targetId,
|
||||||
|
occurredAt: 1_000,
|
||||||
|
})]);
|
||||||
|
|
||||||
|
deliverControl(1_100);
|
||||||
|
|
||||||
|
expect(interruptionsFor(cast.abilityId)).toHaveLength(1);
|
||||||
|
expect(publishedCombatEvents.filter((event) => event.kind === "interrupt"
|
||||||
|
&& event.abilityId === cast.abilityId)).toHaveLength(1);
|
||||||
|
expect(presentationEvents.filter((event) => event.phase === "cast-cancel"
|
||||||
|
&& event.abilityId === cast.abilityId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a channel once without creating a release", () => {
|
||||||
|
prepareCharacter("warlock");
|
||||||
|
const cast = startTargetingCast("warlock-drain-life");
|
||||||
|
expect(cast.mode).toBe("channel");
|
||||||
|
|
||||||
|
deliverControl(1_000);
|
||||||
|
|
||||||
|
expect(interruptionsFor(cast.abilityId)).toHaveLength(1);
|
||||||
|
expect(presentationEvents.filter((event) => event.abilityId === cast.abilityId
|
||||||
|
&& event.phase === "cast-cancel")).toHaveLength(1);
|
||||||
|
expect(presentationEvents.some((event) => event.abilityId === cast.abilityId
|
||||||
|
&& event.phase === "release")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fabricate a control interruption after normal completion or death", () => {
|
||||||
|
const completedCast = startTargetingCast("priest-smite");
|
||||||
|
useCombatStore.getState().tick(0, completedCast.completesAt);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(interruptionsFor(completedCast.abilityId)).toHaveLength(0);
|
||||||
|
expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId
|
||||||
|
&& event.phase === "release")).toBe(true);
|
||||||
|
expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId
|
||||||
|
&& event.phase === "cast-cancel")).toBe(false);
|
||||||
|
|
||||||
|
prepareCharacter("priest");
|
||||||
|
const doomedCast = startTargetingCast("priest-smite", 4_000);
|
||||||
|
const health = useCombatStore.getState().health;
|
||||||
|
useCombatStore.getState().damagePlayer(health * 10, "physical", undefined, 4_001);
|
||||||
|
expect(useCombatStore.getState().health).toBe(0);
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
deliverControl(4_001);
|
||||||
|
expect(interruptionsFor(doomedCast.abilityId)).toHaveLength(0);
|
||||||
|
expect(presentationEvents.some((event) => event.abilityId === doomedCast.abilityId
|
||||||
|
&& event.phase === "cast-cancel")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AbilityEffect } from "./abilityCatalog";
|
||||||
|
import type { CombatSummon } from "./combatStore";
|
||||||
|
import type { CombatPosition } from "./mobRuntimeRegistry";
|
||||||
|
|
||||||
|
/** Select one living, owned, modeled undead minion; hostile actors never qualify. */
|
||||||
|
export function eligibleSacrificeMinion(
|
||||||
|
summons: readonly CombatSummon[],
|
||||||
|
ownerActorId: string,
|
||||||
|
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
|
||||||
|
origin: CombatPosition,
|
||||||
|
now: number,
|
||||||
|
): CombatSummon | undefined {
|
||||||
|
return summons.find((summon) => summon.ownerActorId === ownerActorId
|
||||||
|
&& summon.health > 0 && summon.expiresAt > now
|
||||||
|
&& effect.creatureIds.includes(summon.creatureId)
|
||||||
|
&& Math.hypot(summon.position[0] - origin[0], summon.position[1] - origin[1],
|
||||||
|
summon.position[2] - origin[2]) <= effect.radius);
|
||||||
|
}
|
||||||
@@ -174,6 +174,72 @@ describe("talent runtime modifiers", () => {
|
|||||||
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
|
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Blood Frenzy's target-state damage scope broad", () => {
|
||||||
|
const bloodFrenzy = TALENT_NODES.find((node) => node.id === "talent-1664")!;
|
||||||
|
expect(bloodFrenzy.rankDescriptions[1]).toBe(
|
||||||
|
"Increases your melee attack speed by 4%. Rend and Deep Wounds increase all Physical damage caused to that target by 4%. Does not stack with similar effects.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiers = talentModifiersForRanks({ [bloodFrenzy.id]: 2 }, "warrior");
|
||||||
|
expect(modifiers).toContainEqual(expect.objectContaining({
|
||||||
|
kind: "damage-percent",
|
||||||
|
amount: 4,
|
||||||
|
scope: "all Physical damage caused to that target",
|
||||||
|
sourceTalentId: bloodFrenzy.id,
|
||||||
|
rank: 2,
|
||||||
|
}));
|
||||||
|
for (const abilityName of ["Mortal Strike", "Rend", "Deep Wounds"]) {
|
||||||
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName }), abilityName)
|
||||||
|
.toBeCloseTo(104);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Morbidity's Death Coil clauses and Death and Decay cooldown on their named spells", () => {
|
||||||
|
const morbidity = TALENT_NODES.find((node) => node.id === "talent-1933")!;
|
||||||
|
expect(morbidity.name).toBe("Morbidity");
|
||||||
|
expect(morbidity.rankDescriptions[2]).toBe(
|
||||||
|
"Increases the damage and healing of Death Coil by 15% and reduces the cooldown on Death and Decay by 15 sec.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiers = talentModifiersForRanks({ [morbidity.id]: 3 }, "death-knight");
|
||||||
|
expect(modifiers).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "damage-percent", amount: 15 }),
|
||||||
|
expect.objectContaining({ kind: "healing-percent", amount: 15 }),
|
||||||
|
expect.objectContaining({ kind: "cooldown-flat-ms", amount: -15_000, scope: "cooldown on Death and Decay" }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(applyTalentModifierValue(30_000, modifiers, "cooldown-flat-ms", {
|
||||||
|
abilityName: "Death and Decay",
|
||||||
|
})).toBe(15_000);
|
||||||
|
for (const [abilityName, baseCooldownMs] of [
|
||||||
|
["Dark Command", 8_000],
|
||||||
|
["Death Grip", 35_000],
|
||||||
|
["Death Coil", 8_000],
|
||||||
|
] as const) {
|
||||||
|
expect(
|
||||||
|
applyTalentModifierValue(baseCooldownMs, modifiers, "cooldown-flat-ms", { abilityName }),
|
||||||
|
abilityName,
|
||||||
|
).toBe(baseCooldownMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Death Coil" })).toBeCloseTo(115);
|
||||||
|
expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Coil" })).toBeCloseTo(115);
|
||||||
|
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Icy Touch" })).toBe(100);
|
||||||
|
expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Strike" })).toBe(100);
|
||||||
|
|
||||||
|
const broadCooldown = parseTalentModifiers("Reduces the cooldown of all your spells by 2 sec.")
|
||||||
|
.map((modifier) => ({
|
||||||
|
...modifier,
|
||||||
|
sourceTalentId: "test:broad-cooldown",
|
||||||
|
sourceTalentName: "Broad Cooldown",
|
||||||
|
rank: 1,
|
||||||
|
description: "Reduces the cooldown of all your spells by 2 sec.",
|
||||||
|
}));
|
||||||
|
for (const abilityName of ["Fireball", "Icy Touch"]) {
|
||||||
|
expect(applyTalentModifierValue(10_000, broadCooldown, "cooldown-flat-ms", { abilityName })).toBe(8_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => {
|
it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => {
|
||||||
const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
|
const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
|
||||||
|
|
||||||
|
|||||||
@@ -223,8 +223,12 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
|
|||||||
if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence });
|
if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence });
|
||||||
const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
|
const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
|
||||||
? null
|
? null
|
||||||
: sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
|
: sentence.match(/\breduces?\s+(?:the\s+)?cooldown(?:\s+(of|on)\s+(.+?))?\s+by\s+(\d+(?:\.\d+)?)\s+secs?\b/i);
|
||||||
if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence });
|
if (cooldown) {
|
||||||
|
const target = cooldown[2]?.trim();
|
||||||
|
const scope = target ? `cooldown ${cooldown[1]} ${target}` : "cooldown";
|
||||||
|
add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[3]) * 1000, unit: "milliseconds", scope });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return modifiers;
|
return modifiers;
|
||||||
@@ -445,6 +449,24 @@ export interface TalentModifierContext {
|
|||||||
readonly tags?: readonly string[];
|
readonly tags?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function namedModifierTarget(scope: string): string | undefined {
|
||||||
|
const scopeWithoutAmount = scope.replace(/\s+by\s+-?\d+(?:\.\d+)?\s*(?:%|secs?)(?:\s*[.!?])?$/i, "");
|
||||||
|
// These are the source tooltip forms used for named abilities (for example,
|
||||||
|
// "cooldown on Death and Decay" and "damage of your Fire Blast"). Other
|
||||||
|
// prepositions can describe a target condition instead, as in "damage
|
||||||
|
// caused to that target", and must leave the modifier broadly applicable.
|
||||||
|
const target = scopeWithoutAmount.match(/\b(?:of|on)\s+(?:your\s+)?([^,.!?]+)$/)?.[1]?.trim();
|
||||||
|
if (!target) return undefined;
|
||||||
|
|
||||||
|
// Generic descriptors describe a category of spells, effects, or targets.
|
||||||
|
// Keep those modifiers broad; remaining words indicate a named ability.
|
||||||
|
const remainingTarget = target.toLowerCase()
|
||||||
|
.replace(/\b(?:a|an|the|your|all|any|each|every|both|other|next|additional|total|maximum|direct|periodic|instant|damaging|healing|helpful|harmful|melee|ranged|magic|physical|arcane|fire|frost|holy|nature|shadow|spell|spells|ability|abilities|attack|attacks|damage|cooldown|cost|cast|casting|time|effect|effects|critical|strike|chance|done|dealt|taken|suffered|received|target|targets|enemy|enemies|ally|allies|friendly|hostile|party|raid|members|you|self|both|and|or|of|on|with|for|to|by)\b/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return remainingTarget ? target.toLowerCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean {
|
function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean {
|
||||||
const scope = modifier.scope.toLowerCase();
|
const scope = modifier.scope.toLowerCase();
|
||||||
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
|
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
|
||||||
@@ -469,6 +491,8 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
|
|||||||
}
|
}
|
||||||
if (context.abilityName) {
|
if (context.abilityName) {
|
||||||
const normalizedName = context.abilityName.toLowerCase();
|
const normalizedName = context.abilityName.toLowerCase();
|
||||||
|
const namedTarget = namedModifierTarget(scope);
|
||||||
|
if (namedTarget && !namedTarget.includes(normalizedName)) return false;
|
||||||
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 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)
|
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);
|
|| /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);
|
||||||
|
|||||||
@@ -1633,6 +1633,14 @@ function classScriptEffects(
|
|||||||
triggerSpellId: 0,
|
triggerSpellId: 0,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
}];
|
}];
|
||||||
|
case "death-knight:Death Pact": return [{
|
||||||
|
kind: "sacrifice-minion",
|
||||||
|
// These are the undead ghoul entries already modeled by Raise Dead and
|
||||||
|
// Army of the Dead. Unknown summon families are never assumed undead.
|
||||||
|
creatureIds: [26125, 24207],
|
||||||
|
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
|
||||||
|
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
|
||||||
|
}];
|
||||||
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
|
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
|
||||||
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
|
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
|
||||||
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
|
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
|
||||||
@@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function learnOnlyRank(rank: Wow335Rank): boolean {
|
||||||
|
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
|
||||||
|
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
|
||||||
|
}
|
||||||
|
|
||||||
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
||||||
const effects: AbilityEffect[] = [];
|
const effects: AbilityEffect[] = [];
|
||||||
for (const [effectIndex, effect] of rank.effects.entries()) {
|
for (const [effectIndex, effect] of rank.effects.entries()) {
|
||||||
@@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
}
|
}
|
||||||
const auraOnlyStance = classId === "death-knight"
|
const auraOnlyStance = classId === "death-knight"
|
||||||
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||||
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
||||||
&& !(classId === "mage" && abilityName === "Molten Armor")
|
&& !(classId === "mage" && abilityName === "Molten Armor")
|
||||||
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||||
if (classId === "death-knight" && abilityName === "Death Coil") {
|
if (classId === "death-knight" && abilityName === "Death Coil") {
|
||||||
@@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
||||||
.filter((effect) => !(
|
.filter((effect) => !(
|
||||||
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|
||||||
|
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|
||||||
|| (classId === "paladin" && abilityName === "Divine Storm"
|
|| (classId === "paladin" && abilityName === "Divine Storm"
|
||||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||||
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
|
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
|
||||||
@@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
|
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
|
||||||
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
|
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
|
||||||
: chain.sourceUnlockLevel;
|
: chain.sourceUnlockLevel;
|
||||||
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
|
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
|
||||||
const radius = Math.max(
|
? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||||
|
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
|
||||||
abilityNameRadius(chain.name),
|
abilityNameRadius(chain.name),
|
||||||
...firstRaw.effects.map((effect) => effect.radius),
|
...firstRaw.effects.map((effect) => effect.radius),
|
||||||
);
|
);
|
||||||
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
gcdMs: first.gcdMs,
|
gcdMs: first.gcdMs,
|
||||||
cost: first.cost,
|
cost: first.cost,
|
||||||
effects: first.effects,
|
effects: first.effects,
|
||||||
passive: chain.passive,
|
passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
|
||||||
|
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
|
||||||
source: "wow335-progression",
|
source: "wow335-progression",
|
||||||
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
|
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
|
||||||
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
|
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { installCombatClock } from "./combatClock";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||||
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
|
|
||||||
|
const PACT = "wow335-death-knight-death-pact";
|
||||||
|
const RAISE = "wow335-death-knight-raise-dead";
|
||||||
|
const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80);
|
||||||
|
let restoreClock: (() => void) | undefined;
|
||||||
|
|
||||||
|
function hostile(id: string, boss = false) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss });
|
||||||
|
setMobPosition(id, [2, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) {
|
||||||
|
const source = ability(RAISE);
|
||||||
|
const effect = source.effects.find(e => e.kind === "summon") as Extract<AbilityEffect, {kind: "summon"}>;
|
||||||
|
return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Death Pact source semantics and learned Blood Gorged", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreClock = installCombatClock(() => 1_000);
|
||||||
|
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
|
||||||
|
usePartyStore.setState({ members: [], command: "attack" });
|
||||||
|
useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100,
|
||||||
|
resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } });
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
});
|
||||||
|
afterEach(() => restoreClock?.());
|
||||||
|
|
||||||
|
it("does not spend resources, cooldown or GCD without a living owned minion", () => {
|
||||||
|
hostile("trash"); hostile("boss", true);
|
||||||
|
const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000);
|
||||||
|
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 });
|
||||||
|
const s = useCombatStore.getState();
|
||||||
|
expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined();
|
||||||
|
expect(s.globalCooldownEndsAt).toBe(0);
|
||||||
|
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => {
|
||||||
|
hostile("trash"); hostile("boss", true);
|
||||||
|
expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true);
|
||||||
|
const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!;
|
||||||
|
expect(first).toBeDefined();
|
||||||
|
const other = summon();
|
||||||
|
const before = useCombatStore.getState();
|
||||||
|
const result = before.castAbility(PACT, undefined, 3_000);
|
||||||
|
expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 });
|
||||||
|
const s = useCombatStore.getState();
|
||||||
|
expect(s.health).toBe(600); expect(s.resource).toBe(60);
|
||||||
|
expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500);
|
||||||
|
expect(s.summons.find(x => x.id === first.id)?.health).toBe(0);
|
||||||
|
expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false });
|
||||||
|
expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0);
|
||||||
|
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
|
||||||
|
const events = s.combatEvents.filter(e => e.abilityId === PACT);
|
||||||
|
expect(events.filter(e => e.kind === "damage")).toHaveLength(0);
|
||||||
|
expect(events.filter(e => e.kind === "death")).toHaveLength(1);
|
||||||
|
expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({
|
||||||
|
targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false,
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => {
|
||||||
|
const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125);
|
||||||
|
useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id
|
||||||
|
? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) }));
|
||||||
|
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||||
|
expect(useCombatStore.getState().health).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consume another caster's minion; companion automatic use heals only itself", () => {
|
||||||
|
const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood",
|
||||||
|
specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party");
|
||||||
|
const member = { ...base, health: 100, maxHealth: 1_000, resource: 100,
|
||||||
|
resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0,
|
||||||
|
talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null)
|
||||||
|
.filter(a => a.id !== PACT).map(a => [a.id, 20_000])) };
|
||||||
|
usePartyStore.setState({ members: [member], command: "attack" });
|
||||||
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const id = summon(member.id);
|
||||||
|
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||||
|
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true);
|
||||||
|
hostile("party-trash");
|
||||||
|
useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } }));
|
||||||
|
advanceDungeonPartyCombat(2_000);
|
||||||
|
|
||||||
|
const s = useCombatStore.getState();
|
||||||
|
expect(s.summons.find(x => x.id === id)?.health).toBe(0);
|
||||||
|
expect(s.health).toBe(200);
|
||||||
|
expect(usePartyStore.getState().members[0].health).toBe(500);
|
||||||
|
expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([
|
||||||
|
expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }),
|
||||||
|
]);
|
||||||
|
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps overhealing and accounts for it once", () => {
|
||||||
|
summon(); useCombatStore.setState({ health: 950 });
|
||||||
|
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50);
|
||||||
|
expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT))
|
||||||
|
.toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => {
|
||||||
|
const source = ability("wow335-death-knight-blood-gorged");
|
||||||
|
expect(source.passive).toBe(true);
|
||||||
|
expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false);
|
||||||
|
useCombatStore.setState({ talentRanks: { "talent-2034": rank } });
|
||||||
|
expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000))
|
||||||
|
.toMatchObject({ ok: false, reason: "passive", damage: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user