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.
This commit is contained in:
1 parent
a9322a3897
commit
3687c423e2
11 files changed
+284
-10
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.
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -679,6 +679,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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+54
-1
@@ -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,
|
||||||
@@ -1712,6 +1713,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 +1824,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 +1934,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)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13665,6 +13691,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 +16907,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 +18720,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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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