Compare commits

...
7 Commits
Author SHA1 Message Date
warren 2f8cb8a510 fix: allow explicit WoW Warrior off-hand swings 2026-10-15 09:26:01 -04:00
Codex Bot 9be6f54c52 fix: record control driven cast cancellations (#16) 2026-10-15 09:12:08 -04:00
warren 6a716fb8ca fix: preserve broad target-state talent scopes (#20) 2026-10-15 09:10:55 -04:00
warren b3e0445e9c Fix named talent cooldown scopes (#20)
Preserve named cooldown targets from tooltip clauses and keep named damage and healing effects scoped to their described abilities. Broad spell cooldowns remain broad.

Issue: #20
Coordinator: Root (/root), session 01a12399-cfab-7c72-8455-e0e4cb7e156c
Worker: /root/combat_talents_luna, GPT-6 Luna xhigh
Branch: fix/20-named-talent-scope
2026-10-15 09:10:55 -04:00
warren 72a67ac1e3 Fix benchmark maintained aura selection (#18) 2026-10-15 09:10:55 -04:00
Codex Bot 89b6f4f79d fix: label player auto attack damage events (#15) 2026-10-15 09:10:20 -04:00
warren 3687c423e2 Correct minion sacrifice and passive talent semantics (refs #19)
Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
2026-10-15 09:09:45 -04:00
20 changed files with 1127 additions and 33 deletions

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
```
+5 -1
View File
@@ -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
+1
View File
@@ -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;
+1 -1
View File
@@ -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);
});
});
+17 -8
View File
@@ -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);
} }
} }
+2
View File
@@ -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;
+47
View File
@@ -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({
+53 -5
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
+16 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
});
+18
View File
@@ -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);
}
+66
View File
@@ -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")!;
+26 -2
View File
@@ -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);
+20 -4
View File
@@ -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 });
});
});