Correct combat events, rotations and explicit weapon mechanics #21
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
|
||||
// activation mechanics, so retain it for the spellbook but classify it
|
||||
// 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.recoveryTimeMs === 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: "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: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number }
|
||||
| {
|
||||
readonly kind: "summon";
|
||||
readonly creatureId: number;
|
||||
|
||||
@@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
||||
for (const effect of ability.effects) {
|
||||
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
||||
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 === "taunt") categories.add("taunt");
|
||||
if (effect.kind === "interrupt") categories.add("interrupt");
|
||||
|
||||
@@ -679,6 +679,12 @@ function scoreAbility(
|
||||
score += injured.length ? -3_000 : 300;
|
||||
}
|
||||
} 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")) {
|
||||
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
|
||||
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
|
||||
|
||||
@@ -1536,7 +1536,7 @@ describe("combat store", () => {
|
||||
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.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 });
|
||||
});
|
||||
|
||||
|
||||
+54
-1
@@ -1,3 +1,4 @@
|
||||
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
||||
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
|
||||
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;
|
||||
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
|
||||
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. */
|
||||
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: 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 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,
|
||||
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
||||
> {
|
||||
@@ -1908,7 +1934,7 @@ function recordCombatEvent(
|
||||
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)];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
else if (ability.name === "Enraged Regeneration"
|
||||
&& !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"
|
||||
&& !state.summons.some((summon) => summon.health <= 0
|
||||
&& (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") {
|
||||
for (const id of affectedMobIds) {
|
||||
const mob = work.mobs[id];
|
||||
@@ -18676,6 +18720,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
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) => {
|
||||
const now = request.now ?? combatNow();
|
||||
const state = get();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
||||
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
||||
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|
||||
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
|
||||
}
|
||||
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(
|
||||
member.classId,
|
||||
member.secondaryClassId,
|
||||
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
|
||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||
friendlyTargets,
|
||||
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)))
|
||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||
@@ -4587,6 +4590,17 @@ function executePartyAbility(
|
||||
ability.id,
|
||||
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") {
|
||||
for (const hostileTargetId of hostileTargets) {
|
||||
const target = useCombatStore.getState().mobs[hostileTargetId];
|
||||
@@ -5147,7 +5161,7 @@ function executePartyAbility(
|
||||
}
|
||||
emitPartyAbility(member, ability, targetId, "release", 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);
|
||||
}
|
||||
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,
|
||||
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:Army of the Dead": return Array.from({ length: 8 }, () => ({
|
||||
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[] {
|
||||
const effects: AbilityEffect[] = [];
|
||||
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"
|
||||
&& ["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")
|
||||
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||
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) => !(
|
||||
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|
||||
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|
||||
|| (classId === "paladin" && abilityName === "Divine Storm"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (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"
|
||||
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
|
||||
: chain.sourceUnlockLevel;
|
||||
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||
const radius = Math.max(
|
||||
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
|
||||
? "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),
|
||||
...firstRaw.effects.map((effect) => effect.radius),
|
||||
);
|
||||
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
gcdMs: first.gcdMs,
|
||||
cost: first.cost,
|
||||
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",
|
||||
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
|
||||
// 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