Update Healer Man server

This commit is contained in:
phenom committed 2026-10-07 13:05:04 -04:00
1 parent 69809f7228
commit ff20ab8779
101 files changed
+16713 -945

No files matched your search

+7 -2
View File
@@ -938,9 +938,14 @@ function mergeWow335ClassicAbilities(
: ability;
}
const [updated] = remaining.splice(importedIndex, 1);
// The selected source Kick is pure interruption. The legacy prototype's
// damage and second interrupt cannot become effects of that sourced row.
const sourceOnlyKick = ability.id === "rogue-kick" && updated.id === "wow335-rogue-kick"
&& updated.classId === "rogue" && updated.source === "wow335-progression"
&& updated.dbcSpellId === 1766;
const ranks = updated.ranks?.map((rank) => ({
...rank,
effects: mergeCoreEffects(rank.effects, ability.effects),
effects: sourceOnlyKick ? rank.effects : mergeCoreEffects(rank.effects, ability.effects),
}));
return {
...updated,
@@ -950,7 +955,7 @@ function mergeWow335ClassicAbilities(
range: ability.range,
...(ability.radius !== undefined ? { radius: ability.radius } : {}),
...(ability.cooldownGroup !== undefined ? { cooldownGroup: ability.cooldownGroup } : {}),
effects: mergeCoreEffects(updated.effects, ability.effects),
effects: sourceOnlyKick ? updated.effects : mergeCoreEffects(updated.effects, ability.effects),
ranks,
};
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { deflectLandedCombatResult, resolveCombatResult } from "./combatResults";
function attack(overrides: Parameters<typeof resolveCombatResult>[0]["rules"] = {}, roll = 0.5) {
return resolveCombatResult({ amount: 100, attackKind: "spell", damageSchool: "frost",
attacker: { level: 80, criticalChancePercent: 100 },
defender: { level: 80, resistances: { frost: 400 } },
rules: { canMiss: false, resistanceMode: "partial", ...overrides }, roll: () => roll });
}
describe("guaranteed source deflection after the existing hit gate", () => {
it("discards landed critical/partial-resist damage without reporting absorb, block or crit", () => {
const landed = attack();
expect(landed).toMatchObject({ landed: true, critical: true, partiallyResisted: true });
const result = deflectLandedCombatResult(landed);
expect(result).toMatchObject({ outcome: "deflect", deflected: true, landed: false,
avoided: true, rawDamage: 100, finalDamage: 0, blocked: false, blockedAmount: 0,
critical: false, resistedAmount: 0, fullyResisted: false, partiallyResisted: false });
});
it.each(["miss", "resist"] as const)("preserves an earlier %s and its original evidence", (outcome) => {
const prior = outcome === "miss"
? attack({ canMiss: true, baseMissChancePercent: 100 })
: attack({ resistanceMode: "binary" }, 0);
expect(prior.outcome).toBe(outcome);
expect(deflectLandedCombatResult(prior)).toBe(prior);
});
it("removes block-roll evidence from the discarded ranged spell packet", () => {
const blocked = resolveCombatResult({ amount: 100, attackKind: "ranged", damageSchool: "physical",
attacker: { level: 80 }, defender: { level: 80, blockPercent: 100, blockValue: 20,
criticalBlockChancePercent: 100 },
rules: { canMiss: false, canCrit: false, blockMode: "post-hit" }, roll: () => 0.5 });
expect(blocked).toMatchObject({ landed: true, blocked: true, criticalBlocked: true });
const deflected = deflectLandedCombatResult(blocked);
expect(deflected).toMatchObject({ outcome: "deflect", blocked: false, finalDamage: 0 });
expect(deflected.postHitBlockRoll).toBeUndefined();
expect(deflected.criticalBlockRoll).toBeUndefined();
});
});
+38 -1
View File
@@ -2,7 +2,7 @@ import { armorReductionPercent, type DamageSchool } from "./itemStats";
import type { CharacterStats, WotlkAttackKind } from "./wotlkStats";
export type CombatAttackKind = WotlkAttackKind;
export type CombatOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
export type CombatOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist" | "deflect";
export type CombatResistanceMode = "none" | "partial" | "binary";
export type CombatRollStage = "outcome" | "resistance" | "block" | "critical-block";
@@ -87,6 +87,8 @@ export interface CombatResult {
readonly parried: boolean;
readonly blocked: boolean;
readonly avoided: boolean;
/** Exact source spell deflection avoids the packet rather than absorbing it. */
readonly deflected?: boolean;
readonly fullyResisted: boolean;
readonly partiallyResisted: boolean;
readonly rawDamage: number;
@@ -110,6 +112,41 @@ export interface CombatResult {
readonly criticalBlocked?: boolean;
}
/**
* Exact 100% source deflection is applied after the existing miss/full-resist
* result, before any damage or proc is committed. It cannot replace avoidance.
* Partial-resist and block calculations on the discarded packet are not damage.
*/
export function deflectLandedCombatResult(result: CombatResult): CombatResult {
if (!result.landed || result.avoided || result.fullyResisted) return result;
const { postHitBlockRoll: _blockRoll, postHitBlockChancePercent: _blockChance,
criticalBlockRoll: _criticalBlockRoll, criticalBlockChancePercent: _criticalBlockChance,
criticalBlocked: _criticalBlocked, ...retained } = result;
return Object.freeze({
...retained,
outcome: "deflect",
landed: false,
critical: false,
missed: false,
dodged: false,
parried: false,
blocked: false,
avoided: true,
deflected: true,
fullyResisted: false,
partiallyResisted: false,
damageBeforeMitigation: 0,
effectiveArmor: 0,
armorReductionPercent: 0,
armorMitigatedAmount: 0,
blockedAmount: 0,
resistedAmount: 0,
damageReductionAmount: 0,
finalDamage: 0,
resistanceFraction: 0,
});
}
const DEFAULT_CRITICAL_DAMAGE_MULTIPLIER = 2;
const DUAL_WIELD_AUTO_ATTACK_MISS_PENALTY = 19;
const PARTIAL_RESIST_STEP = 0.1;
+1
View File
@@ -175,6 +175,7 @@ const DEFENSIVE_COOLDOWN_NAMES = new Set([
"Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration",
"Pain Suppression", "Ice Block", "Fire Ward", "Frost Ward",
"Evasion", "Cloak of Shadows", "Vanish",
"Deterrence",
]);
const THREAT_REDIRECT_NAMES = new Set(["Misdirection", "Tricks of the Trade"]);
+201 -29
View File
@@ -1,3 +1,4 @@
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
nativeMagicInputsForType, type RomNativeCombatantSnapshot, type RomNativeHitProfile } from "./romNativeCombatResolution";
@@ -13,14 +14,19 @@ import { combatNow } from "./combatClock";
import { selectEnemyChainTargets } from "./enemyChainTargeting";
import { mergePassiveAuraResourceModifiers } from "./classPassiveResourceModifiers";
import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount } from "./enemyPeriodicDamage";
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
import { applyEnemySourceAura, enemySourceAuraContract, supportedEnemySourceAuraDefinition, removeEnemyActorAuras } from "./enemySourceAuras";
import { enemyBlockAuraContract, ordinaryCreatureSelfTargetCriticalBlockAuraDefinition,
resolveEnemyNpcBlockAuraInputs } from "./enemyBlockAuras";
import { enemyUtilitySpellBlockDisposition, enemyUtilitySpellBlockSourceContract } from "./enemyUtilitySpellBlocking";
import { enemyUtilitySpellBlockAbilityContract, enemyUtilitySpellBlockDisposition } from "./enemyUtilitySpellBlocking";
import { isWow335ShieldBashDazeParent, wow335ShieldBashDazeTargetEligible } from "./wow335ShieldBashDaze";
import { sourceFearSummonPosition } from "./enemyFearSummonMovement";
import { enemyControlAbilityCanEscapeAuras, enemyControlImmunityPurgeProofForAbility,
purgeNamedEnemyControlShoutAuras } from "./enemyControlImmunityPurge";
import { WOW335_HUNTER_DETERRENCE_ABILITY_ID, WOW335_DETERRENCE_PARENT_AURA_IDS,
WOW335_DETERRENCE_PACKAGE_AURA_IDS, WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
wow335DeterrenceAbilityContract, wow335DeterrenceActivePackage,
resolveWow335DeterrenceIncoming } from "./wow335DeterrenceRuntime";
import { enemyTriggeredShoutContract, planEnemyTriggeredShoutTargets,
enemyTriggeredShoutControlTag, enemyTriggeredShoutMovementSpeedEffect,
resolveEnemyTriggeredShoutControl } from "./enemyTriggeredShouts";
@@ -43,6 +49,7 @@ import {
ENEMY_DISARM_UNARMED_MAIN_HAND,
enemyDisarmBlocksAbilityCast,
enemyDisarmRequirementCandidates,
enemySpellHasMatchingEquippedItem,
activeEnemyDisarmChannels,
enemyDisarmItemDependentAuraSuppressionIds,
enemyDisarmAurasForWeaponChannel,
@@ -120,6 +127,7 @@ import {
type DeathKnightRuneState,
} from "./combatActors";
import {
companionResourceMaximum,
companionActorFromMember,
companionActorWithMemberRuntime,
memberFromCompanionActor,
@@ -750,7 +758,7 @@ import {
rogueInstantPoisonDamage,
roguePoisonApplicationChances,
} from "./roguePoison";
import { hunterExplosiveTrapDirectScaling } from "./wow335AbilityCatalog";
import { hunterExplosiveTrapDirectScaling, wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
import {
wow335ShamanImprovedWaterShieldCappedRestore,
wow335ShamanImprovedWaterShieldShouldProc,
@@ -944,6 +952,7 @@ import {
baseMissChancePercent,
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
deflectLandedCombatResult,
resolveCombatResult,
type CombatAttackerStats,
type CombatDefenderStats,
@@ -1053,6 +1062,7 @@ export interface GameplaySettings extends GraphicsSettings {
readonly showOwnDamageText: boolean;
readonly showAllyDamageText: boolean;
readonly showIncomingDamageText: boolean;
readonly healerManaRecovery: boolean;
readonly autoTargetNearest: boolean;
readonly enableScreenShake: boolean;
readonly masterVolume: number;
@@ -1073,6 +1083,7 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showOwnDamageText: true,
showAllyDamageText: false,
showIncomingDamageText: false,
healerManaRecovery: true,
autoTargetNearest: true,
enableScreenShake: true,
masterVolume: 0,
@@ -3117,7 +3128,7 @@ function resolveNpcUtilityBlockResult(
): CombatResult | null {
const mob = state.mobs[targetId];
const raw = ability.wowSpellDefense;
const contract = enemyUtilitySpellBlockSourceContract(ability.dbcSpellId, raw?.sourceProfile);
const contract = enemyUtilitySpellBlockAbilityContract(ability);
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
const casterKind = sourceActorId === PLAYER_AGGRO_ID ? "player" : member ? "party" : "unknown";
if (!contract || !raw || ability.source === "runewaker" || sourceStats.romNative
@@ -3503,6 +3514,7 @@ function combatResultDescription(result: CombatResult): string {
if (result.outcome === "dodge") return "was dodged";
if (result.outcome === "parry") return "was parried";
if (result.outcome === "resist") return "was resisted";
if (result.outcome === "deflect") return "was deflected";
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`
+ (result.blocked ? ` (${result.blockedAmount} blocked)` : "");
@@ -3730,8 +3742,30 @@ function resolveEnemyDamageResult(
nativeSourceDirect?: RomNativeEnemyDirectDamageValue,
targetOrdinal = 0,
applicationOnly = false,
defenderStunnedAtImpact?: boolean,
): CombatResult {
const attackKind = enemyAttackKind(attack);
const currentState = useCombatStore.getState();
const deterrenceAbility = deterrenceAbilityForActor(currentState, actorId);
const defenderStunned = defenderStunnedAtImpact ?? actorIsStunned(currentState, actorId, now);
const deterrencePackage = deterrenceAbility && wow335DeterrenceActivePackage({
ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now,
});
const hitSource = attack.damageOrigin === "wow335" && attack.spellId !== undefined
? enemyDamageTypeSourceForSpell("wow335", attack.spellId) : undefined;
const sourceFlags = hitSource && "hitSource" in hitSource && hitSource.hitSource && attack.wowHitSource
&& hitSource.damageClass === attack.damageClass && hitSource.schoolMask === attack.schoolMask
&& hitSource.hitSource.attributes0 === attack.wowHitSource.attributes0
&& hitSource.hitSource.attributes3 === attack.wowHitSource.attributes3
&& hitSource.hitSource.attributes7 === attack.wowHitSource.attributes7
? hitSource.hitSource : undefined;
const sourceAlwaysHit = Boolean(sourceFlags && (sourceFlags.attributes3 & 0x00040000) !== 0);
const sourceNoAttackMiss = Boolean(sourceFlags && [1, 2, 3].includes(attack.damageClass!)
&& (sourceFlags.attributes7 & 0x02000000) !== 0);
const sourceNoActiveDefense = Boolean(sourceFlags && (attack.damageClass === 2 || attack.damageClass === 3)
&& (sourceFlags.attributes0 & 0x00200000) !== 0);
const sourceCannotDodge = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x00800000) !== 0);
const sourceCannotParry = Boolean(sourceFlags && (sourceFlags.attributes7 & 0x01000000) !== 0);
const incomingModifier = (stat: string) => defenderAuras.reduce((total, aura) => total
+ (aura.definition.modifiers ?? []).reduce((sum, modifier) => modifier.kind === "stat"
&& modifier.stat === stat && modifier.operation === "flat"
@@ -3795,7 +3829,7 @@ function resolveEnemyDamageResult(
const preMitigationAmount = amount > 0 ? resolveAuraDamageValue(amount, defenderAuras, {
direction: "taken", school: attack.school, attackKind, phase: "pre-mitigation",
}) : amount;
return resolveCombatResult({
const result = resolveCombatResult({
amount: preMitigationAmount,
attackKind,
damageSchool: attack.school,
@@ -3809,11 +3843,15 @@ function resolveEnemyDamageResult(
},
rules: {
isAutoAttack: !attack.periodicTick && attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
canMiss: !attack.periodicTick && !nativeFallbackGatePassed,
canMiss: !attack.periodicTick && !nativeFallbackGatePassed && !sourceAlwaysHit && !sourceNoAttackMiss,
canCrit: !attack.periodicTick && !applicationOnly,
canDodge: !attack.periodicTick && !nativeFallbackGatePassed && attackKind === "melee",
canParry: !attack.periodicTick && attackKind === "melee",
canBlock: !attack.periodicTick && !applicationOnly && attackKind !== "spell",
canDodge: !attack.periodicTick && !nativeFallbackGatePassed && attackKind === "melee"
&& !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotDodge,
canParry: !attack.periodicTick && attackKind === "melee"
&& !(deterrencePackage && defenderStunned)
&& !sourceAlwaysHit && !sourceNoActiveDefense && !sourceCannotParry,
canBlock: !attack.periodicTick && !applicationOnly && attackKind !== "spell"
&& !sourceAlwaysHit && !sourceNoActiveDefense,
...(attack.targetMitigationSnapshotted ? { ignoreArmor: true, resistanceMode: "none" as const } : {}),
...(attack.periodicTick && attack.periodicIgnoresArmor ? { ignoreArmor: true } : {}),
baseMissChancePercent: baseMissChancePercent(attackKind, mob.level, defender.level)
@@ -3821,6 +3859,80 @@ function resolveEnemyDamageResult(
},
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
});
if (!deterrenceAbility || !result.landed || result.outcome === "deflect") return result;
const deflection = resolveWow335DeterrenceIncoming({
ability: deterrenceAbility, auras: currentState.auras, defenderId: actorId, nowMs: now,
incoming: { ...attack, attackKind,
isAutoAttack: isEnemyWeaponSwing(attack) },
baseOutcome: result.outcome, defenderStunned,
// The verified linked Aura288 makes the complete package facing-independent.
attackerInFront: false,
});
return deflection.outcome === "deflect" ? deflectLandedCombatResult(result) : result;
}
function deterrenceAbilityForActor(state: CombatState, actorId: string): AbilityDefinition | undefined {
const owner = actorId === PLAYER_AURA_ENTITY_ID
? state : usePartyStore.getState().members.find((member) => member.id === actorId);
if (!owner || owner.classId !== "hunter") return undefined;
const ability = abilityById(WOW335_HUNTER_DETERRENCE_ABILITY_ID);
return ability && isAbilityUnlocked(ability, owner.level) && wow335DeterrenceAbilityContract(ability)
? abilityAtLevel(ability, owner.level) : undefined;
}
function actorIsStunned(state: CombatState, actorId: string, now: number): boolean {
const owner = actorId === PLAYER_AURA_ENTITY_ID
? state : usePartyStore.getState().members.find((member) => member.id === actorId);
if (owner && owner.controlledUntil > now && owner.controlMechanic === "stun") return true;
return state.auras.some((aura) => aura.targetId === actorId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& (aura.definition.utilityTags?.includes("enemy-control-source:stun")
|| (aura.definition.id.startsWith("enemy-control:") && aura.definition.id.endsWith(":stun"))));
}
function deterrencePreventsIncomingSpellEffects(
state: CombatState, actorId: string, attack: EnemyAttackDefinition, now: number,
): boolean {
const ability = deterrenceAbilityForActor(state, actorId);
if (!ability) return false;
// This exact package guarantees deflection after any prior miss/resist gate.
// Its hostile riders cannot land in either case; damage keeps its actual roll.
return resolveWow335DeterrenceIncoming({ ability, auras: state.auras,
defenderId: actorId, nowMs: now, baseOutcome: "hit",
incoming: { ...attack, attackKind: enemyAttackKind(attack), isAutoAttack: isEnemyWeaponSwing(attack) },
defenderStunned: actorIsStunned(state, actorId, now), attackerInFront: false,
}).outcome === "deflect";
}
function completeDeterrenceLinkedAuras(
auras: readonly ActiveAura[], ability: AbilityDefinition, sourceId: string, targetId: string, now: number,
): readonly ActiveAura[] {
if (sourceId !== targetId || !wow335DeterrenceAbilityContract(ability)
|| !WOW335_DETERRENCE_PARENT_AURA_IDS.every((id) => auras.some((aura) => (
aura.definition.id === id && aura.sourceId === sourceId && aura.targetId === targetId
&& aura.appliedAt === now && aura.expiresAt === now + 5_000
)))) return auras;
return WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.reduce((current, effect) => {
if (effect.kind !== "apply-aura") return current;
return applyCombatAura(current, { definition: auraDefinitionForAbility(effect.aura, ability),
sourceId, targetId, now }).auras;
}, auras);
}
function synchronizedDeterrenceAuras(state: CombatState, auras: readonly ActiveAura[], now: number): readonly ActiveAura[] {
const packageIds = new Set(WOW335_DETERRENCE_PACKAGE_AURA_IDS);
let result = auras.filter((aura) => !packageIds.has(aura.definition.id)
|| aura.sourceId === aura.targetId);
const candidates = result.filter((aura) => packageIds.has(aura.definition.id));
for (const candidate of candidates) {
const ability = deterrenceAbilityForActor(state, candidate.targetId);
if (!ability || !wow335DeterrenceActivePackage({ ability, auras: result,
defenderId: candidate.targetId, nowMs: now })) {
result = result.filter((aura) => !(packageIds.has(aura.definition.id)
&& aura.sourceId === candidate.sourceId && aura.targetId === candidate.targetId));
}
}
return result;
}
function talentAdjustedMaxResource(
@@ -4274,6 +4386,27 @@ export function characterCombatDefender(state: CombatState): CombatDefenderStats
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
}
function recoveryAuras(state: CombatState, now: number): readonly ActiveAura[] {
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const actors = [
...(includePlayer ? [{ id: PLAYER_AURA_ENTITY_ID, health: state.health, role: state.gearRole,
maxMana: state.maxResourcePools.mana ?? (resourceProfileForClass(state.classId).type === "mana" ? state.maxResource : 0) }] : []),
...usePartyStore.getState().members.map((member) => ({ id: member.id, health: member.health,
role: member.role, maxMana: member.resourcePools.mana !== undefined ? companionResourceMaximum(member, "mana") : 0 })),
];
const inCombat = Object.values(state.mobs).some((mob) => !mob.dead
&& (mob.engaged || Object.values(mob.threatByActor).some((threat) => threat > 0)));
return synchronizeHealerManaRecovery(state.auras, actors, state.settings.healerManaRecovery, inCombat, now);
}
/** Refresh the shared buff before companion recovery, even without a player tick. */
export function refreshHealerManaRecovery(now: number): void {
useCombatStore.setState((state) => {
const auras = recoveryAuras(state, now);
return auras === state.auras ? state : { auras };
});
}
function normalizedSettings(
current: GameplaySettings,
patch: Partial<GameplaySettings>,
@@ -4291,6 +4424,7 @@ function normalizedSettings(
showOwnDamageText: patch.showOwnDamageText ?? current.showOwnDamageText,
showAllyDamageText: patch.showAllyDamageText ?? current.showAllyDamageText,
showIncomingDamageText: patch.showIncomingDamageText ?? current.showIncomingDamageText,
healerManaRecovery: patch.healerManaRecovery ?? current.healerManaRecovery,
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
@@ -4320,6 +4454,7 @@ function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings):
&& left.showOwnDamageText === right.showOwnDamageText
&& left.showAllyDamageText === right.showAllyDamageText
&& left.showIncomingDamageText === right.showIncomingDamageText
&& left.healerManaRecovery === right.healerManaRecovery
&& left.autoTargetNearest === right.autoTargetNearest
&& left.enableScreenShake === right.enableScreenShake
&& left.masterVolume === right.masterVolume
@@ -13466,14 +13601,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& enemyControlPreventsActions(state.controlMechanic)
&& ability?.dbcSpellId !== 498570
&& ability?.dbcSpellId !== 1953
&& !((state.controlMechanic === "fear" || state.controlMechanic === "stun")
&& !((state.controlMechanic === "fear" || state.controlMechanic === "stun" || state.controlMechanic === "confuse")
&& enemyControlAbilityCanEscapeAuras(ability, state.controlMechanic,
state.auras, PLAYER_AURA_ENTITY_ID, now))
) failure = "player-controlled";
else if (
effectiveTarget === "hostile"
&& abilityAttackKind(state.classId, ability) === "melee"
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
(wow335HunterPreventionTypeForAbility(ability) === 2
|| (effectiveTarget === "hostile" && abilityAttackKind(state.classId, ability) === "melee"))
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => (
(aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("pacified")
))
) failure = "player-controlled";
else if (state.activeCast && !completingCast) failure = "already-casting";
else if (!completingCast && !suddenDoomFreeDeathCoil
@@ -13527,6 +13664,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: effectiveTarget === "friendly"
? state.selectedFriendlyActorId ?? party.selectedMemberId
: null;
if (ability?.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) targetId = null;
// Splash selects a destination. A stale, distant or fallen selected actor
// must not become a second target requirement for that ground cast.
if (ability && isCoaWitchDoctorSplashPotion(ability) && targeting?.shape === "ground") targetId = null;
@@ -13599,7 +13737,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole,
level: state.level,
});
if (enemyDisarmBlocksAbilityCast(
const equippedItems = enemyDisarmRequirementCandidates(loadout);
if ((ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
&& !enemySpellHasMatchingEquippedItem(ability.wowWeaponRequirements, equippedItems))
|| enemyDisarmBlocksAbilityCast(
state.auras,
PLAYER_AURA_ENTITY_ID,
now,
@@ -13609,7 +13750,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
passive: ability.passive,
createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"),
},
enemyDisarmRequirementCandidates(loadout),
equippedItems,
)) failure = "condition-not-met";
}
const requiredTargetStateTag = ability ? romAbilityRequiredTargetStateTag(ability) : null;
@@ -16458,6 +16599,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? { ...aura, stacks: 0 }
: aura)
: [...auraResult.auras];
if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID) {
work.auras = [...completeDeterrenceLinkedAuras(work.auras, ability,
PLAYER_AURA_ENTITY_ID, recipientId, now)];
}
if (state.classId === "mage" && ability.name === "Presence of Mind"
&& wow335MageArcanePotencyCanProc(
mageArcanePotencyRank,
@@ -18318,7 +18463,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
resolveMobUtilitySpellHit: (id, sourceActorId, ability, now = combatNow()) => {
if (!enemyUtilitySpellBlockSourceContract(ability.dbcSpellId, ability.wowSpellDefense?.sourceProfile)) return null;
if (!enemyUtilitySpellBlockAbilityContract(ability)) return null;
let result: CombatResult | null = null;
set((state) => {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
@@ -18769,6 +18914,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
if (ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
&& (sourceActorId !== targetActorId || !deterrenceAbilityForActor(state, sourceActorId)
|| !wow335DeterrenceAbilityContract(ability)
|| !ability.effects.some((ownEffect, index) => ownEffect.kind === "apply-aura"
&& ownEffect.aura.id === effect.aura.id && wow335DeterrenceAbilityContract({
...ability, effects: ability.effects.map((entry, ordinal) => ordinal === index ? effect : entry),
})))) return false;
const isRomPriestChampionBeacon = effect.aura.utilityTags?.includes(
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG,
) ?? false;
@@ -18825,7 +18977,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| (effect.sourceAuraType === 147 && effect.miscValue === 1733);
const controlPurge = purgeNamedEnemyControlShoutAuras(unpurgedResult.auras, targetActorId,
sourceImmunityGrant ? enemyControlImmunityPurgeProofForAbility(ability) : undefined, now);
const result = { ...unpurgedResult, auras: controlPurge.auras };
const result = { ...unpurgedResult, auras: completeDeterrenceLinkedAuras(controlPurge.auras,
ability, sourceActorId, targetActorId, now) };
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
let health = state.health;
let maxHealth = state.maxHealth;
@@ -20824,6 +20977,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hasLineOfSight: combatHasLineOfSight,
}) : [];
const targets = attack.chain ? chainHits.map((hit) => hit.target.id) : primaryTargets;
const effectTargets = targets.filter((actorId) => (
!deterrencePreventsIncomingSpellEffects(state, actorId, attack, now)
));
const presentationTargetId = attack.target === "self" || attack.target === "nearby-friendly"
? id
: targets[0] ?? resolution.targetActorId;
@@ -20899,7 +21055,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
school: periodicSchool ?? attack.school, runewakerEffectType: periodicSource.component.effectType,
runewakerComponentId: periodicSource.component.componentId,
attackKind: periodicSource.component.effectType === 0 ? "spell" : "melee" } : attack;
for (const actorId of targets) {
for (const actorId of effectTargets) {
if (wowPeriodic) {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
const applicationDefender = actorId === PLAYER_AGGRO_ID
@@ -20993,7 +21149,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
instantCast: instantSourceCast,
});
}
if (targets.length) {
if (effectTargets.length) {
enemyHealing.push({
targetId: id,
amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)),
@@ -21121,7 +21277,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? enemySourceAuraContract(effect.wowAuraSource.spellId, effect.wowAuraSource.sourceProfile) : undefined;
const sourceDefinition = sourceAura ? supportedEnemySourceAuraDefinition(sourceAura) : undefined;
if (effect.wowAuraSource && !sourceDefinition) continue;
for (const actorId of targets) {
for (const actorId of effectTargets) {
if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, effect.damageSchool ?? attack.school)) continue;
if (sourceDefinition) {
const debuff = effect.wowAuraSource
@@ -21296,7 +21452,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
});
}
}
for (const actorId of targets) {
for (const actorId of effectTargets) {
if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, attack.school)) continue;
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const auraTargetId = actorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : actorId;
@@ -21358,7 +21514,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
];
} else if (effect.kind === "pull") {
const pulledPosition: CombatPosition = [position[0], position[1], position[2]];
for (const actorId of targets) {
for (const actorId of effectTargets) {
if (actorId === PLAYER_AGGRO_ID) {
set({ playerPosition: pulledPosition });
} else {
@@ -21619,7 +21775,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
};
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const playerImpactAuras = aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID);
const playerImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(current, current.auras, now), PLAYER_AURA_ENTITY_ID);
const learnedCoaPassives = learnedRomCoaPassives(
current.classId, current.secondaryClassId, current.level, current.talentRanks,
);
@@ -21674,9 +21830,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.attackRevision,
now,
bonusCriticalChancePercent,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
playerImpactAuras,
hit.nativeSourceDirect,
hit.targetOrdinal,
false,
actorIsStunned(state, hit.actorId, now),
);
const combatResult = {
...resolvedCombatResult,
@@ -22729,7 +22887,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
const member = memberFromCompanionActor(storedMember, authoritativeActor);
if (member.health <= 0) continue;
const memberImpactAuras = aurasForEntity(currentBeforeHit.auras, member.id);
const memberImpactAuras = aurasForEntity(synchronizedDeterrenceAuras(currentBeforeHit, currentBeforeHit.auras, now), member.id);
const learnedMemberCoaPassives = learnedRomCoaPassives(
member.classId, member.secondaryClassId, member.level, member.talentRanks,
);
@@ -22783,9 +22941,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.attackRevision,
now,
bonusCriticalChancePercent,
aurasForEntity(get().auras, member.id),
memberImpactAuras,
hit.nativeSourceDirect,
hit.targetOrdinal,
false,
actorIsStunned(state, hit.actorId, now),
);
const combatResult = {
...resolvedCombatResult,
@@ -23695,8 +23855,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const warriorAngerManagementRageRemainderMs = angerManagementProfile
? angerManagementElapsedMs % angerManagementProfile.tickIntervalMs
: 0;
const auraExpiration = expireAuras(state.auras, now);
let activeAuras = [...auraExpiration.active];
const auraExpiration = expireAuras(recoveryAuras(state, now), now);
let activeAuras = [...synchronizedDeterrenceAuras(state, auraExpiration.active, now)];
const activeHunterPetIds = new Set(state.summons
.filter((summon) => summon.hunterPet && summon.health > 0 && summon.expiresAt > now)
.map((summon) => summon.id));
@@ -23863,6 +24023,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}).current;
}
resourcePools[resourceProfile.type] = resource;
const recoveryManaMaximum = state.maxResourcePools.mana ?? (resourceProfile.type === "mana" ? state.maxResource : 0);
if (state.health > 0 && recoveryManaMaximum > 0) {
resourcePools.mana = Math.min(recoveryManaMaximum, (resourcePools.mana ?? 0)
+ healerManaRecoveryAmount(activeAuras, PLAYER_AURA_ENTITY_ID, recoveryManaMaximum, delta));
if (resourceProfile.type === "mana") resource = resourcePools.mana;
}
if (angerManagementProfile && angerManagementTicks > 0) {
const rageMaximum = state.maxResourcePools.rage ?? state.maxResource;
const rage = Math.min(
@@ -24026,6 +24192,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const targetPosition = getMobPosition(autoShotTargetId);
const sourcePosition = combatSpatialActor(PLAYER_AGGRO_ID);
const canAutoShot = !activeEnemyDisarmChannels(work.auras, PLAYER_AURA_ENTITY_ID, now).has("ranged")
&& !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now)
.some((aura) => aura.definition.utilityTags?.includes("pacified"))
&& !state.activeCast
&& (state.controlledUntil <= now || !enemyControlPreventsActions(state.controlMechanic))
&& Math.sqrt(mobRuntimeDistanceSquared(autoShotTargetId, state.playerPosition))
@@ -24116,6 +24284,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
const canAutoAttack = (
!state.activeCast
&& !aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID, now)
.some((aura) => aura.definition.utilityTags?.includes("pacified"))
&& !(state.classId === "rom-warden" && activeWardenNatureCrystal(work.summons, now))
&& (
state.controlledUntil <= now
@@ -26572,12 +26742,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
updateSettings: (patch) => set((state) => {
const settings = normalizedSettings(state.settings, patch);
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
return gameplaySettingsEqual(settings, state.settings) ? state : { settings,
auras: recoveryAuras({ ...state, settings }, combatNow()) };
}),
resetSettings: () => set((state) => (
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
? state
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
: { settings: DEFAULT_GAMEPLAY_SETTINGS,
auras: recoveryAuras({ ...state, settings: DEFAULT_GAMEPLAY_SETTINGS }, combatNow()) }
)),
}));
+9 -1
View File
@@ -1,4 +1,9 @@
import { enemyDamageTypeSourceForSpell, resolveEnemyDamageType, type EnemyDamageOrigin } from "./mobDamageTypes";
import {
enemyDamageTypeSourceForSpell,
resolveEnemyDamageType,
type EnemyDamageOrigin,
type WowEnemyHitSource,
} from "./mobDamageTypes";
import { romNativeEnemyPeriodicDamageSources } from "./romNativePeriodicDamage";
import type { EnemyPeriodicDamageSourceProfile } from "./enemyPeriodicDamage";
import type { EnemyAuraSourceProfile } from "./enemySourceAuras";
@@ -193,6 +198,8 @@ export interface EnemyAttackDefinition {
readonly damageClass?: number;
/** Original WoW school bits; composite schools retain the authored scalar policy. */
readonly schoolMask?: number;
/** Raw WoW Spell.dbc hit-gate attributes copied from an exact source row. */
readonly wowHitSource?: WowEnemyHitSource;
/** Source RuneWaker HP calculation channel (0 magic, 1 physical). */
readonly runewakerEffectType?: number;
readonly runewakerComponentId?: number;
@@ -420,6 +427,7 @@ export function normalizeEnemyAttack(
...(damageType.attackKind ? { attackKind: damageType.attackKind } : {}),
...(damageType.damageClass !== undefined ? { damageClass: damageType.damageClass } : {}),
...(damageType.schoolMask !== undefined ? { schoolMask: damageType.schoolMask } : {}),
...(damageType.hitSource ? { wowHitSource: damageType.hitSource } : {}),
...(damageType.sourceDamageClass === "magical" ? { runewakerEffectType: 0 }
: damageType.sourceDamageClass === "physical" ? { runewakerEffectType: 1 }
: value.runewakerEffectType !== undefined ? { runewakerEffectType: value.runewakerEffectType } : {}),
+6 -5
View File
@@ -206,7 +206,8 @@ function deterrenceContract(
});
}
const DETERRENCE_DISARM = effect(6, 185, -100);
// Aura 185 is MOD_ATTACKER_RANGED_HIT_CHANCE; it is not a Disarm aura.
const DETERRENCE_RANGED_HIT_PENALTY = effect(6, 185, -100);
const DETERRENCE_UNKNOWN = effect(6, 27, 0);
const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<number, EnemyBlockAuraContract>>>> = Object.freeze({
@@ -215,7 +216,7 @@ const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<nu
47294: criticalBlockContract("wow335", 47294, 20, [5]),
47295: criticalBlockContract("wow335", 47295, 40, [10]),
47296: criticalBlockContract("wow335", 47296, 60, [15]),
67801: deterrenceContract("wow335", 67801, 5_000, [DETERRENCE_DISARM]),
67801: deterrenceContract("wow335", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
}),
ascension: Object.freeze({
3419: blockChanceContract("ascension"),
@@ -223,15 +224,15 @@ const CONTRACTS: Readonly<Record<EnemyBlockAuraSourceProfile, Readonly<Record<nu
47295: criticalBlockContract("ascension", 47295, 100, [7, 16]),
47296: criticalBlockContract("ascension", 47296, 100, [10, 25]),
67801: deterrenceContract("ascension", 67801, 60_000, [effect(6, 4, 0), DETERRENCE_UNKNOWN]),
667801: deterrenceContract("ascension", 667801, 5_000, [DETERRENCE_DISARM, DETERRENCE_UNKNOWN]),
1167801: deterrenceContract("ascension", 1167801, 5_000, [DETERRENCE_DISARM]),
667801: deterrenceContract("ascension", 667801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY, DETERRENCE_UNKNOWN]),
1167801: deterrenceContract("ascension", 1167801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
}),
epoch: Object.freeze({
3419: blockChanceContract("epoch"),
47294: criticalBlockContract("epoch", 47294, 20, [5]),
47295: criticalBlockContract("epoch", 47295, 40, [10]),
47296: criticalBlockContract("epoch", 47296, 60, [15]),
67801: deterrenceContract("epoch", 67801, 5_000, [DETERRENCE_DISARM]),
67801: deterrenceContract("epoch", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]),
}),
});
+4 -2
View File
@@ -191,7 +191,7 @@ describe("source-backed enemy NPC blocking", () => {
expect(classifyEnemyNpcSpellBlockPhase({ ...physicalDirectSpell, sourceProfile: "epoch" as never })).toBeNull();
});
it("uses only the two source-proven custom bits for Shield Bash spell 72", () => {
it("uses only the two source-proven custom bits for the three exact utility ranks", () => {
const shieldBash = enemyNpcSourceSpellBlockMetadata(72, "wow335");
expect(shieldBash).toMatchObject({
damageClass: SPELL_DAMAGE_CLASS_MELEE,
@@ -210,7 +210,9 @@ describe("source-backed enemy NPC blocking", () => {
ignoresHitDirection: true,
})).toBe(true);
expect(enemyNpcSourceSpellBlockMetadata(72, "ascension")).toBeUndefined();
expect(enemyNpcSourceSpellBlockMetadata(1766, "wow335")).toBeUndefined();
expect(enemyNpcSourceSpellBlockMetadata(1766, "wow335")).toEqual(shieldBash);
expect(enemyNpcSourceSpellBlockMetadata(6552, "wow335")).toEqual(shieldBash);
expect(enemyNpcSourceSpellBlockMetadata(5246, "wow335")).toBeUndefined();
const unknownBehindBit = {
...shieldBash!,
+2 -2
View File
@@ -47,7 +47,7 @@ export interface EnemyNpcSpellDefenseMetadata {
}
/**
* Returns the narrowly audited Shield Bash hit-table contract. Its two block-
* Returns the narrowly audited Shield Bash, Kick and Pummel hit-table contract. Its two block-
* relevant AttributesCu bits are sourced independently; this does not assert
* that all other custom bits are zero.
*/
@@ -55,7 +55,7 @@ export function enemyNpcSourceSpellBlockMetadata(
spellId: number,
sourceProfile: EnemyNpcSpellSourceProfile,
): EnemyNpcSpellDefenseMetadata | undefined {
if (spellId !== 72 || sourceProfile !== "wow335") return undefined;
if (![72, 1766, 6552].includes(spellId) || sourceProfile !== "wow335") return undefined;
return Object.freeze({
damageClass: SPELL_DAMAGE_CLASS_MELEE,
schoolMask: SPELL_SCHOOL_MASK_NORMAL,
@@ -1,5 +1,5 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses, type AbilityDefinition } from "./abilityCatalog";
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses, type AbilityDefinition } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
@@ -63,6 +63,27 @@ describe("source immunity grants through actual runtime consumers", () => {
expect(useCombatStore.getState().castAbility(selected("warrior", "Berserker Rage").id, { now: 2_000 }))
.toMatchObject({ ok: false, reason: "player-controlled" });
});
it.each([
["fear", 19134],
["stun", 20511],
] as const)("rejects learned Bladestorm while under source %s without spending resources or purging", (mechanic, spellId) => {
prepare("player", mechanic, spellId);
const ability = selected("warrior", "Bladestorm");
const nodeId = abilityTalentNodeId(ability);
expect(nodeId).not.toBeNull();
// Synthetic learned rank isolates the cast-control gate from talent-tree allocation.
useCombatStore.setState({ talentRanks: { [nodeId!]: 1 } });
const before = useCombatStore.getState();
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000 }))
.toMatchObject({ ok: false, reason: "player-controlled" });
const after = useCombatStore.getState();
expect(after.auras).toEqual(before.auras);
expect(after.controlledUntil).toBe(before.controlledUntil);
expect(after.controlMechanic).toBe(mechanic);
expect(after.resourcePools).toEqual(before.resourcePools);
expect(after.cooldowns).toEqual(before.cooldowns);
expect(after.globalCooldownEndsAt).toBe(before.globalCooldownEndsAt);
});
it.each(["player", "party"] as const)("purges the whole FEAR-mechanic child and its rider for %s on an accepted Bladestorm grant", (recipient) => {
const id = prepare(recipient, "stun", 20511);
const ability = selected("warrior", "Bladestorm");
+146 -3
View File
@@ -9,9 +9,12 @@ import {
purgeNamedEnemyControlShoutAuras,
} from "./enemyControlImmunityPurge";
function selectedAbility(spellId: number) {
const base = WOW335_ABILITIES_BY_CLASS.warrior.find((ability) => ability.dbcSpellId === spellId);
if (!base) throw new Error(`Missing generated Warrior ability ${spellId}`);
function selectedAbility(
spellId: number,
classId: keyof typeof WOW335_ABILITIES_BY_CLASS = "warrior",
) {
const base = WOW335_ABILITIES_BY_CLASS[classId].find((ability) => ability.dbcSpellId === spellId);
if (!base) throw new Error(`Missing generated ${classId} ability ${spellId}`);
return abilityAtLevel(base, 80);
}
@@ -40,6 +43,7 @@ describe("source-proven enemy control immunity purge", () => {
grantsMechanics: ["fear"],
purgesNamedShoutSpellMechanics: ["fear"],
usableWhileControlledBy: ["fear"],
directCastBypasses: [],
});
expect(enemyControlAbilityUsableWhileControlled(berserkerRage, "fear")).toBe(true);
expect(enemyControlAbilityUsableWhileControlled(berserkerRage, "stun")).toBe(false);
@@ -55,11 +59,70 @@ describe("source-proven enemy control immunity purge", () => {
grantsMechanics: ["fear", "stun"],
purgesNamedShoutSpellMechanics: ["fear"],
usableWhileControlledBy: [],
directCastBypasses: [],
});
expect(enemyControlAbilityUsableWhileControlled(bladestorm, "fear")).toBe(false);
expect(enemyControlAbilityUsableWhileControlled(bladestorm, "stun")).toBe(false);
});
it("proves Lichborne's exact Charm/Fear/Sleep purge and its separate cast-state flags", () => {
const lichborne = selectedAbility(49039, "death-knight");
const proof = enemyControlImmunityPurgeProofForAbility(lichborne);
expect(lichborne).toMatchObject({
id: "wow335-death-knight-lichborne",
name: "Lichborne",
classId: "death-knight",
source: "wow335-progression",
dbcSpellId: 49039,
wowSpellDefense: {
sourceProfile: "wow335",
damageClass: 0,
schoolMask: 32,
attributes0: 262144,
attributes1: 163872,
attributes3: 0,
attributes5: 393224,
directDamageFromEffects: false,
},
});
expect(lichborne.effects).toHaveLength(3);
const expectedEffects = [
{ miscValue: 1, sourceBasePoints: 0, sourceDieSides: 1, immunity: undefined },
{ miscValue: 5, sourceBasePoints: 0, sourceDieSides: 0, immunity: "fear" },
{ miscValue: 10, sourceBasePoints: 0, sourceDieSides: 0, immunity: "sleep" },
] as const;
lichborne.effects.forEach((effect, index) => {
const expected = expectedEffects[index];
expect(effect.kind).toBe("apply-aura");
if (effect.kind !== "apply-aura") throw new Error("Expected selected Lichborne immunity aura");
expect(effect).toMatchObject({
sourceEffectType: 6,
sourceAuraType: 77,
sourceBasePoints: expected.sourceBasePoints,
sourceDieSides: expected.sourceDieSides,
sourcePointsPerLevel: 0,
miscValue: expected.miscValue,
recipient: "caster",
});
if (expected.immunity) expect(effect.aura.controlImmunities).toContain(expected.immunity);
expect(effect.aura.id).toBe(`wow335:49039:effect:${index}:aura:77`);
});
expect(proof).toMatchObject({
sourceProfile: "wow335",
sourceSpellId: 49039,
grantsMechanics: ["charm", "fear", "sleep"],
purgesNamedShoutSpellMechanics: ["fear"],
usableWhileControlledBy: ["fear", "stun", "confuse"],
directCastBypasses: ["fear", "stun", "confuse"],
});
expect(enemyControlAbilityUsableWhileControlled(lichborne, "fear")).toBe(true);
expect(enemyControlAbilityUsableWhileControlled(lichborne, "stun")).toBe(true);
expect(enemyControlAbilityUsableWhileControlled(lichborne, "confuse")).toBe(true);
expect(enemyControlAbilityUsableWhileControlled(lichborne, "charm")).toBe(false);
expect(enemyControlAbilityUsableWhileControlled(lichborne, "sleep")).toBe(false);
});
it("fails closed for a changed profile, missing or changed raw flags, and changed aura scalars", () => {
const berserkerRage = selectedAbility(18499);
const defense = berserkerRage.wowSpellDefense!;
@@ -89,6 +152,35 @@ describe("source-proven enemy control immunity purge", () => {
})).toBeUndefined();
});
it("fails closed for Lichborne when its selected WoW profile, full flags, or effect scalars change", () => {
const lichborne = selectedAbility(49039, "death-knight");
const defense = lichborne.wowSpellDefense!;
const fearIndex = lichborne.effects.findIndex((effect) => (
effect.kind === "apply-aura" && effect.sourceAuraType === 77 && effect.miscValue === 5
));
const fearAura = lichborne.effects[fearIndex];
if (fearAura.kind !== "apply-aura") throw new Error("Expected selected Lichborne fear immunity aura");
expect(enemyControlImmunityPurgeProofForAbility({
...lichborne,
wowSpellDefense: { ...defense, sourceProfile: "ascension" },
})).toBeUndefined();
expect(enemyControlImmunityPurgeProofForAbility({
...lichborne,
wowSpellDefense: { ...defense, attributes1: undefined },
})).toBeUndefined();
expect(enemyControlImmunityPurgeProofForAbility({
...lichborne,
wowSpellDefense: { ...defense, attributes5: defense.attributes5! ^ 0x00020000 },
})).toBeUndefined();
expect(enemyControlImmunityPurgeProofForAbility({
...lichborne,
effects: lichborne.effects.map((effect, index) => index === fearIndex
? { ...fearAura, sourceDieSides: 1 }
: effect),
})).toBeUndefined();
});
it("does not infer active purge from Icebound Fortitude's stun-immunity row", () => {
const base = WOW335_ABILITIES_BY_CLASS["death-knight"].find((ability) => ability.dbcSpellId === 48792);
if (!base) throw new Error("Missing generated Death Knight ability 48792");
@@ -128,6 +220,7 @@ describe("source-proven enemy control immunity purge", () => {
grantsMechanics: ["fear"],
purgesNamedShoutSpellMechanics: ["fear"],
usableWhileControlledBy: ["fear"],
directCastBypasses: [],
} as const;
expect(purgeNamedEnemyControlShoutAuras([aura], "party-warrior", forged, 0)).toEqual({
@@ -155,4 +248,54 @@ describe("source-proven enemy control immunity purge", () => {
berserkerRage, "stun", [sourceFear], "party-warrior", 100,
)).toBe(false);
});
it("does not let Bladestorm borrow its purge grant as a control cast bypass", () => {
const bladestorm = selectedAbility(46924);
const sourceFear = activeAura("enemy-control:source-spell-5246", "party-warrior");
const sourceChildStun = activeAura("enemy-control:source-spell-20511", "party-warrior");
expect(enemyControlAbilityCanEscapeAuras(
bladestorm, "fear", [sourceFear], "party-warrior", 100,
)).toBe(false);
expect(enemyControlAbilityCanEscapeAuras(
bladestorm, "stun", [sourceChildStun], "party-warrior", 100,
)).toBe(false);
});
it("allows Lichborne through sourced or directly supported control and retains unrelated auras", () => {
const lichborne = selectedAbility(49039, "death-knight");
const proof = enemyControlImmunityPurgeProofForAbility(lichborne);
const sourceChildStun = activeAura("enemy-control:source-spell-20511", "party-death-knight");
const genericStun = activeAura("enemy-control:another-caster:another-spell:stun", "party-death-knight");
const genericConfuse = activeAura("enemy-control:another-caster:another-spell:confuse", "party-death-knight");
const genericFear = activeAura("enemy-control:another-caster:another-spell:fear", "party-death-knight");
expect(enemyControlAbilityCanEscapeAuras(
lichborne, "stun", [sourceChildStun], "party-death-knight", 100,
)).toBe(true);
expect(enemyControlAbilityCanEscapeAuras(
lichborne, "stun", [genericStun], "party-death-knight", 100,
)).toBe(true);
expect(enemyControlAbilityCanEscapeAuras(
lichborne, "confuse", [genericConfuse], "party-death-knight", 100,
)).toBe(true);
expect(enemyControlAbilityCanEscapeAuras(
lichborne, "fear", [genericFear], "party-death-knight", 100,
)).toBe(true);
expect(enemyControlAbilityCanEscapeAuras(
selectedAbility(18499), "fear", [genericFear], "party-death-knight", 100,
)).toBe(false);
const mixedFear = purgeNamedEnemyControlShoutAuras(
[genericFear, activeAura("enemy-control:source-spell-19134", "party-death-knight")],
"party-death-knight", proof, 100,
);
expect(mixedFear.removed.map((aura) => aura.definition.id)).toEqual(["enemy-control:source-spell-19134"]);
expect(mixedFear.auras).toEqual([genericFear]);
expect(purgeNamedEnemyControlShoutAuras(
[genericStun, genericConfuse], "party-death-knight", proof, 100,
)).toEqual({
auras: [genericStun, genericConfuse],
removed: [],
});
});
});
+99 -18
View File
@@ -1,17 +1,23 @@
import type { AbilityDefinition } from "./abilityCatalog";
import type { ActiveAura } from "./combatAuras";
export type EnemyControlImmunityMechanic = "fear" | "stun";
export type EnemyControlImmunityMechanic = "charm" | "confuse" | "fear" | "sleep" | "stun";
export type EnemyControlCastBlockMechanic = Extract<
EnemyControlImmunityMechanic,
"confuse" | "fear" | "stun"
>;
export interface EnemyControlImmunityPurgeProof {
readonly sourceProfile: "wow335";
readonly sourceSpellId: 18499 | 46924;
readonly sourceSpellId: 18499 | 46924 | 49039;
/** Mechanic immunities proven by the exact selected source aura/effect. */
readonly grantsMechanics: readonly EnemyControlImmunityMechanic[];
/** Every named shout aura below has spell-level Mechanic=FEAR. */
readonly purgesNamedShoutSpellMechanics: readonly "fear"[];
/** Core cast-prevention bypasses proven for this selected spell. */
readonly usableWhileControlledBy: readonly EnemyControlImmunityMechanic[];
readonly usableWhileControlledBy: readonly EnemyControlCastBlockMechanic[];
/** Explicit source flags that permit casts through a control aura without purging it. */
readonly directCastBypasses: readonly EnemyControlCastBlockMechanic[];
}
const IMMUNITY_PURGES_EFFECT = 0x00008000;
@@ -29,7 +35,7 @@ function isExactApplyAura(
effect: AbilityDefinition["effects"][number] | undefined,
expected: {
readonly index: number;
readonly spellId: 18499 | 46924;
readonly spellId: 18499 | 46924 | 49039;
readonly auraType: number;
readonly miscValue: number;
readonly basePoints: number;
@@ -60,25 +66,28 @@ function verifiedProof(
function defenseMatches(
ability: AbilityDefinition,
expected: {
readonly spellId: 18499 | 46924;
readonly spellId: 18499 | 46924 | 49039;
readonly classId: "warrior" | "death-knight";
readonly attributes0: number;
readonly attributes1: number;
readonly attributes3: number;
readonly attributes5: number;
readonly damageClass: number;
readonly schoolMask: number;
},
): boolean {
const defense = ability.wowSpellDefense;
return ability.dbcSpellId === expected.spellId
&& ability.classId === "warrior"
&& ability.classId === expected.classId
&& ability.source === "wow335-progression"
&& defense?.sourceProfile === "wow335"
&& defense.damageClass === expected.damageClass
&& defense.schoolMask === 1
&& defense.schoolMask === expected.schoolMask
&& defense.attributes0 === expected.attributes0
&& defense.attributes1 === expected.attributes1
&& defense.attributes3 === expected.attributes3
&& defense.attributes5 === expected.attributes5
&& defense.directDamageFromEffects === false
&& Number.isInteger(defense.attributes1)
&& Number.isInteger(defense.attributes5)
&& (defense.attributes1 & IMMUNITY_PURGES_EFFECT) !== 0;
@@ -91,11 +100,13 @@ function berserkerRageProof(ability: AbilityDefinition): EnemyControlImmunityPur
|| ability.effects.length !== 3
|| !defenseMatches(ability, {
spellId: 18499,
classId: "warrior",
attributes0: 327696,
attributes1: 32768,
attributes3: 0,
attributes5: 0,
damageClass: 0,
schoolMask: 1,
})) return undefined;
const [fear, sap, incapacitate] = ability.effects;
@@ -115,6 +126,7 @@ function berserkerRageProof(ability: AbilityDefinition): EnemyControlImmunityPur
grantsMechanics: Object.freeze(["fear"]),
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
usableWhileControlledBy: Object.freeze(["fear"]),
directCastBypasses: Object.freeze([]),
});
}
@@ -125,11 +137,13 @@ function bladestormProof(ability: AbilityDefinition): EnemyControlImmunityPurgeP
|| ability.effects.length !== 4
|| !defenseMatches(ability, {
spellId: 46924,
classId: "warrior",
attributes0: 327696,
attributes1: 32784,
attributes3: 1024,
attributes5: 512,
damageClass: 2,
schoolMask: 1,
})) return undefined;
const sourceAuras = ability.effects.filter((effect) => effect.kind === "apply-aura");
@@ -157,16 +171,55 @@ function bladestormProof(ability: AbilityDefinition): EnemyControlImmunityPurgeP
grantsMechanics: Object.freeze(["fear", "stun"]),
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
usableWhileControlledBy: Object.freeze([]),
directCastBypasses: Object.freeze([]),
});
}
/** Returns a source-pinned purge/cast proof only for the two audited WoW 3.3.5a rows. */
function lichborneProof(ability: AbilityDefinition): EnemyControlImmunityPurgeProof | undefined {
if (ability.id !== "wow335-death-knight-lichborne"
|| ability.name !== "Lichborne"
|| ability.target !== "self"
|| ability.effects.length !== 3
|| !defenseMatches(ability, {
spellId: 49039,
classId: "death-knight",
attributes0: 262144,
attributes1: 163872,
attributes3: 0,
attributes5: 393224,
damageClass: 0,
schoolMask: 32,
})) return undefined;
const [charm, fear, sleep] = ability.effects;
if (!isExactApplyAura(charm, {
index: 0, spellId: 49039, auraType: 77, miscValue: 1, basePoints: 0, dieSides: 1,
})
|| !isExactApplyAura(fear, {
index: 1, spellId: 49039, auraType: 77, miscValue: 5, basePoints: 0, dieSides: 0,
}) || !fear.aura.controlImmunities?.includes("fear")
|| !isExactApplyAura(sleep, {
index: 2, spellId: 49039, auraType: 77, miscValue: 10, basePoints: 0, dieSides: 0,
}) || !sleep.aura.controlImmunities?.includes("sleep")) return undefined;
return verifiedProof({
sourceProfile: "wow335",
sourceSpellId: 49039,
grantsMechanics: Object.freeze(["charm", "fear", "sleep"]),
purgesNamedShoutSpellMechanics: Object.freeze(["fear"]),
usableWhileControlledBy: Object.freeze(["fear", "stun", "confuse"]),
directCastBypasses: Object.freeze(["fear", "stun", "confuse"]),
});
}
/** Returns a source-pinned purge/cast proof only for the three audited WoW 3.3.5a rows. */
export function enemyControlImmunityPurgeProofForAbility(
ability: AbilityDefinition | null | undefined,
): EnemyControlImmunityPurgeProof | undefined {
if (!ability) return undefined;
if (ability.dbcSpellId === 18499) return berserkerRageProof(ability);
if (ability.dbcSpellId === 46924) return bladestormProof(ability);
if (ability.dbcSpellId === 49039) return lichborneProof(ability);
return undefined;
}
@@ -175,24 +228,26 @@ export function enemyControlAbilityUsableWhileControlled(
ability: AbilityDefinition | null | undefined,
mechanic: EnemyControlImmunityMechanic,
): boolean {
return enemyControlImmunityPurgeProofForAbility(ability)?.usableWhileControlledBy.includes(mechanic) ?? false;
return enemyControlImmunityPurgeProofForAbility(ability)?.usableWhileControlledBy
.some((allowed) => allowed === mechanic) ?? false;
}
const NON_ACTION_BLOCKING_CONTROL_MECHANICS = new Set(["root", "slow", "interrupt"]);
/**
* The Berserker Rage fear exception also covers the Fear-mechanic 20511 child
* while it applies a stun effect. It does not cover a separate stun aura.
* Berserker Rage and Lichborne can use the Fear-mechanic 20511 child while it
* applies a stun effect. Lichborne's exact row also permits separate stun and
* confuse states; those auras stay active because the row grants no such purge.
*/
export function enemyControlAbilityCanEscapeAuras(
ability: AbilityDefinition | null | undefined,
mechanic: EnemyControlImmunityMechanic,
mechanic: EnemyControlCastBlockMechanic,
auras: readonly ActiveAura[],
targetId: string,
now: number,
): boolean {
const proof = enemyControlImmunityPurgeProofForAbility(ability);
if (!proof || proof.sourceSpellId !== 18499 || !Number.isFinite(now)) return false;
if (!proof || !Number.isFinite(now)) return false;
const activeControlAuras = auras.filter((aura) => aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.id.startsWith("enemy-control:"));
@@ -200,21 +255,47 @@ export function enemyControlAbilityCanEscapeAuras(
const allPreventingAurasAreCovered = activeControlAuras.every((aura) => {
const id = aura.definition.id;
if (NAMED_SHOUT_AURA_IDS.has(id)) return true;
if (NAMED_SHOUT_AURA_IDS.has(id)) {
return proof.purgesNamedShoutSpellMechanics.includes("fear");
}
const suffix = id.slice("enemy-control:".length);
if (suffix.startsWith("source-spell-")) return false;
const sourceMechanic = suffix.split(":").at(-1);
return sourceMechanic !== undefined
&& NON_ACTION_BLOCKING_CONTROL_MECHANICS.has(sourceMechanic);
&& (NON_ACTION_BLOCKING_CONTROL_MECHANICS.has(sourceMechanic)
|| proof.directCastBypasses.some((allowed) => allowed === sourceMechanic));
});
if (!allPreventingAurasAreCovered) return false;
if (mechanic === "fear") {
return enemyControlAbilityUsableWhileControlled(ability, "fear")
const canPurgeNamedShout = proof.purgesNamedShoutSpellMechanics.includes("fear")
&& activeControlAuras.some((aura) => NAMED_SHOUT_AURA_IDS.has(aura.definition.id));
const canBypassGenericFear = proof.directCastBypasses.includes("fear")
&& activeControlAuras.some((aura) => {
const suffix = aura.definition.id.slice("enemy-control:".length);
return !NAMED_SHOUT_AURA_IDS.has(aura.definition.id)
&& !suffix.startsWith("source-spell-")
&& suffix.split(":").at(-1) === "fear";
});
return enemyControlAbilityUsableWhileControlled(ability, "fear")
&& (canPurgeNamedShout || canBypassGenericFear);
}
return mechanic === "stun"
&& activeControlAuras.some((aura) => aura.definition.id === "enemy-control:source-spell-20511");
if (mechanic === "stun") {
const canCastThroughSourceChild = activeControlAuras.some(
(aura) => aura.definition.id === "enemy-control:source-spell-20511",
) && (enemyControlAbilityUsableWhileControlled(ability, "fear")
|| enemyControlAbilityUsableWhileControlled(ability, "stun"));
const canCastThroughDirectStun = proof.directCastBypasses.includes("stun")
&& activeControlAuras.some((aura) => {
const suffix = aura.definition.id.slice("enemy-control:".length);
return !suffix.startsWith("source-spell-") && suffix.split(":").at(-1) === "stun";
});
return canCastThroughSourceChild || canCastThroughDirectStun;
}
return mechanic === "confuse"
&& proof.directCastBypasses.includes("confuse")
&& activeControlAuras.some((aura) => aura.definition.id.slice("enemy-control:".length)
.split(":").at(-1) === "confuse");
}
export interface EnemyControlShoutAuraPurgeResult {
+9
View File
@@ -285,6 +285,15 @@ function equippedItemMatchesRequirement(
return true;
}
/** Checks the equipped item itself; Disarm usability has its separate source exceptions. */
export function enemySpellHasMatchingEquippedItem(
requirement: EnemyDisarmAbilityRequirement,
equippedItems: readonly EnemyDisarmEquippedItem[],
): boolean {
return requirement.equippedItemClass < 0
|| equippedItems.some((item) => equippedItemMatchesRequirement(item, requirement));
}
/**
* Identify self-cast passive item auras whose every matching equipped weapon
* is currently disarmed. This is an effective-aura view: the persistent aura
+70 -13
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
enemyNpcSourceSpellBlockMetadata,
SPELL_ATTR0_CU_DIRECT_DAMAGE,
@@ -8,10 +10,13 @@ import {
} from "./enemyBlocking";
import {
enemyUtilityEffectsAfterBlock,
enemyUtilitySpellBlockAbilityContract,
enemyUtilitySpellBlockDisposition,
enemyUtilitySpellBlockSourceContract,
} from "./enemyUtilitySpellBlocking";
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rogue"]); });
const sourceSpell = enemyNpcSourceSpellBlockMetadata(72, "wow335")!;
const utilityEffects = [
{ kind: "interrupt", lockoutMs: 6_000 },
@@ -28,28 +33,80 @@ const baseInput = {
};
describe("ordinary NPC utility spell full-hit blocking", () => {
it("publishes the exact Shield Bash 72 signature and block-relevant metadata", () => {
it("publishes the exact Shield Bash, Kick, and Pummel rank contracts", () => {
expect(enemyUtilitySpellBlockSourceContract(72, "wow335")).toMatchObject({
spellId: 72,
sourceProfile: "wow335",
spellId: 72, sourceProfile: "wow335", abilityId: "wow335-warrior-shield-bash",
classId: "warrior", abilityName: "Shield Bash",
spellDefense: {
damageClass: SPELL_DAMAGE_CLASS_MELEE,
schoolMask: SPELL_SCHOOL_MASK_NORMAL,
attributes0: 327696,
attributes3: 8,
directDamageFromEffects: false,
sourceProfile: "wow335",
customAttributeEvidence: { knownMask: 131_328, value: 0 },
damageClass: SPELL_DAMAGE_CLASS_MELEE, schoolMask: SPELL_SCHOOL_MASK_NORMAL,
attributes0: 327696, attributes3: 8, directDamageFromEffects: false,
sourceProfile: "wow335", customAttributeEvidence: { knownMask: 131_328, value: 0 },
},
dbcEffects: [
{ index: 0, effectType: 68, mechanic: 26, targetA: 6, triggerSpellId: 0 },
{ index: 1, effectType: 64, mechanic: 0, targetA: 6, triggerSpellId: 29_703 },
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 0 },
{ index: 1, effectType: 64, auraType: 0, mechanic: 0, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 29_703 },
],
projectedEffects: [
{ kind: "interrupt", lockoutMs: 6_000 },
{ kind: "trigger-spell", spellId: 29_703 },
],
});
expect(enemyUtilitySpellBlockSourceContract(1766, "wow335")).toMatchObject({
spellId: 1766, abilityId: "rogue-kick", sourceAbilityId: "wow335-rogue-kick",
classId: "rogue", abilityName: "Kick",
dbcEffects: [
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 },
{ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 },
],
projectedEffects: [{ kind: "interrupt", lockoutMs: 5_000 }],
});
expect(enemyUtilitySpellBlockSourceContract(6552, "wow335")).toMatchObject({
spellId: 6552, abilityId: "wow335-warrior-pummel", classId: "warrior", abilityName: "Pummel",
dbcEffects: [
{ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 },
{ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 },
],
projectedEffects: [{ kind: "interrupt", lockoutMs: 4_000 }],
});
expect(enemyUtilitySpellBlockSourceContract(72, "ascension")).toBeUndefined();
expect(enemyUtilitySpellBlockSourceContract(1_671, "wow335")).toBeUndefined();
});
it("binds blockability to the actual selected playable ability and exact projected effects", () => {
const cases = [
{ classId: "warrior" as const, name: "Shield Bash", spellId: 72 },
{ classId: "rogue" as const, name: "Kick", spellId: 1_766 },
{ classId: "warrior" as const, name: "Pummel", spellId: 6_552 },
];
for (const item of cases) {
const base = abilitiesForClass(item.classId).find((ability) => ability.name === item.name);
expect(base, item.name).toBeDefined();
const selected = abilityAtLevel(base!, 80);
expect(selected.dbcSpellId, item.name).toBe(item.spellId);
expect(selected.source, item.name).toBe("wow335-progression");
expect(enemyUtilitySpellBlockAbilityContract(selected)?.spellId, JSON.stringify(selected)).toBe(item.spellId);
}
const rawKick = WOW335_ABILITIES_BY_CLASS.rogue.find((ability) => ability.id === "wow335-rogue-kick");
expect(rawKick).toBeDefined();
expect(enemyUtilitySpellBlockAbilityContract(abilityAtLevel(rawKick!, 80))?.spellId).toBe(1_766);
const kick = abilityAtLevel(abilitiesForClass("rogue").find((ability) => ability.name === "Kick")!, 80);
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, id: "wrong-chain" })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, classId: "warrior" })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, source: "runewaker" })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, dbcSpellId: 6552 })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, effects: [
...kick.effects, { kind: "status", status: "stun", durationMs: 1_000 },
] })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, effects: [
{ kind: "interrupt", lockoutMs: 4_000 },
] })).toBeUndefined();
expect(enemyUtilitySpellBlockAbilityContract({ ...kick, wowSpellDefense: {
...kick.wowSpellDefense!, customAttributes: SPELL_ATTR0_CU_DIRECT_DAMAGE,
} })).toBeUndefined();
});
it("suppresses every utility effect after a resolved full block", () => {
const disposition = enemyUtilitySpellBlockDisposition({ ...baseInput, outcome: "block" });
const effectsToApply = enemyUtilityEffectsAfterBlock(utilityEffects, disposition);
+146 -26
View File
@@ -4,6 +4,7 @@ import {
type EnemyNpcSpellDefenseMetadata,
type EnemyNpcSpellSourceProfile,
} from "./enemyBlocking";
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type EnemyUtilitySpellCasterKind = "player" | "party" | "enemy-npc" | "rom-native" | "unknown";
export type EnemyUtilitySpellTargetKind = "ordinary-npc" | "character" | "native-rom" | "summon" | "unknown";
@@ -11,54 +12,173 @@ export type EnemyUtilitySpellHitOutcome = "hit" | "crit" | "miss" | "dodge" | "p
export type EnemyUtilitySpellBlockDisposition = "apply" | "blocked" | "avoided" | "fallback";
export interface EnemyUtilitySpellBlockSourceContract {
readonly spellId: 72;
readonly spellId: 72 | 1766 | 6552;
readonly sourceProfile: "wow335";
readonly abilityId: string;
/** Raw imported chain ID, accepted only with the same exact source projection. */
readonly sourceAbilityId?: string;
readonly classId: "warrior" | "rogue";
readonly abilityName: "Shield Bash" | "Kick" | "Pummel";
readonly spellDefense: EnemyNpcSpellDefenseMetadata;
/** Relevant fields from the two exact Shield Bash Spell.dbc effect slots. */
readonly dbcEffects: readonly [
{ readonly index: 0; readonly effectType: 68; readonly mechanic: 26; readonly targetA: 6; readonly triggerSpellId: 0 },
{ readonly index: 1; readonly effectType: 64; readonly mechanic: 0; readonly targetA: 6; readonly triggerSpellId: 29703 },
];
/** Relevant source fields from the exact selected Spell.dbc rank. */
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
/** Exact projection produced by the playable catalog for this selected rank. */
readonly projectedEffects: readonly AbilityEffect[];
}
const SHIELD_BASH_72_EFFECTS = Object.freeze([
Object.freeze({ index: 0 as const, effectType: 68 as const, mechanic: 26 as const, targetA: 6 as const, triggerSpellId: 0 as const }),
Object.freeze({ index: 1 as const, effectType: 64 as const, mechanic: 0 as const, targetA: 6 as const, triggerSpellId: 29703 as const }),
]) as EnemyUtilitySpellBlockSourceContract["dbcEffects"];
interface EnemyUtilitySpellBlockDbcEffect {
readonly index: number;
readonly effectType: number;
readonly auraType: number;
readonly mechanic: number;
readonly targetA: number;
readonly targetB: number;
readonly basePoints: number;
readonly triggerSpellId: number;
}
interface EnemyUtilitySpellBlockRankContract {
readonly spellId: EnemyUtilitySpellBlockSourceContract["spellId"];
readonly abilityId: string;
readonly sourceAbilityId?: string;
readonly classId: "warrior" | "rogue";
readonly abilityName: EnemyUtilitySpellBlockSourceContract["abilityName"];
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
readonly projectedEffects: readonly AbilityEffect[];
}
const UTILITY_SPELL_BLOCK_RANKS: readonly EnemyUtilitySpellBlockRankContract[] = Object.freeze([
Object.freeze({
spellId: 72,
abilityId: "wow335-warrior-shield-bash",
classId: "warrior",
abilityName: "Shield Bash",
dbcEffects: Object.freeze([
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
Object.freeze({ index: 1, effectType: 64, auraType: 0, mechanic: 0, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 29703 }),
]),
projectedEffects: Object.freeze([
Object.freeze({ kind: "interrupt", lockoutMs: 6_000 }),
Object.freeze({ kind: "trigger-spell", spellId: 29_703 }),
]),
}),
Object.freeze({
spellId: 1766,
abilityId: "rogue-kick",
sourceAbilityId: "wow335-rogue-kick",
classId: "rogue",
abilityName: "Kick",
dbcEffects: Object.freeze([
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
]),
projectedEffects: Object.freeze([
Object.freeze({ kind: "interrupt", lockoutMs: 5_000 }),
]),
}),
Object.freeze({
spellId: 6552,
abilityId: "wow335-warrior-pummel",
classId: "warrior",
abilityName: "Pummel",
dbcEffects: Object.freeze([
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
]),
projectedEffects: Object.freeze([
Object.freeze({ kind: "interrupt", lockoutMs: 4_000 }),
]),
}),
]);
/**
* Returns the only audited utility full-hit-block spell contract. The rank and
* profile are intentionally exact; the DBC effect signature is interrupt plus
* its triggered aura, with no direct-damage effect.
* Returns an audited utility full-block contract for an exact WoW 3.3.5a
* selected rank. Other ranks and source profiles remain on their current path.
*/
export function enemyUtilitySpellBlockSourceContract(
spellId: number | undefined,
sourceProfile: EnemyNpcSpellSourceProfile | undefined,
): EnemyUtilitySpellBlockSourceContract | undefined {
if (spellId !== 72 || sourceProfile !== "wow335") return undefined;
const spellDefense = enemyNpcSourceSpellBlockMetadata(72, "wow335");
if (sourceProfile !== "wow335") return undefined;
const rank = UTILITY_SPELL_BLOCK_RANKS.find((candidate) => candidate.spellId === spellId);
if (!rank) return undefined;
const spellDefense = enemyNpcSourceSpellBlockMetadata(rank.spellId, "wow335");
if (!spellDefense) return undefined;
return Object.freeze({
spellId: 72,
spellId: rank.spellId,
sourceProfile: "wow335",
abilityId: rank.abilityId,
...(rank.sourceAbilityId ? { sourceAbilityId: rank.sourceAbilityId } : {}),
classId: rank.classId,
abilityName: rank.abilityName,
spellDefense,
dbcEffects: SHIELD_BASH_72_EFFECTS,
dbcEffects: rank.dbcEffects,
projectedEffects: rank.projectedEffects,
});
}
function matchesShieldBashBlockMetadata(
function matchesUtilityBlockMetadata(
metadata: EnemyNpcSpellDefenseMetadata | undefined,
contract: EnemyUtilitySpellBlockSourceContract,
reconstructSourceProof = false,
): boolean {
if (!metadata) return false;
const expected = contract.spellDefense;
return metadata.damageClass === expected.damageClass
&& metadata.schoolMask === expected.schoolMask
&& metadata.attributes0 === expected.attributes0
&& metadata.attributes3 === expected.attributes3
&& metadata.directDamageFromEffects === expected.directDamageFromEffects
&& metadata.sourceProfile === expected.sourceProfile
&& classifyEnemyNpcSpellBlockPhase(metadata) === "hit-table";
if (metadata.damageClass !== expected.damageClass
|| metadata.schoolMask !== expected.schoolMask
|| metadata.attributes0 !== expected.attributes0
|| metadata.attributes3 !== expected.attributes3
|| metadata.directDamageFromEffects !== expected.directDamageFromEffects
|| metadata.sourceProfile !== expected.sourceProfile) return false;
const knownCustom = expected.customAttributeEvidence;
if (knownCustom) {
if (metadata.customAttributes !== undefined
&& ((metadata.customAttributes & knownCustom.knownMask) >>> 0) !== knownCustom.value) return false;
const evidence = metadata.customAttributeEvidence;
if (evidence && ((evidence.knownMask & knownCustom.knownMask) !== knownCustom.knownMask
|| ((evidence.value & knownCustom.knownMask) >>> 0) !== knownCustom.value)) return false;
}
const withSourceProof = reconstructSourceProof
&& metadata.customAttributes === undefined
&& metadata.customAttributeEvidence === undefined && knownCustom
? { ...metadata, customAttributeEvidence: knownCustom }
: metadata;
return classifyEnemyNpcSpellBlockPhase(withSourceProof) === "hit-table";
}
function stableSignature(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableSignature).join(",")}]`;
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableSignature(entry)}`).join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
/**
* Binds the spell-block contract to the actual selected playable catalog row.
* Matching a spell ID alone cannot authorize another class, source, wrapper,
* or modified effect list to borrow this source proof.
*/
export function enemyUtilitySpellBlockAbilityContract(
ability: AbilityDefinition | undefined,
): EnemyUtilitySpellBlockSourceContract | undefined {
if (!ability) return undefined;
const contract = enemyUtilitySpellBlockSourceContract(
ability.dbcSpellId,
ability.wowSpellDefense?.sourceProfile,
);
if (!contract
|| (ability.id !== contract.abilityId && ability.id !== contract.sourceAbilityId)
|| ability.classId !== contract.classId
|| ability.name !== contract.abilityName
|| ability.source !== "wow335-progression"
|| ability.dbcSpellId !== contract.spellId
|| !matchesUtilityBlockMetadata(ability.wowSpellDefense, contract, true)
|| stableSignature(ability.effects) !== stableSignature(contract.projectedEffects)) return undefined;
return contract;
}
/**
@@ -78,7 +198,7 @@ export function enemyUtilitySpellBlockDisposition(input: {
if (!contract
|| input.casterKind !== "player" && input.casterKind !== "party"
|| input.targetKind !== "ordinary-npc"
|| !matchesShieldBashBlockMetadata(input.spellDefense, contract)) return "fallback";
|| !matchesUtilityBlockMetadata(input.spellDefense, contract)) return "fallback";
if (input.outcome === "block") return "blocked";
if (input.outcome === "miss" || input.outcome === "dodge"
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "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 Line truncated
export default "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 Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
File diff suppressed because it is too large. Load diff
@@ -68390,9 +68390,9 @@
{
"id": "visual",
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
"byteLength": 8566408,
"triangleCount": 560149,
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
"byteLength": 8803744,
"triangleCount": 560186,
"compression": "meshopt+ktx2",
"transform": {
"position": [
@@ -97715,9 +97715,9 @@
{
"id": "visual",
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
"byteLength": 59344144,
"triangleCount": 5185049,
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
"byteLength": 29792924,
"triangleCount": 4816703,
"compression": "meshopt+ktx2",
"transform": {
"position": [
@@ -67005,9 +67005,9 @@
{
"id": "visual",
"url": "/assets/game/manastorm/33-shadowfang/visual.glb",
"checksum": "2c562fb61596a536d728385c4f2bdfdc3230f4f84ef40875b9a568e18a82ef9d",
"byteLength": 8566408,
"triangleCount": 560149,
"checksum": "6c69f739383fc98f440f181785c4f27c7f39cfcb18d9a8b5ac8282262070bf75",
"byteLength": 8803744,
"triangleCount": 560186,
"compression": "meshopt+ktx2",
"transform": {
"position": [
@@ -93841,9 +93841,9 @@
{
"id": "visual",
"url": "/assets/game/manastorm/575-utgardepinnacle/visual.glb",
"checksum": "26ecf5398de8b836e829b9fa39a4e78f7d8aaf60e0daf37c94dfdf3a4944c722",
"byteLength": 59344144,
"triangleCount": 5185049,
"checksum": "bffaf126d0f65b2863d54418b1889f9e62adf5562491c3e74e6a5c5e05664844",
"byteLength": 29792924,
"triangleCount": 4816703,
"compression": "meshopt+ktx2",
"transform": {
"position": [
@@ -4401,9 +4401,16 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
"navigation": {
"id": "navigation",
"fileName": "raksha-temple-navigation.glb",
"checksum": "1e1d1ad26907dc8c4fd0a917dba40b52d2aaaea4a0335af06871ee28230eddf1",
"size": 8686564,
"triangleCount": 180952
"checksum": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117",
"size": 166048,
"detourTopology": {
"schemaVersion": 1,
"polygonCount": 239,
"directedLinkCount": 514,
"detailTriangleCount": 653,
"assetChecksum": "77b2b22bb89474bfe2bd9974d9f58cd680634d4f7feef6163723bb8f3afe6117"
},
"triangleCount": 653
}
},
"transform": {
@@ -4435,9 +4442,9 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
"entrance": {
"name": "Raksha Temple Entrance",
"footPosition": [
0.021786,
10.664467,
17.560761
-0.020312,
20.837463,
11.334656
],
"forward": [
-0.173648,
@@ -4451,9 +4458,9 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [
"id": "entrance",
"name": "Raksha Temple Entrance",
"center": [
0.021786,
10.664467,
17.560761
-0.020312,
20.837463,
11.334656
],
"radius": 24
}
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import { usePartyStore, createConfiguredPartyMember } from "./partyStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { HEALER_MANA_RECOVERY_ID, synchronizeHealerManaRecovery } from "./healerManaRecovery";
const START = 100_000;
function prepare(enabled = true) {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", raceId: "rom-human", gender: "male", role: "healer",
statRuleset: "rom", level: 30, settings: { healerManaRecovery: enabled },
});
const state = useCombatStore.getState();
useCombatStore.setState({ resource: 0, resourcePools: { ...state.resourcePools, mana: 0 } });
return state.maxResource;
}
describe("healer out-of-combat mana recovery", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useDungeonSimulationStore.getState().leave();
usePartyStore.setState({ members: [], command: "stop", activeMobId: null, memberTargetIds: {} });
});
it("restores 15% per second to a native RoM player before the native pulse, even after spending mana", () => {
const maximum = prepare();
useCombatStore.setState({ lastResourceSpentAt: { mana: START } });
useCombatStore.getState().tick(0.5, START + 500);
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.075);
expect(useCombatStore.getState().resourcePools.mana).toBeCloseTo(maximum * 0.075);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(true);
});
it("stops in combat even with the party stopped and resumes after the last enemy dies", () => {
const maximum = prepare();
const combat = useCombatStore.getState();
combat.registerMob("recovery-enemy", { name: "Recovery enemy", maxHealth: 100, xpReward: 0 });
combat.damageMob("recovery-enemy", 1, START);
combat.tick(0.5, START + 500);
expect(useCombatStore.getState().resource).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
combat.damageMob("recovery-enemy", 100, START + 500);
combat.tick(0.5, START + 1_000);
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.075);
});
it("honors the saved off switch, removes the buff immediately, and clamps to maximum", () => {
const maximum = prepare(false);
useCombatStore.getState().tick(0.5, START + 500);
expect(useCombatStore.getState().resource).toBe(0);
useCombatStore.getState().updateSettings({ healerManaRecovery: true });
useCombatStore.setState({ resource: maximum - 1, resourcePools: { mana: maximum - 1 } });
useCombatStore.getState().tick(0.25, START + 750);
expect(useCombatStore.getState().resource).toBe(maximum);
useCombatStore.getState().updateSettings({ healerManaRecovery: false });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
});
it("restores a companion's own maximum mana without creating other resource pools", () => {
prepare();
const base = createConfiguredPartyMember({ name: "Recovery mage", classId: "rom-mage",
raceId: "rom-human", gender: "male", specialization: "Primary", role: "damage", statRuleset: "rom",
}, 30, 0, "healer-recovery");
const member = { ...base, resource: 0, resourcePools: { ...base.resourcePools, mana: 0 },
nextActionAt: Number.MAX_SAFE_INTEGER };
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(START);
advanceDungeonPartyCombat(START + 500);
const recovered = usePartyStore.getState().members[0];
expect(recovered.resourcePools.mana).toBeCloseTo(base.maxResource * 0.075);
expect(recovered.resource).toBe(recovered.resourcePools.mana);
expect(Object.keys(recovered.resourcePools)).toEqual(Object.keys(base.resourcePools));
});
it("uses an NPC healer for a damage-role player and stops when the healer dies", () => {
const maximum = prepare();
useCombatStore.setState({ gearRole: "damage" });
const healer = createConfiguredPartyMember({ name: "Recovery healer", classId: "priest",
specialization: "Discipline", specializationId: "priest-discipline", role: "healer",
}, 30, 0, "recovery-npc-provider");
usePartyStore.setState({ members: [healer] });
useCombatStore.getState().tick(0.25, START + 250);
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.0375);
usePartyStore.setState({ members: [{ ...healer, health: 0 }] });
useCombatStore.getState().tick(0.25, START + 500);
expect(useCombatStore.getState().resource).toBeCloseTo(maximum * 0.0375);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID)).toBe(false);
});
it("requires a living healer, excludes dead and non-mana recipients, and never stacks providers", () => {
const actors = [
{ id: "healer-1", role: "healer", health: 100, maxMana: 200 },
{ id: "healer-2", role: "healer", health: 100, maxMana: 400 },
{ id: "dead", role: "damage", health: 0, maxMana: 200 },
{ id: "warrior", role: "tank", health: 100, maxMana: 0 },
];
const auras = synchronizeHealerManaRecovery([], actors, true, false, START);
expect(auras.map((aura) => aura.targetId)).toEqual(["healer-1", "healer-2"]);
expect(synchronizeHealerManaRecovery(auras, actors, true, false, START + 500)).toHaveLength(2);
expect(synchronizeHealerManaRecovery(auras, actors.map((actor) => ({ ...actor, health: 0 })), true, false, START)).toHaveLength(0);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { applyAura, type ActiveAura, type AuraDefinition } from "./combatAuras";
export const HEALER_MANA_RECOVERY_ID = "healer-mana-recovery";
export const HEALER_MANA_RECOVERY_RATE = 0.15;
const DEFINITION: AuraDefinition = {
id: HEALER_MANA_RECOVERY_ID,
name: "Healer Mana Recovery",
description: "Restores 15% of maximum mana per second to living party members while out of combat. Requires a living healer. Toggle in Options.",
disposition: "buff",
icon: "/assets/ui/spells/spell_frost_manarecharge.png",
durationMs: null,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [HEALER_MANA_RECOVERY_ID],
};
export interface RecoveryActor {
readonly id: string;
readonly health: number;
readonly role: string;
readonly maxMana: number;
}
/** One provider and one application per recipient, regardless of healer count. */
export function synchronizeHealerManaRecovery(
auras: readonly ActiveAura[],
actors: readonly RecoveryActor[],
enabled: boolean,
inCombat: boolean,
now: number,
): readonly ActiveAura[] {
const provider = enabled && !inCombat
? actors.find((actor) => actor.health > 0 && actor.role === "healer")
: undefined;
const recipients = new Set(provider
? actors.filter((actor) => actor.health > 0 && actor.maxMana > 0).map((actor) => actor.id)
: []);
let result = auras.filter((aura) => aura.definition.id !== HEALER_MANA_RECOVERY_ID
|| (aura.sourceId === provider?.id && recipients.has(aura.targetId)));
for (const targetId of recipients) {
if (result.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID && aura.targetId === targetId)) continue;
result = [...applyAura(result, { definition: DEFINITION, sourceId: provider!.id, targetId, now }).auras];
}
return result.length === auras.length && result.every((aura, index) => aura === auras[index])
? auras : result;
}
export function healerManaRecoveryAmount(
auras: readonly ActiveAura[], targetId: string, maximum: number, deltaSeconds: number,
): number {
return auras.some((aura) => aura.definition.id === HEALER_MANA_RECOVERY_ID && aura.targetId === targetId)
? Math.max(0, maximum) * HEALER_MANA_RECOVERY_RATE * Math.max(0, deltaSeconds)
: 0;
}
+80
View File
@@ -31,6 +31,56 @@ describe("enemy damage type source metadata", () => {
});
});
it("retains exact WoW hit-source attribute words through attack normalization", () => {
expect(enemyDamageTypeSourceForSpell("wow335", 38_163)).toMatchObject({
hitSource: { attributes0: 0x0008_0000, attributes3: 0x0004_0000, attributes7: 0 },
});
expect(resolveEnemyDamageType({ origin: "wow335", spellId: 38_163 })).toMatchObject({
hitSource: { attributes0: 0x0008_0000, attributes3: 0x0004_0000, attributes7: 0 },
});
const alwaysHit = normalizeEnemyAttack({
id: "wow-38163",
name: "Source Hit-Flag Fixture",
spellId: 38_163,
damageOrigin: "wow335",
delivery: "projectile",
target: "primary",
school: "shadow",
attackKind: "spell",
animation: "cast",
range: 20,
cooldownMs: 4_000,
damageMultiplier: 1,
});
expect(alwaysHit.wowHitSource).toEqual({
attributes0: 0x0008_0000,
attributes3: 0x0004_0000,
attributes7: 0,
});
// Spell 122 has a real source Attributes3 value of zero; preserve it.
const knownZeroSource = enemyDamageTypeSourceForSpell("wow335", 122);
expect(knownZeroSource && "hitSource" in knownZeroSource ? knownZeroSource.hitSource : undefined).toEqual({
attributes0: 0x4001_0000,
attributes3: 0,
attributes7: 0,
});
expect(normalizeEnemyAttack({
id: "wow-122",
name: "Known Zero Attribute Fixture",
spellId: 122,
damageOrigin: "wow335",
delivery: "projectile",
target: "primary",
school: "frost",
animation: "cast",
range: 20,
cooldownMs: 4_000,
damageMultiplier: 1,
}).wowHitSource).toEqual({ attributes0: 0x4001_0000, attributes3: 0, attributes7: 0 });
});
it("keeps multi-school masks intact and uses authored school as the runtime fallback", () => {
expect(wowSchoolsFromSchoolMask(20)).toEqual({
schools: ["fire", "frost"],
@@ -60,6 +110,34 @@ describe("enemy damage type source metadata", () => {
school: "fire",
sourceVerified: false,
});
expect(resolveEnemyDamageType({ origin: "wow335", spellId: 999_999, school: "fire" }))
.not.toHaveProperty("hitSource");
expect(normalizeEnemyAttack({
id: "unknown-hit-source",
name: "Unknown Hit Source",
spellId: 999_999,
damageOrigin: "wow335",
delivery: "projectile",
target: "primary",
school: "fire",
animation: "cast",
range: 20,
cooldownMs: 4_000,
damageMultiplier: 1,
})).not.toHaveProperty("wowHitSource");
expect(normalizeEnemyAttack({
id: "rw-native-hit-source",
name: "RuneWaker Native Source",
spellId: 12_675,
damageOrigin: "runewaker",
delivery: "projectile",
target: "primary",
school: "fire",
animation: "cast",
range: 20,
cooldownMs: 4_000,
damageMultiplier: 1,
})).not.toHaveProperty("wowHitSource");
expect(resolveEnemyDamageType({
origin: "wow335",
damageClass: 3,
@@ -120,6 +198,8 @@ describe("enemy damage type source metadata", () => {
school: "shadow",
sourceVerified: true,
});
expect(resolveEnemyDamageType({ origin: "runewaker", spellId: 12_675 }))
.not.toHaveProperty("hitSource");
const unknownCalculation = enemyDamageTypeComponentForSpell(490_045, 500_056);
expect(unknownCalculation).toMatchObject({
+11
View File
@@ -11,11 +11,19 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export interface WowEnemyHitSource {
readonly attributes0: number;
readonly attributes3: number;
readonly attributes7: number;
}
export interface WowEnemyDamageTypeSource {
readonly spellId: number;
readonly name: string;
readonly damageClass: number;
readonly schoolMask: number;
/** Raw hit-gate attribute words copied from a matched WoW Spell.dbc row. */
readonly hitSource?: WowEnemyHitSource;
readonly schools: readonly EnemyDamageSchool[];
readonly school?: EnemyDamageSchool;
readonly attackKind?: EnemyAttackKind;
@@ -69,6 +77,8 @@ export interface ResolvedEnemyDamageType {
readonly damageClass?: number;
readonly sourceDamageClass?: "none" | "magic" | "melee" | "ranged" | "physical" | "magical";
readonly schoolMask?: number;
/** Copied only from a matched WoW damage row; absent means unknown/not WoW. */
readonly hitSource?: WowEnemyHitSource;
readonly schools: readonly EnemyDamageSchool[];
/** Present only when source evidence identifies exactly one runtime school. */
readonly school?: EnemyDamageSchool;
@@ -188,6 +198,7 @@ export function resolveEnemyDamageType(input: ResolveEnemyDamageTypeInput): Reso
...(damageClass !== undefined ? { damageClass } : {}),
...(sourceDamageClass ? { sourceDamageClass } : {}),
...(schoolMask !== undefined ? { schoolMask } : {}),
...(wowSource?.hitSource ? { hitSource: wowSource.hitSource } : {}),
schools: maskResult.schools.length ? maskResult.schools : runewakerComponent?.school ? [runewakerComponent.school] : [],
...(school ? { school } : {}),
...(maskResult.unknownBits ? { unknownSchoolMaskBits: maskResult.unknownBits } : {}),
+62 -20
View File
@@ -1,3 +1,4 @@
import { healerManaRecoveryAmount } from "./healerManaRecovery";
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
import { classifyEnemyNpcSpellBlockPhase } from "./enemyBlocking";
@@ -8,7 +9,11 @@ import {
enemyDisarmAbilityWeaponChannel,
enemyDisarmBlocksAbilityCast,
enemyDisarmRequirementCandidates,
enemySpellHasMatchingEquippedItem,
} from "./enemyDisarmRuntime";
import { WOW335_HUNTER_DETERRENCE_ABILITY_ID, wow335DeterrenceAbilityContract,
wow335DeterrenceActivePackage } from "./wow335DeterrenceRuntime";
import { wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
import type { EnemyDisarmWeaponChannel } from "./enemyWeaponDebuffs";
import {
abilitiesForClass,
@@ -36,6 +41,7 @@ import {
auraAdjustedCharacterStats,
effectiveActorAuras,
equipmentAdjustedCombatAmount,
refreshHealerManaRecovery,
refreshPriestPainAndSufferingDot,
stableCombatUnit,
useCombatStore,
@@ -189,7 +195,7 @@ import {
} from "./partyAbilityAi";
import { minimumHostileTargetsForAbility } from "./combatRotation";
import { emitRotationDecision } from "./rotationTelemetry";
import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { imminentCooldownMechanic, currentCompanionCooldownPolicy } from "./companionCooldownPolicy";
import { normalizeActorResourcePool, type DeathKnightRuneState } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { enemyControlAbilityCanEscapeAuras } from "./enemyControlImmunityPurge";
@@ -2290,6 +2296,9 @@ function maybeProcPartyWarriorUnbridledWrath(
/** Weapon swings continue during the GCD and supply Rage for paid attacks. */
function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember {
if (useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes("pacified"))) return member;
const hasMaelstromWeaponTalent = member.classId === "shaman"
&& (member.talentRanks["talent-2057"] ?? 0) > 0;
if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter"
@@ -2605,6 +2614,10 @@ export function partyAbilityTargetIsValid(
castCanMove = false,
): boolean {
const combat = useCombatStore.getState();
if (wow335HunterPreventionTypeForAbility(ability) === 2 && combat.auras.some((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes("pacified")
))) return false;
if (ability.wowWeaponRequirements) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id,
@@ -2615,7 +2628,10 @@ export function partyAbilityTargetIsValid(
role: member.role,
level: member.level,
});
if (enemyDisarmBlocksAbilityCast(
const equippedItems = enemyDisarmRequirementCandidates(loadout);
if ((ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
&& !enemySpellHasMatchingEquippedItem(ability.wowWeaponRequirements, equippedItems))
|| enemyDisarmBlocksAbilityCast(
combat.auras,
member.id,
now,
@@ -2625,7 +2641,7 @@ export function partyAbilityTargetIsValid(
passive: ability.passive,
createsAura: ability.effects.some((effect) => effect.kind === "apply-aura"),
},
enemyDisarmRequirementCandidates(loadout),
equippedItems,
)) return false;
}
if (!coaWitchDoctorCanBrewIngredient(ability, combat.auras, member.id, member.talentRanks, now)) return false;
@@ -5192,6 +5208,7 @@ function membersDiffer(left: readonly PartyMember[], right: readonly PartyMember
* tank can redirect that tick's pressure before the death/respawn boundary.
*/
export function advanceDungeonPartyCombat(now = Date.now()): void {
refreshHealerManaRecovery(now);
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
if (!party.members.length) return;
@@ -5321,6 +5338,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0,
}).current;
}
if (living && member.resourcePools.mana !== undefined && nativeManaMaximum > 0) {
resourcePools.mana = Math.min(nativeManaMaximum, (resourcePools.mana ?? 0)
+ healerManaRecoveryAmount(combat.auras, member.id, nativeManaMaximum, deltaSeconds));
}
const angerManagement = member.classId === "warrior"
? wow335WarriorAngerManagementProfile(
member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0,
@@ -5402,22 +5423,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
&& enemyControlPreventsActions(member.controlMechanic)
) {
const controlCombat = useCombatStore.getState();
const berserkerRageBase = member.classId === "warrior"
? abilitiesForCharacter(member.classId, member.secondaryClassId)
.find((ability) => ability.dbcSpellId === 18499)
: undefined;
const berserkerRage = berserkerRageBase
? learnedAbilityForBuild(berserkerRageBase, member.level, member.talentRanks)
: null;
const mechanic = member.controlMechanic;
if (berserkerRage && !member.activeCast && (mechanic === "fear" || mechanic === "stun")
&& enemyControlAbilityCanEscapeAuras(
berserkerRage, mechanic, controlCombat.auras, member.id, now,
)
&& partyAbilityTargetIsValid(member, berserkerRage, member.id, undefined, now)) {
const committed = commitPartyAbilityUseWithRogueCooldown(member, berserkerRage, now);
const escapeAbility = !member.activeCast && (mechanic === "fear" || mechanic === "stun" || mechanic === "confuse")
? abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.find((ability) => ability && enemyControlAbilityCanEscapeAuras(
ability, mechanic, controlCombat.auras, member.id, now,
) && partyAbilityTargetIsValid(member, ability, member.id, undefined, now))
: undefined;
if (escapeAbility) {
const committed = commitPartyAbilityUseWithRogueCooldown(member, escapeAbility, now);
if (committed) {
const execution = executePartyAbility(committed, berserkerRage, member.id, now, nextMembers);
const execution = executePartyAbility(committed, escapeAbility, member.id, now, nextMembers);
const partyStateAfterGrant = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
const actorAfterGrant = useCombatStore.getState().actors[member.id];
const controlledUntil = actorAfterGrant?.controlledUntil
@@ -5429,8 +5446,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
...execution.member,
controlledUntil,
controlMechanic: controlledUntil > now ? controlMechanic : null,
status: targetId ? member.role === "tank" ? "guarding" : "attacking" : "ready",
nextActionAt: now + Math.max(250, berserkerRage.castTimeMs, berserkerRage.gcdMs),
status: controlledUntil > now && enemyControlPreventsActions(controlMechanic)
? "holding" : targetId ? member.role === "tank" ? "guarding" : "attacking" : "ready",
nextActionAt: now + Math.max(250, escapeAbility.castTimeMs, escapeAbility.gcdMs),
};
continue;
}
@@ -5440,7 +5458,31 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
: { ...member, status: "holding" };
continue;
}
if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("pacified"))) {
const hunterDeterrence = member.classId === "hunter"
? abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.find((ability): ability is AbilityDefinition => Boolean(ability
&& ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID && wow335DeterrenceAbilityContract(ability)))
: undefined;
const activeHunterDeterrence = hunterDeterrence && wow335DeterrenceActivePackage({
ability: hunterDeterrence, auras: combat.auras, defenderId: member.id, nowMs: now,
});
if (!member.activeCast && hunterDeterrence && !activeHunterDeterrence
&& Object.values(combat.mobs).some((mob) => !mob.dead && mob.engaged && mob.targetActorId === member.id
&& (member.health / Math.max(1, member.maxHealth)
< currentCompanionCooldownPolicy().generic.defensiveHealthPercent / 100
|| Boolean(imminentCooldownMechanic(mob, member.role, now))))
&& partyAbilityIsReady(member, hunterDeterrence, now)
&& partyAbilityTargetIsValid(member, hunterDeterrence, member.id, undefined, now)) {
const committed = commitPartyAbilityUseWithRogueCooldown(member, hunterDeterrence, now);
if (committed) {
const execution = executePartyAbility(committed, hunterDeterrence, member.id, now, nextMembers);
nextMembers[index] = { ...execution.member, status: "holding", nextActionAt: now + 250 };
continue;
}
}
if (!activeHunterDeterrence && combat.auras.some((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("pacified"))) {
nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" };
continue;
}
@@ -0,0 +1,86 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { ROM_SCOUT_AUTO_SHOT_ABILITY_ID } from "./romScoutAutoShot";
const TARGET = "pacify-weapon-swing-fixture";
const ORIGIN = [0, 0, 0] as const;
const FRONT = [0, 0, -2] as const;
function prepare(pacified: boolean, targetId = PLAYER_AGGRO_ID) {
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 });
usePartyStore.setState({ members: [] });
useCombatStore.getState().setPlayerPosition(ORIGIN);
useCombatStore.getState().registerMob(TARGET, { name: "Synthetic autoattack target",
level: 1, maxHealth: 1_000_000, xpReward: 0 });
registerMobRuntime(TARGET, FRONT, 0);
registerCombatSpatialProvider({ actor: (id) => id === TARGET
? { id, position: FRONT, yaw: 0 } : { id, position: ORIGIN, yaw: Math.PI },
hasLineOfSight: () => true, projectGroundPoint: (position) => position });
useCombatStore.setState((state) => ({ autoAttackTargetId: TARGET,
nextAutoAttackAt: 1_000, nextOffhandAutoAttackAt: 1_000,
auras: pacified ? [...applyAura(state.auras, { definition: {
id: "synthetic-source-pacify", name: "Accepted pacify aura fixture",
disposition: "buff", durationMs: 2_000, utilityTags: ["pacified"],
}, sourceId: targetId, targetId, now: 1_000 }).auras] : state.auras,
}));
}
function playerSwings() {
return useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === TARGET && event.attackHand === "mainhand");
}
describe("accepted pacify aura suppresses player weapon autos", () => {
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rom-scout", "rom-rogue"]); });
afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); });
it("holds due swings through the whole aura window and resumes once at expiry", () => {
prepare(true);
for (const now of [1_000, 1_500, 2_999]) useCombatStore.getState().tick(0.05, now);
expect(playerSwings()).toHaveLength(0);
expect(useCombatStore.getState().mobs[TARGET]!.health).toBe(1_000_000);
expect(useCombatStore.getState().autoAttackTargetId).toBe(TARGET);
useCombatStore.getState().tick(0.05, 3_000);
expect(playerSwings()).toHaveLength(1);
expect(useCombatStore.getState().nextAutoAttackAt).toBeGreaterThan(3_000);
useCombatStore.getState().tick(0.05, 3_001);
expect(playerSwings()).toHaveLength(1);
});
it("does not borrow another actor's pacify aura", () => {
prepare(true, "another-actor");
useCombatStore.getState().tick(0.05, 1_000);
expect(playerSwings()).toHaveLength(1);
});
it("also holds the owned Scout ranged-auto scheduler until pacify expires", () => {
prepare(false);
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue",
raceId: "rom-human", statRuleset: "rom", level: 30, inventory: [], equipment: {} });
useCombatStore.getState().registerMob(TARGET, { name: "Synthetic ranged auto target",
level: 1, maxHealth: 1_000_000, xpReward: 0 });
useCombatStore.getState().setPlayerPosition(ORIGIN);
// Synthetic already-selected ranged mode; the real native scheduler and
// automatic Training Gear profile resolve subsequent weapon results.
useCombatStore.setState((state) => ({ autoShotTargetId: TARGET, nextAutoShotAt: 1_000,
auras: [...applyAura(state.auras, { definition: {
id: "synthetic-ranged-pacify", name: "Accepted ranged pacify fixture",
disposition: "buff", durationMs: 2_000, utilityTags: ["pacified"],
}, sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000 }).auras],
}));
const rangedEvents = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === PLAYER_AGGRO_ID
&& event.abilityId === ROM_SCOUT_AUTO_SHOT_ABILITY_ID && event.attackHand === "ranged");
for (const now of [1_000, 1_500, 2_999]) useCombatStore.getState().tick(0.05, now);
expect(rangedEvents()).toHaveLength(0);
useCombatStore.getState().tick(0.05, 3_000);
expect(rangedEvents()).toHaveLength(1);
useCombatStore.getState().tick(0.05, 3_001);
expect(rangedEvents()).toHaveLength(1);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { GLTFLoader } from "three-stdlib";
import { expect, it } from "vitest";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import {
findNearestPartyNavigationSurface,
findPartyNavigationPath,
type PartyNavigationDetourTopology,
} from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
it("loads Raksha's source deck without inventing a connection to Shadoj", async () => {
const definition = DUNGEON_REGISTRY["raksha-temple"];
const asset = definition.assets.navigation!;
const file = `src/assets/game/dungeons/raksha-temple/${asset.fileName}`;
const bytes = await readFile(file);
const checksum = createHash("sha256").update(bytes).digest("hex");
expect(asset.checksum).toBe(checksum);
expect(asset.detourTopology?.assetChecksum).toBe(checksum);
const gltf = await new GLTFLoader().parseAsync(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "",
);
const graph = preparePartyNavigationGraph(
file, gltf.scene, [], [], undefined, false,
gltf.userData.runewakerNavigationTopology as PartyNavigationDetourTopology,
asset.detourTopology, undefined, checksum,
);
const authoredEntry = [-0.020312, 21.013313, 11.334656] as const;
const surface = findNearestPartyNavigationSurface(graph, authoredEntry, 1);
expect(surface?.distance).toBeLessThan(0.25);
expect(surface?.position[1]).toBeGreaterThan(20.7);
expect(surface?.position[1]).toBeLessThan(21);
expect(definition.entrance.footPosition[1]).toBeGreaterThan(20.7);
expect(findNearestPartyNavigationSurface(graph, definition.entrance.footPosition, 1)?.distance)
.toBeLessThan(0.01);
expect(findPartyNavigationPath(graph, authoredEntry, [-0.367608, 20.837463, 13.304272], {
maxStartSnapDistance: 1, maxEndSnapDistance: 1,
})).not.toBeNull();
expect(findPartyNavigationPath(graph, authoredEntry, [-115.048229, 9.971153, 284.713953], {
maxStartSnapDistance: 1, maxEndSnapDistance: 6,
})).toBeNull();
}, 120_000);
@@ -60,6 +60,7 @@ function advanceFromStartToPulse(startAt: number): void {
function configuredNativeFive(): readonly PartyMember[] {
useCombatStore.getState().initializeCharacter({
settings: { healerManaRecovery: false },
classId: "rom-priest",
raceId: "rom-human",
gender: "male",
@@ -192,6 +193,7 @@ describe("native RoM recovery cadence integration", () => {
it("regenerates secondary Mana for a Rogue/Priest NPC while its primary Energy pool advances normally", () => {
useCombatStore.getState().initializeCharacter({
settings: { healerManaRecovery: false },
classId: "rom-priest",
raceId: "rom-human",
gender: "male",
@@ -241,6 +243,7 @@ describe("native RoM recovery cadence integration", () => {
it("keeps shared WoW regeneration for WoW-mode RoM players and NPCs, clearing stale native clocks", () => {
useCombatStore.getState().initializeCharacter({
settings: { healerManaRecovery: false },
classId: "rom-mage",
raceId: "rom-human",
gender: "male",
+146 -1
View File
@@ -1,10 +1,23 @@
import { describe, expect, it } from "vitest";
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import {
abilityAtLevel,
abilityById,
isAbilityUnlocked,
preloadAbilityCatalogsForClasses,
type AbilityDefinition,
type AbilityEffect,
} from "./abilityCatalog";
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
import {
WOW335_ABILITIES_BY_CLASS,
WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
wow335HunterPreventionTypeForAbility,
} from "./wow335AbilityCatalog";
import {
WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
} from "./wow335DeterrenceRuntime";
import generatedData from "./wow335AbilityData.generated.json";
import { talentNodeById } from "./talentCatalog";
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
@@ -27,6 +40,138 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
}
describe("WoW 3.3.5 executable effect mapping", () => {
it("selects Hunter Deterrence 19263 and exposes its complete linked aura package", () => {
expect(generatedData.classes.hunter.some((chain) => chain.name === "Deterrence"
|| chain.ranks.some((rank) => [19_263, 65_871, 67_801].includes(rank.spellId)))).toBe(false);
const deterrence = byId("wow335-hunter-deterrence");
const catalogDeterrence = abilityById("wow335-hunter-deterrence");
expect(catalogDeterrence).toMatchObject({
id: "wow335-hunter-deterrence",
classId: "hunter",
dbcSpellId: 19_263,
source: "wow335-progression",
});
expect(isAbilityUnlocked(catalogDeterrence!, 60)).toBe(true);
expect(isAbilityUnlocked(catalogDeterrence!, 59)).toBe(false);
expect(WOW335_ABILITIES_BY_CLASS.hunter.filter((ability) => ability.name === "Deterrence")).toHaveLength(1);
expect(deterrence).toMatchObject({
id: "wow335-hunter-deterrence",
classId: "hunter",
source: "wow335-progression",
dbcSpellId: 19_263,
name: "Deterrence",
unlockLevel: 60,
sourceUnlockLevel: 60,
target: "self",
range: { min: 0, max: 0 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 90_000,
gcdMs: 0,
cost: { resource: "mana", amount: 0 },
wowSpellDefense: {
damageClass: 0,
schoolMask: 1,
attributes0: 327_696,
attributes1: 16,
attributes3: 0,
attributes5: 0,
directDamageFromEffects: false,
sourceProfile: "wow335",
},
wowWeaponRequirements: {
equippedItemClass: 2,
equippedItemSubclassMask: 173_555,
equippedItemInventoryMask: 0,
},
});
expect(abilityAtLevel(deterrence, 80)).toMatchObject({
dbcSpellId: 19_263,
description: "When activated, increases parry chance by 100% and grants a 100% chance to deflect spells. While Deterrence is active, you cannot attack. Lasts 5 sec.",
cooldownMs: 90_000,
gcdMs: 0,
cost: { resource: "mana", amount: 0 },
wowWeaponRequirements: {
equippedItemClass: 2,
equippedItemSubclassMask: 173_555,
equippedItemInventoryMask: 0,
},
});
expect(effectsOfKind(abilityAtLevel(deterrence, 80), "apply-aura")
.map((effect) => effect.aura.durationMs)).toEqual([5_000, 5_000, 5_000]);
expect(deterrence.effects).toEqual(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS);
expect(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[1]?.kind === "apply-aura"
? WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[1].aura.utilityTags
: undefined).toBeUndefined();
expect(effectsOfKind(deterrence, "apply-aura").map((effect) => [
effect.sourceEffectType,
effect.sourceAuraType,
effect.sourceBasePoints,
effect.aura.durationMs,
])).toEqual([
[6, 47, 99, 5_000],
[6, 287, 99, 5_000],
[6, 25, -1, 5_000],
]);
expect(WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.map((effect) => {
expect(effect.kind).toBe("apply-aura");
if (effect.kind !== "apply-aura") return [];
return [effect.aura.id, effect.sourceEffectType, effect.sourceAuraType, effect.sourceBasePoints, effect.aura.durationMs];
})).toEqual([
["wow335:67801:effect:0:aura:288", 6, 288, 99, 5_000],
["wow335:67801:effect:1:aura:185", 6, 185, -101, 5_000],
]);
const linkedRangedHit = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS[1];
expect(linkedRangedHit?.kind === "apply-aura" ? linkedRangedHit.aura.modifiers : undefined).toEqual([
{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 },
]);
const linkedIgnoreFacing = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS[0];
expect(linkedIgnoreFacing?.kind === "apply-aura" ? linkedIgnoreFacing.aura.utilityTags : undefined).toBeUndefined();
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[67_801]).toBeUndefined();
expect(WOW335_ABILITIES_BY_CLASS.hunter.some((ability) => ability.dbcSpellId === 67_801)).toBe(false);
expect(WOW335_ABILITIES_BY_CLASS.hunter.some((ability) => ability.dbcSpellId === 65_871)).toBe(false);
});
it("retains pinned PreventionType for every selected Hunter rank and merged public aliases", async () => {
const selectedSpellIds = new Set<number>();
for (const ability of WOW335_ABILITIES_BY_CLASS.hunter) {
for (const rank of ability.ranks ?? []) {
selectedSpellIds.add(rank.spellId);
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[rank.spellId]).toBeDefined();
expect(wow335HunterPreventionTypeForAbility({ ...ability, dbcSpellId: rank.spellId }))
.toBe(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[rank.spellId]);
}
}
expect(selectedSpellIds.size).toBe(190);
expect([...selectedSpellIds].sort((left, right) => left - right)).toEqual(
Object.keys(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID).map(Number).sort((left, right) => left - right),
);
await preloadAbilityCatalogsForClasses(["hunter"]);
const publicArcaneShot = abilityById("hunter-arcane-shot");
expect(publicArcaneShot).toMatchObject({ id: "hunter-arcane-shot", classId: "hunter", source: "wow335-progression" });
expect(wow335HunterPreventionTypeForAbility(abilityAtLevel(publicArcaneShot!, 80))).toBe(2);
const publicSteadyShot = abilityById("hunter-steady-shot");
expect(wow335HunterPreventionTypeForAbility(abilityAtLevel(publicSteadyShot!, 80))).toBe(2);
const arcaneShot = byId("wow335-hunter-arcane-shot");
const selectedArcaneRank = { ...arcaneShot, dbcSpellId: 3_044 };
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, classId: "mage" })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, source: "core" })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, source: "ascension-progression" })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, name: "Deterrence" })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, id: "foreign-hunter-arcane-shot" })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 999_999 })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 65_871 })).toBeUndefined();
expect(wow335HunterPreventionTypeForAbility({ ...selectedArcaneRank, dbcSpellId: 67_801 })).toBeUndefined();
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[19_263]).toBe(0);
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[3044]).toBe(2);
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[13_813]).toBe(1);
expect(WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[136]).toBe(1);
});
it("resolves tooltip tokens that explicitly reference another DBC spell", () => {
const chains = Object.values(generatedData.classes).flat();
const dancingRuneWeapon = chains.find((chain) => chain.name === "Dancing Rune Weapon");
+211 -3
View File
@@ -40,6 +40,8 @@ import {
} from "./wow335WarlockMetamorphosisRuntime";
import { WOW335_WARLOCK_SOUL_LINK_TAG } from "./wow335WarlockSoulLinkRuntime";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
@@ -50,6 +52,7 @@ interface Wow335Effect {
readonly basePoints: number;
readonly dieSides: number;
readonly pointsPerLevel: number;
readonly mechanic?: number;
readonly implicitTargetA: number;
readonly implicitTargetB: number;
readonly radius: number;
@@ -100,6 +103,126 @@ const data = generatedData as {
readonly classes: Readonly<Record<BaseClassId, readonly Wow335Chain[]>>;
};
const WOW335_HUNTER_DETERRENCE_CHAIN: Wow335Chain = Object.freeze({
id: "wow335-hunter-deterrence",
name: "Deterrence",
iconBasename: "ability_whirlwind",
iconPath: "/assets/ui/spells/ability_whirlwind.png",
sourceUnlockLevel: 60,
ranks: Object.freeze([Object.freeze({
spellId: 19263,
rank: 0,
level: 60,
sourceLevel: 60,
maxScalingLevel: 0,
description: "When activated, increases parry chance by 100% and grants a 100% chance to deflect spells. While Deterrence is active, you cannot attack. Lasts 5 sec.",
castTimeMs: 0,
cooldownMs: 90_000,
gcdMs: 0,
durationMs: 5_000,
channelDurationMs: 0,
powerType: 0,
powerCost: 0,
powerCostPercentage: 0,
rangeIndex: 1,
rangeMin: 0,
rangeMax: 0,
equippedItemClass: 2,
equippedItemSubclassMask: 173_555,
equippedItemInventoryMask: 0,
wowSpellDefense: Object.freeze({
damageClass: 0,
schoolMask: 1,
attributes0: 327_696,
attributes1: 16,
attributes3: 0,
attributes5: 0,
directDamageFromEffects: false,
sourceProfile: "wow335" as const,
}),
effects: Object.freeze(WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent.effects.map((effect) => ({
...effect,
coefficient: [1, 1, 0][effect.index] ?? 0,
}))),
})]),
});
const WOW335_HUNTER_DETERRENCE_DESCRIPTION = WOW335_HUNTER_DETERRENCE_CHAIN.ranks[0]!.description;
function isExactHunterDeterrenceChain(chain: Wow335Chain): boolean {
const [rank] = chain.ranks;
const source = WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent;
if (chain.id !== "wow335-hunter-deterrence"
|| chain.name !== "Deterrence"
|| chain.iconBasename !== "ability_whirlwind"
|| chain.iconPath !== "/assets/ui/spells/ability_whirlwind.png"
|| chain.sourceUnlockLevel !== 60
|| chain.ranks.length !== 1
|| !rank
|| rank.spellId !== source.spellId
|| rank.rank !== 0
|| rank.level !== 60
|| rank.sourceLevel !== 60
|| rank.maxScalingLevel !== 0
|| rank.description !== WOW335_HUNTER_DETERRENCE_DESCRIPTION
|| rank.castTimeMs !== 0
|| rank.cooldownMs !== 90_000
|| rank.gcdMs !== 0
|| rank.durationMs !== 5_000
|| rank.channelDurationMs !== 0
|| rank.powerType !== 0
|| rank.powerCost !== 0
|| rank.powerCostPercentage !== 0
|| rank.rangeIndex !== 1
|| rank.rangeMin !== 0
|| rank.rangeMax !== 0
|| rank.equippedItemClass !== 2
|| rank.equippedItemSubclassMask !== 173_555
|| rank.equippedItemInventoryMask !== 0) return false;
const defense = rank.wowSpellDefense;
if (!defense
|| defense.damageClass !== 0
|| defense.schoolMask !== 1
|| defense.attributes0 !== 327_696
|| defense.attributes1 !== 16
|| defense.attributes3 !== 0
|| defense.attributes5 !== 0
|| defense.directDamageFromEffects !== false
|| defense.sourceProfile !== "wow335") return false;
return rank.effects.length === source.effects.length
&& rank.effects.every((effect, index) => {
const expected = source.effects[index];
return expected !== undefined
&& effect.index === expected.index
&& effect.effectType === expected.effectType
&& effect.auraType === expected.auraType
&& effect.basePoints === expected.basePoints
&& effect.dieSides === expected.dieSides
&& effect.pointsPerLevel === expected.pointsPerLevel
&& (effect.mechanic ?? 0) === expected.mechanic
&& effect.implicitTargetA === expected.implicitTargetA
&& effect.implicitTargetB === expected.implicitTargetB
&& effect.radius === expected.radius
&& effect.periodMs === expected.periodMs
&& effect.miscValue === expected.miscValue
&& effect.triggerSpellId === expected.triggerSpellId
&& effect.coefficient === [1, 1, 0][index];
});
}
function hunterChainsWithSelectedDeterrence(): readonly Wow335Chain[] {
const chains = data.classes.hunter;
const candidates = chains.filter((chain) => chain.name === "Deterrence"
|| chain.ranks.some((rank) => [19_263, 65_871, 67_801].includes(rank.spellId)));
if (candidates.length === 0) return [...chains, WOW335_HUNTER_DETERRENCE_CHAIN];
if (candidates.length === 1 && isExactHunterDeterrenceChain(candidates[0]!)) return chains;
// A conflicting same-name/linked-ID import is source drift. Drop the whole
// candidate group and fail closed rather than substitute 65871 or 67801.
return chains.filter((chain) => !candidates.includes(chain));
}
const RESOURCE_BY_CLASS: Readonly<Record<BaseClassId, ResourceType>> = {
warrior: "rage",
paladin: "mana",
@@ -2386,7 +2509,15 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
?? auraMasteryTalentTooltip;
return talentTooltip ? { ...rank, description: talentTooltip } : rank;
});
const first = ranks[0];
const selectedDeterrence = classId === "hunter"
&& chain.id === "wow335-hunter-deterrence"
&& chain.name === "Deterrence"
&& castRanks.length === 1
&& castRanks[0]?.spellId === 19_263;
const mappedRanks = selectedDeterrence
? ranks.map((rank) => ({ ...rank, effects: WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS }))
: ranks;
const first = mappedRanks[0];
const firstRaw = castRanks[0];
if (!first || !firstRaw) return null;
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
@@ -2458,7 +2589,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
? chain.talentNodeId
: undefined,
ranks,
ranks: mappedRanks,
};
}
@@ -2499,7 +2630,8 @@ function classicCooldownGroup(classId: BaseClassId, abilityName: string): string
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => {
const generated = data.classes[classId]
const sourceChains = classId === "hunter" ? hunterChainsWithSelectedDeterrence() : data.classes[classId];
const generated = sourceChains
.map((chain) => abilityDefinition(classId, chain))
.filter((ability): ability is AbilityDefinition => Boolean(ability));
const specialized = classId === "warlock"
@@ -2514,6 +2646,82 @@ export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly Ab
))];
}))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
export type Wow335HunterPreventionType = 0 | 1 | 2;
// Spell.dbc field 214 in the pinned 234-field layout (field 220 in the
// selected 239-field archive) is kept locally until the generator has a
// source-profile-aware way to carry this cast gate. Every value below was
// read from the pinned Spell.dbc row; missing IDs intentionally remain absent.
const WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE: Readonly<Record<Wow335HunterPreventionType, readonly number[]>> = Object.freeze({
0: Object.freeze([
23_989, 1_494, 1_130, 14_323, 14_324, 14_325, 53_338, 883, 2_641, 6_991,
1_515, 19_883, 19_884, 781, 1_462, 19_885, 3_045, 19_880, 5_384,
19_878, 19_574, 19_882, 24_131, 24_134, 24_135, 27_069, 49_009, 49_010,
34_471, 19_879, 62_757, 19_263,
]),
1: Object.freeze([
13_163, 13_165, 14_318, 14_319, 14_320, 14_321, 14_322, 25_296, 27_044,
982, 136, 3_111, 3_661, 3_662, 13_542, 13_543, 13_544, 27_046, 48_989,
48_990, 6_197, 1_002, 1_513, 14_326, 14_327, 5_118, 13_795, 13_797, 14_298,
14_302, 14_299, 14_303, 14_300, 14_304, 14_301, 14_305, 27_023, 27_024,
49_053, 49_055, 49_054, 49_056, 34_074, 1_499, 14_310, 14_311, 13_809,
13_161, 19_577, 1_543, 13_813, 14_316, 14_317, 27_025, 49_066, 49_067,
13_159, 19_506, 20_043, 20_190, 27_045,
49_071, 34_026, 34_600, 61_846, 61_847, 60_192,
]),
2: Object.freeze([
75, 2_973, 14_260, 14_261, 14_262, 14_263, 14_264, 14_265, 14_266, 27_014,
48_995, 48_996, 1_978, 13_549, 13_550, 13_551, 13_552, 13_553, 13_554,
13_555, 25_295, 27_016, 49_000, 49_001, 3_044, 14_281, 14_282, 14_283,
14_284, 14_285, 14_286, 14_287, 27_019, 49_044, 49_045, 5_116, 20_736,
2_974, 19_503, 1_495, 14_269, 14_270, 14_271, 36_916, 53_339, 2_643,
14_288, 14_289, 14_290, 25_294, 27_021, 49_047, 49_048, 19_434, 27_065,
49_049, 49_050, 3_043, 19_306, 20_909, 20_910, 27_067, 48_998, 48_999,
34_490, 3_034, 1_510, 14_294, 14_295, 27_022, 19_386, 24_132, 24_133,
27_068, 49_011, 49_012, 3_674, 63_668, 63_669, 63_670, 63_671, 63_672,
56_641, 49_051, 49_052, 53_209, 53_301, 19_801, 34_477, 61_005, 61_006,
]),
});
const hunterPreventionTypesBySpellId: Record<number, Wow335HunterPreventionType> = {};
for (const type of [0, 1, 2] as const) {
for (const spellId of WOW335_HUNTER_PREVENTION_SPELL_IDS_BY_TYPE[type]) {
hunterPreventionTypesBySpellId[spellId] = type;
}
}
export const WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID: Readonly<Record<number, Wow335HunterPreventionType>> =
Object.freeze(hunterPreventionTypesBySpellId);
const HUNTER_SOURCE_RANK_IDENTITIES = new Map<number, { readonly id: string; readonly name: string }>();
const DUPLICATE_HUNTER_SOURCE_RANK_IDS = new Set<number>();
for (const ability of WOW335_ABILITIES_BY_CLASS.hunter) {
for (const rank of ability.ranks ?? []) {
if (HUNTER_SOURCE_RANK_IDENTITIES.has(rank.spellId)) {
HUNTER_SOURCE_RANK_IDENTITIES.delete(rank.spellId);
DUPLICATE_HUNTER_SOURCE_RANK_IDS.add(rank.spellId);
continue;
}
if (!DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(rank.spellId)) {
HUNTER_SOURCE_RANK_IDENTITIES.set(rank.spellId, { id: ability.id, name: ability.name });
}
}
}
/** Returns the pinned source PreventionType only for a selected Hunter row. */
export function wow335HunterPreventionTypeForAbility(
ability: Pick<AbilityDefinition, "id" | "classId" | "source" | "name" | "dbcSpellId">,
): Wow335HunterPreventionType | undefined {
if (ability.classId !== "hunter" || ability.source !== "wow335-progression") return undefined;
const preventionType = WOW335_HUNTER_PREVENTION_TYPES_BY_SPELL_ID[ability.dbcSpellId];
const identity = HUNTER_SOURCE_RANK_IDENTITIES.get(ability.dbcSpellId);
if (preventionType === undefined || !identity || DUPLICATE_HUNTER_SOURCE_RANK_IDS.has(ability.dbcSpellId)) {
return undefined;
}
const publicAlias = identity.id.startsWith("wow335-") ? identity.id.slice("wow335-".length) : undefined;
if (ability.name !== identity.name || (ability.id !== identity.id && ability.id !== publicAlias)) return undefined;
return preventionType;
}
// These ground channels store their actual damage in same-name triggered
// child spells. Resolve the child once so each channel pulse can use its
// rank-specific DBC amount rather than creating a delayed area effect.
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
WOW335_DETERRENCE_AURA_LINK,
WOW335_DETERRENCE_SOURCE_SIGNATURES,
wow335DeterrenceSourceContract,
type Wow335DeterrenceEffectSignature,
type Wow335DeterrenceSpellLink,
type Wow335DeterrenceSpellSignature,
} from "./wow335Deterrence";
function cloneSignature(value: Wow335DeterrenceSpellSignature): Wow335DeterrenceSpellSignature {
return {
...value,
attributes: [...value.attributes] as Wow335DeterrenceSpellSignature["attributes"],
effects: value.effects.map((effect): Wow335DeterrenceEffectSignature => ({ ...effect })),
};
}
function packageInput(): {
sourceProfile: string;
parent: Wow335DeterrenceSpellSignature;
linkedAura: Wow335DeterrenceSpellSignature;
linkedRows: Wow335DeterrenceSpellLink[];
} {
return {
sourceProfile: "wow335",
parent: cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent),
linkedAura: cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.linkedAura),
linkedRows: [{ ...WOW335_DETERRENCE_AURA_LINK }],
};
}
describe("WoW335 Hunter Deterrence source contract", () => {
it("accepts the exact parent, linked aura, and aura-link signature", () => {
const contract = wow335DeterrenceSourceContract(packageInput());
expect(contract?.sourceProfile).toBe("wow335");
expect(contract?.parent.effects.map(({ auraType, basePoints }) => [auraType, basePoints]))
.toEqual([[47, 100], [287, 100], [25, 0]]);
expect(contract?.linkedAura.effects.map(({ auraType, basePoints }) => [auraType, basePoints]))
.toEqual([[288, 100], [185, -100]]);
expect(contract?.auraLink).toEqual({ spellTrigger: 19263, spellEffect: 67801, type: 2 });
});
it("rejects other profiles and same-name unlinked spell 65871", () => {
const otherProfile = packageInput();
otherProfile.sourceProfile = "ascension";
expect(wow335DeterrenceSourceContract(otherProfile)).toBeUndefined();
const duplicate = packageInput();
duplicate.parent = cloneSignature(WOW335_DETERRENCE_SOURCE_SIGNATURES.unlinkedSameNameDuplicate);
expect(wow335DeterrenceSourceContract(duplicate)).toBeUndefined();
});
it("rejects parent attribute or aura sibling drift", () => {
const attributeDrift = packageInput();
attributeDrift.parent = {
...attributeDrift.parent,
attributes: [327696, 0, 0, 0, 0, 0, 0, 0],
};
expect(wow335DeterrenceSourceContract(attributeDrift)).toBeUndefined();
const auraDrift = packageInput();
auraDrift.parent = {
...auraDrift.parent,
effects: auraDrift.parent.effects.map((effect, index) => (
index === 1 ? { ...effect, auraType: 288 } : effect
)),
};
expect(wow335DeterrenceSourceContract(auraDrift)).toBeUndefined();
});
it("rejects changed linked aura effects, duration, or aura relationship", () => {
const childDrift = packageInput();
childDrift.linkedAura = {
...childDrift.linkedAura,
effects: childDrift.linkedAura.effects.map((effect, index) => (
index === 1 ? { ...effect, basePoints: 0 } : effect
)),
};
expect(wow335DeterrenceSourceContract(childDrift)).toBeUndefined();
const durationDrift = packageInput();
durationDrift.linkedAura = { ...durationDrift.linkedAura, durationMs: 6_000 };
expect(wow335DeterrenceSourceContract(durationDrift)).toBeUndefined();
const linkDrift = packageInput();
linkDrift.linkedRows = [{ ...WOW335_DETERRENCE_AURA_LINK, type: 1 }];
expect(wow335DeterrenceSourceContract(linkDrift)).toBeUndefined();
});
it("fails closed when the pinned parent link has additional or missing rows", () => {
const noLink = packageInput();
noLink.linkedRows = [];
expect(wow335DeterrenceSourceContract(noLink)).toBeUndefined();
const extraLink = packageInput();
extraLink.linkedRows.push({ spellTrigger: 19263, spellEffect: 65871, type: 2 });
expect(wow335DeterrenceSourceContract(extraLink)).toBeUndefined();
});
});
+185
View File
@@ -0,0 +1,185 @@
export type Wow335DeterrenceEffectSignature = Readonly<{
index: number;
effectType: number;
auraType: number;
basePoints: number;
dieSides: number;
pointsPerLevel: number;
mechanic: number;
implicitTargetA: number;
implicitTargetB: number;
radius: number;
periodMs: number;
miscValue: number;
triggerSpellId: number;
}>;
export type Wow335DeterrenceSpellSignature = Readonly<{
spellId: number;
name: string;
rangeIndex: number;
durationIndex: number;
durationMs: number;
schoolMask: number;
damageClass: number;
mechanic: number;
dispel: number;
attributes: readonly [number, number, number, number, number, number, number, number];
effects: readonly Wow335DeterrenceEffectSignature[];
}>;
export type Wow335DeterrenceSpellLink = Readonly<{
spellTrigger: number;
spellEffect: number;
type: number;
}>;
export interface Wow335DeterrenceSourcePackage {
readonly sourceProfile: "wow335";
/** Hunter's selected Spell.dbc row. Its effects are self-targeted auras. */
readonly parent: Wow335DeterrenceSpellSignature;
/** The spell_linked_spell aura row applied with the parent. */
readonly linkedAura: Wow335DeterrenceSpellSignature;
/** The exact positive aura link from the pinned world SQL row. */
readonly auraLink: Wow335DeterrenceSpellLink;
}
const effect = (
index: number,
auraType: number,
basePoints: number,
): Wow335DeterrenceEffectSignature => Object.freeze({
index,
effectType: 6,
auraType,
basePoints,
dieSides: 1,
pointsPerLevel: 0,
mechanic: 0,
implicitTargetA: 1,
implicitTargetB: 0,
radius: 0,
periodMs: 0,
miscValue: 0,
triggerSpellId: 0,
});
function sourceRow(
spellId: number,
attributes: Wow335DeterrenceSpellSignature["attributes"],
effects: readonly Wow335DeterrenceEffectSignature[],
): Wow335DeterrenceSpellSignature {
return Object.freeze({
spellId,
name: "Deterrence",
rangeIndex: 1,
durationIndex: 28,
durationMs: 5_000,
schoolMask: 1,
damageClass: 0,
mechanic: 0,
dispel: 0,
attributes: Object.freeze([...attributes]) as Wow335DeterrenceSpellSignature["attributes"],
effects: Object.freeze([...effects]),
});
}
const selectedParent = sourceRow(19263, [327696, 16, 0, 0, 0, 0, 0, 0], [
effect(0, 47, 100),
effect(1, 287, 100),
effect(2, 25, 0),
]);
const linkedAura = sourceRow(67801, [328080, 16, 0, 0, 0, 0, 0, 0], [
effect(0, 288, 100),
effect(1, 185, -100),
]);
/** Same-name Spell.dbc duplicate, reviewed to prevent accidental substitution. */
const unlinkedDuplicate = sourceRow(65871, [327696, 0, 0, 0, 0, 0, 0, 0], [
effect(0, 47, 100),
effect(1, 287, 100),
effect(2, 25, 0),
]);
export const WOW335_DETERRENCE_SOURCE_SIGNATURES = Object.freeze({
selectedParent,
linkedAura,
unlinkedSameNameDuplicate: unlinkedDuplicate,
});
export const WOW335_DETERRENCE_AURA_LINK: Wow335DeterrenceSpellLink = Object.freeze({
spellTrigger: 19263,
spellEffect: 67801,
type: 2,
});
function matchesEffect(
actual: Wow335DeterrenceEffectSignature,
expected: Wow335DeterrenceEffectSignature,
): boolean {
return actual.index === expected.index
&& actual.effectType === expected.effectType
&& actual.auraType === expected.auraType
&& actual.basePoints === expected.basePoints
&& actual.dieSides === expected.dieSides
&& actual.pointsPerLevel === expected.pointsPerLevel
&& actual.mechanic === expected.mechanic
&& actual.implicitTargetA === expected.implicitTargetA
&& actual.implicitTargetB === expected.implicitTargetB
&& actual.radius === expected.radius
&& actual.periodMs === expected.periodMs
&& actual.miscValue === expected.miscValue
&& actual.triggerSpellId === expected.triggerSpellId;
}
function matchesSpell(
actual: Wow335DeterrenceSpellSignature,
expected: Wow335DeterrenceSpellSignature,
): boolean {
return actual.spellId === expected.spellId
&& actual.name === expected.name
&& actual.rangeIndex === expected.rangeIndex
&& actual.durationIndex === expected.durationIndex
&& actual.durationMs === expected.durationMs
&& actual.schoolMask === expected.schoolMask
&& actual.damageClass === expected.damageClass
&& actual.mechanic === expected.mechanic
&& actual.dispel === expected.dispel
&& actual.attributes.length === expected.attributes.length
&& actual.attributes.every((value, index) => value === expected.attributes[index])
&& actual.effects.length === expected.effects.length
&& actual.effects.every((value, index) => {
const expectedEffect = expected.effects[index];
return expectedEffect !== undefined && matchesEffect(value, expectedEffect);
});
}
/**
* Verifies the selected WoW335 Deterrence row and its linked aura package.
* This source contract does not resolve incoming hit outcomes or apply auras.
*/
export function wow335DeterrenceSourceContract(input: {
readonly sourceProfile: string;
readonly parent: Wow335DeterrenceSpellSignature;
readonly linkedAura: Wow335DeterrenceSpellSignature;
/** All spell_linked_spell rows whose trigger is 19263. */
readonly linkedRows: readonly Wow335DeterrenceSpellLink[];
}): Wow335DeterrenceSourcePackage | undefined {
if (input.sourceProfile !== "wow335"
|| !matchesSpell(input.parent, selectedParent)
|| !matchesSpell(input.linkedAura, linkedAura)
|| input.linkedRows.length !== 1) return undefined;
const [row] = input.linkedRows;
if (!row || row.spellTrigger !== WOW335_DETERRENCE_AURA_LINK.spellTrigger
|| row.spellEffect !== WOW335_DETERRENCE_AURA_LINK.spellEffect
|| row.type !== WOW335_DETERRENCE_AURA_LINK.type) return undefined;
return Object.freeze({
sourceProfile: "wow335",
parent: selectedParent,
linkedAura,
auraLink: WOW335_DETERRENCE_AURA_LINK,
});
}
@@ -0,0 +1,327 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
} from "./abilityCatalog";
import { applyAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import {
DEFAULT_COMPANION_COOLDOWN_POLICY,
installCompanionCooldownPolicy,
} from "./companionCooldownPolicy";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import {
createConfiguredPartyMember,
usePartyStore,
} from "./partyStore";
import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs";
import { PLAYER_AGGRO_ID } from "./aggro";
import { WOW335_DETERRENCE_PACKAGE_AURA_IDS, WOW335_HUNTER_DETERRENCE_ABILITY_ID } from "./wow335DeterrenceRuntime";
import { wow335HunterPreventionTypeForAbility } from "./wow335AbilityCatalog";
const TEST_PARTY_ID = "wow335-deterrence-cast-integration";
const TARGET_ID = "wow335-deterrence-cast-target";
const NOW = 40_000;
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["hunter"]);
});
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
installCompanionCooldownPolicy(DEFAULT_COMPANION_COOLDOWN_POLICY);
usePartyStore.setState({ members: [] });
});
function initializePlayer(level = 60): void {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level });
usePartyStore.getState().initializeParty(
TEST_PARTY_ID, level, useCombatStore.getState().health, "damage", 1, "wow",
);
}
function abilityForHunter() {
const ability = abilitiesForCharacter("hunter", null)
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
if (!ability) throw new Error("The selected level-60 Hunter Deterrence spell is missing.");
return ability;
}
function packageFor(ownerId: string) {
return useCombatStore.getState().auras
.filter((aura) => (WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
&& aura.sourceId === ownerId);
}
function expectExactPackage(ownerId: string, appliedAt: number): void {
const auras = packageFor(ownerId);
expect(auras).toHaveLength(5);
expect(auras.map((aura) => aura.definition.id).sort()).toEqual(
[...WOW335_DETERRENCE_PACKAGE_AURA_IDS].sort(),
);
expect(auras.every((aura) => aura.sourceId === ownerId && aura.targetId === ownerId)).toBe(true);
expect(auras.every((aura) => aura.definition.durationMs === 5_000)).toBe(true);
expect(auras.map((aura) => aura.appliedAt)).toEqual(Array(5).fill(appliedAt));
expect(auras.map((aura) => aura.expiresAt)).toEqual(Array(5).fill(appliedAt + 5_000));
}
function installImminentMechanic(attackId: string, serverEntry: number): void {
installCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
mechanics: [{
id: "integration-heavy-shot",
enabled: true,
bossKey: "test/deterrence-heavy-shot",
bossName: "Deterrence Heavy Shot",
bossServerEntry: serverEntry,
attackId,
attackName: "Deterrence Heavy Shot",
scope: "party",
leadTimeMs: 2_500,
minimumCooldownSeconds: 20,
}],
});
}
function installHunterNpc(healthFraction: number, now: number, globalCooldownEndsAt = 0) {
const memberSeed = createConfiguredPartyMember({
name: "Deterrence Marksmanship Hunter",
classId: "hunter",
specialization: "Marksmanship",
specializationId: "hunter:marksmanship",
talentTreeId: "hunter-marksmanship",
talentRanks: {},
role: "damage",
}, 60, 0, TEST_PARTY_ID);
const learnedAbilities = abilitiesForCharacter("hunter", null)
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
const deterrence = learnedAbilities.find((ability) => ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
if (!deterrence) throw new Error("The configured level-60 Hunter did not learn Deterrence.");
const cooldowns = Object.fromEntries(learnedAbilities
.filter((ability) => ability.id !== deterrence.id)
.map((ability) => [ability.id, now + 100_000]));
const member = {
...memberSeed,
health: Math.round(memberSeed.maxHealth * healthFraction),
cooldowns,
globalCooldownEndsAt,
nextActionAt: 0,
status: "ready" as const,
resource: memberSeed.maxResource,
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
};
usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID });
useCombatStore.getState().registerMob(TARGET_ID, {
name: "Deterrence Heavy Shot",
serverEntry: 49335,
level: 60,
maxHealth: 100_000,
xpReward: 0,
attacks: [{
id: "integration-heavy-shot",
name: "Deterrence Heavy Shot",
delivery: "projectile",
target: "primary",
school: "physical",
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 30_000,
initialCooldownMs: [0, 0],
damageMultiplier: 1,
}],
});
setMobPosition(TARGET_ID, [3, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().damageMob(TARGET_ID, 1, now - 1, {
actorId: member.id,
role: "damage",
});
registerPartyRuntimePosition(member.id, [0, 0, -2]);
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
useCombatStore.getState().synchronizeActors();
return member;
}
function setNpcActiveCast(memberId: string, now: number): void {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[TARGET_ID]: {
...state.mobs[TARGET_ID]!,
activeCast: {
attackId: "integration-heavy-shot",
name: "Deterrence Heavy Shot",
school: "physical",
interruptible: true,
targetActorId: memberId,
sourcePosition: [3, 0, 0] as const,
targetPosition: [0, 0, -2] as const,
sourceYaw: 0,
startedAt: now - 1,
completesAt: now + 1_000,
},
},
},
}));
}
describe("WoW335 Hunter Deterrence player and configured-NPC casts", () => {
it("casts the five self-owned auras off-GCD, ignores a foreign target, and expires them at five seconds", () => {
initializePlayer();
const ability = abilityForHunter();
useCombatStore.setState({ globalCooldownEndsAt: NOW + 8_000 });
const cast = useCombatStore.getState().castAbility(ability.id, {
now: NOW,
targetActorId: "foreign-recipient",
});
expect(cast).toMatchObject({ ok: true, abilityId: ability.id });
expectExactPackage(PLAYER_AGGRO_ID, NOW);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "foreign-recipient"
&& (WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id))).toBe(false);
expect(useCombatStore.getState().cooldowns[ability.id]).toBe(NOW + 90_000);
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(NOW + 8_000);
useCombatStore.getState().tick(0.001, NOW + 5_000);
expect(packageFor(PLAYER_AGGRO_ID)).toHaveLength(0);
});
it("learns only at level 60, exposes the exact weapon requirement, and preserves the core aura-spell Disarm exception", () => {
initializePlayer(59);
const ability = abilityForHunter();
expect(learnedAbilityForBuild(ability, 59, {})).toBeNull();
expect(useCombatStore.getState().castAbility(ability.id, { now: NOW })).toMatchObject({
ok: false,
reason: "level-locked",
});
initializePlayer(60);
const unlocked = abilityForHunter();
expect(learnedAbilityForBuild(unlocked, 60, {})).toMatchObject({
wowWeaponRequirements: {
equippedItemClass: 2,
equippedItemSubclassMask: 173_555,
equippedItemInventoryMask: 0,
},
});
const disarmDefinition = enemyWeaponDebuffAuraDefinition(enemyWeaponDebuffContract(6_713, "ascension")!);
useCombatStore.setState((state) => ({
auras: [...applyAura(state.auras, {
definition: disarmDefinition,
sourceId: "verified-disarm-source",
targetId: PLAYER_AGGRO_ID,
now: NOW,
}).auras],
}));
const cast = useCombatStore.getState().castAbility(unlocked.id, { now: NOW + 1 });
expect(cast).toMatchObject({ ok: true, abilityId: unlocked.id });
expectExactPackage(PLAYER_AGGRO_ID, NOW + 1);
});
it("blocks actual PreventionType 2 shots during Deterrence but allows a selected PreventionType 1 trap", () => {
initializePlayer();
useCombatStore.getState().registerMob(TARGET_ID, {
name: "Deterrence cast-gate target",
level: 60,
maxHealth: 100_000,
xpReward: 0,
attacks: [],
});
setMobPosition(TARGET_ID, [10, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
const selectedAbilities = abilitiesForCharacter("hunter", null);
const selectedAbility = (id: string) => {
const catalogAbility = selectedAbilities.find((ability) => ability.id === id);
if (!catalogAbility) throw new Error(`Missing selected Hunter ability ${id}.`);
const learned = learnedAbilityForBuild(catalogAbility, 60, {});
if (!learned) throw new Error(`Hunter did not learn ${id} at level 60.`);
return learned;
};
const arcaneShot = selectedAbility("hunter-arcane-shot");
const steadyShot = selectedAbility("hunter-steady-shot");
const freezingTrapSource = selectedAbilities.find((ability) => (
ability.name === "Freezing Trap" && ability.source === "wow335-progression"
));
if (!freezingTrapSource) throw new Error("Missing selected source Freezing Trap.");
const freezingTrap = learnedAbilityForBuild(freezingTrapSource, 60, {});
if (!freezingTrap) throw new Error("Hunter did not learn Freezing Trap at level 60.");
expect(wow335HunterPreventionTypeForAbility(arcaneShot)).toBe(2);
expect(wow335HunterPreventionTypeForAbility(steadyShot)).toBe(2);
expect(wow335HunterPreventionTypeForAbility(freezingTrap)).toBe(1);
const deterrence = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, { now: NOW });
expect(deterrence.ok).toBe(true);
expectExactPackage(PLAYER_AGGRO_ID, NOW);
const arcaneCast = useCombatStore.getState().castAbility(arcaneShot.id, {
now: NOW + 1,
targetActorId: TARGET_ID,
});
const steadyCast = useCombatStore.getState().castAbility(steadyShot.id, {
now: NOW + 2,
targetActorId: TARGET_ID,
});
expect(arcaneCast).toMatchObject({ ok: false, reason: "player-controlled" });
expect(steadyCast).toMatchObject({ ok: false, reason: "player-controlled" });
const trapCast = useCombatStore.getState().castAbility(freezingTrap.id, {
now: NOW + 3,
targetActorId: TARGET_ID,
});
expect(trapCast).toMatchObject({ ok: true, abilityId: freezingTrap.id });
});
it("casts simultaneous player and Hunter-NPC packages below the 55% defensive threshold", () => {
initializePlayer();
const playerCast = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, {
now: NOW,
targetActorId: TARGET_ID,
});
expect(playerCast.ok).toBe(true);
const npc = installHunterNpc(0.54, NOW, NOW + 7_000);
advanceDungeonPartyCombat(NOW);
expectExactPackage(PLAYER_AGGRO_ID, NOW);
expectExactPackage(npc.id, NOW);
expect(usePartyStore.getState().members[0]).toMatchObject({
cooldowns: { [WOW335_HUNTER_DETERRENCE_ABILITY_ID]: NOW + 90_000 },
globalCooldownEndsAt: NOW + 7_000,
});
});
it("pre-empts a recognized imminent mechanic at full health, but does not maintain Deterrence at full health", () => {
initializePlayer();
const npc = installHunterNpc(1, NOW);
installImminentMechanic("integration-heavy-shot", 49_335);
setNpcActiveCast(npc.id, NOW);
advanceDungeonPartyCombat(NOW);
expectExactPackage(npc.id, NOW);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
installCompanionCooldownPolicy(DEFAULT_COMPANION_COOLDOWN_POLICY);
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 60 });
usePartyStore.getState().initializeParty(TEST_PARTY_ID, 60,
useCombatStore.getState().health, "damage", 1, "wow");
const fullHealthNpc = installHunterNpc(1, NOW + 1_000);
advanceDungeonPartyCombat(NOW + 1_000);
expect(packageFor(fullHealthNpc.id)).toHaveLength(0);
});
});
@@ -0,0 +1,501 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { stableCombatUnit, useCombatStore } from "./combatStore";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { normalizeEnemyAttack, type EnemyAttackDefinition } from "./enemyAttacks";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
import {
WOW335_DETERRENCE_LINKED_AURA_IDS,
WOW335_DETERRENCE_PACKAGE_AURA_IDS,
WOW335_HUNTER_DETERRENCE_ABILITY_ID,
resolveWow335DeterrenceIncoming,
type Wow335DeterrenceIncomingSource,
} from "./wow335DeterrenceRuntime";
import { abilitiesForCharacter, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
const NOW = 70_000;
const TEST_PARTY_ID = "wow335-deterrence-defense-integration";
const warder = requireDungeonDefinition("blackrock-depths").entities["entry-9041"];
const importedFrostboltSource = warder?.combat?.attacks.find((attack) => attack.spellId === 12_675);
const importedFrostbolt = importedFrostboltSource
? normalizeEnemyAttack(importedFrostboltSource)
: undefined;
function allImportedAttacks(): readonly EnemyAttackDefinition[] {
return DUNGEON_DEFINITIONS.flatMap((dungeon) => Object.values(dungeon.entities)
.flatMap((entity) => entity.combat?.attacks ?? []));
}
function importedRangedSpell(): EnemyAttackDefinition | undefined {
return allImportedAttacks()
.map((attack) => normalizeEnemyAttack(attack))
.filter((attack) => attack.damageOrigin === "wow335"
&& attack.spellId !== undefined
&& attack.attackKind === "ranged"
&& attack.wowHitSource !== undefined
&& (attack.wowHitSource.attributes0 & 0x0020_0000) === 0
&& (attack.wowHitSource.attributes3 & 0x0004_0000) === 0
&& (attack.effect === undefined || attack.effect.kind === "damage"))
.sort((left, right) => left.spellId! - right.spellId!)[0];
}
const rangedSpell = importedRangedSpell();
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["hunter"]);
});
afterEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
});
function prepareHunter(): void {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 60 });
usePartyStore.getState().initializeParty(
TEST_PARTY_ID, 60, useCombatStore.getState().health, "damage", 1, "wow",
);
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
}
function prepareHunterNpcDefender(mobId: string) {
const memberSeed = createConfiguredPartyMember({
name: "Deterrence Marksmanship Hunter",
classId: "hunter",
specialization: "Marksmanship",
specializationId: "hunter:marksmanship",
talentTreeId: "hunter-marksmanship",
talentRanks: {},
role: "damage",
}, 60, 0, TEST_PARTY_ID);
const learnedAbilities = abilitiesForCharacter("hunter", null)
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
const deterrence = learnedAbilities.find((ability) => ability.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID);
if (!deterrence) throw new Error("The configured level-60 Hunter did not learn Deterrence.");
const cooldowns = Object.fromEntries(learnedAbilities
.filter((ability) => ability.id !== deterrence.id)
.map((ability) => [ability.id, NOW + 100_000]));
const member = {
...memberSeed,
health: Math.round(memberSeed.maxHealth * 0.54),
cooldowns,
globalCooldownEndsAt: NOW + 7_000,
nextActionAt: 0,
status: "ready" as const,
resource: memberSeed.maxResource,
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
};
usePartyStore.setState({ members: [member], command: "attack", activeMobId: mobId });
useCombatStore.getState().registerMob(mobId, {
name: "Imported Frostbolt source",
level: 60,
maxHealth: 100_000,
xpReward: 0,
attacks: [{ ...importedFrostbolt!, castTimeMs: 0, initialCooldownMs: [0, 0] }],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().damageMob(mobId, 1, NOW - 1, { actorId: member.id, role: "damage" });
registerPartyRuntimePosition(member.id, [0, 0, 2]);
updatePartyRuntimeTargetVisibility(member.id, mobId, true);
useCombatStore.getState().synchronizeActors();
return member;
}
function castDeterrence(): void {
const cast = useCombatStore.getState().castAbility(WOW335_HUNTER_DETERRENCE_ABILITY_ID, { now: NOW });
expect(cast).toMatchObject({ ok: true, abilityId: WOW335_HUNTER_DETERRENCE_ABILITY_ID });
const packageAuras = useCombatStore.getState().auras.filter((aura) => (
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
));
expect(packageAuras).toHaveLength(5);
expect(packageAuras.every((aura) => aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID
&& aura.appliedAt === NOW && aura.expiresAt === NOW + 5_000)).toBe(true);
}
function rangedAutoAttack(): EnemyAttackDefinition {
return {
id: "integration-ranged-auto",
name: "Ranged Auto Attack",
delivery: "projectile",
target: "primary",
school: "physical",
attackKind: "ranged",
attackHand: "ranged",
animation: "attack",
castTimeMs: 0,
range: 30,
cooldownMs: 30_000,
initialCooldownMs: [0, 0],
damageMultiplier: 1,
} as unknown as EnemyAttackDefinition;
}
function applyAbsorbAndDamageTakenProbe(): void {
const combat = useCombatStore.getState();
const auras = applyAura(combat.auras, {
definition: {
id: "integration:deterrence-absorb-and-proc",
name: "Deterrence integration absorb/proc probe",
disposition: "buff",
durationMs: 30_000,
absorbs: [{ id: "probe-absorb", amount: 20_000 }],
procs: [{
id: "probe-damage-taken-proc",
triggers: "damage-taken",
chance: 1,
action: { kind: "resource", resource: "focus", amount: 25, target: "aura-target" },
}],
},
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: NOW,
}).auras;
useCombatStore.setState({
auras: [...auras],
resource: 0,
resourcePools: { ...combat.resourcePools, focus: 0 },
});
}
function resolveAttack(attack: EnemyAttackDefinition, label: string, now = NOW) {
const mobId = `deterrence-source-${label}`;
const normalized = normalizeEnemyAttack({
...attack,
id: `${label}:${attack.id}`,
castTimeMs: 0,
cooldownMs: 30_000,
initialCooldownMs: [0, 0],
});
useCombatStore.getState().registerMob(mobId, {
name: `Deterrence source ${label}`,
level: 60,
maxHealth: 100_000,
xpReward: 0,
attacks: [normalized],
});
if (attack.periodicTick) {
// registerMob normalizes source definitions; reapply the runtime pulse
// marker after that source-attack boundary to model a scheduled DoT tick.
useCombatStore.setState((state) => {
const registered = state.mobs[mobId];
if (!registered) return state;
return { mobs: { ...state.mobs, [mobId]: { ...registered,
attacks: registered.attacks.map((candidate) => ({ ...candidate, periodicTick: true })) } } };
});
}
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, now - 1, { actorId: PLAYER_AGGRO_ID, role: "damage" });
const healthBefore = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(now);
useCombatStore.getState().advanceMobCombat(now + 1);
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
candidate.sourceActorId === mobId && candidate.targetActorId === PLAYER_AGGRO_ID && candidate.kind === "damage"
));
return {
attack: useCombatStore.getState().mobs[mobId]!.attacks[0]!,
event,
result: useCombatStore.getState().lastCombatResult,
healthBefore,
healthAfter: useCombatStore.getState().health,
mobId,
};
}
function landedAttackLabel(attack: EnemyAttackDefinition, prefix: string): string {
const label = Array.from({ length: 10_000 }, (_, index) => `${prefix}-${index}`).find((candidate) => {
const mobId = `deterrence-source-${candidate}`;
const attackId = `${candidate}:${attack.id}`;
const roll = stableCombatUnit(`${mobId}:${attackId}:${PLAYER_AGGRO_ID}:0:${NOW}:outcome`);
return roll >= 0.15 && roll < 0.8;
});
if (!label) throw new Error(`Could not choose a deterministic landed-hit seed for ${attack.id}.`);
return label;
}
function sourceAttack(input: {
readonly id: string;
readonly spellId: number;
readonly school: EnemyAttackDefinition["school"];
readonly attackKind?: EnemyAttackDefinition["attackKind"];
readonly periodicTick?: boolean;
}): EnemyAttackDefinition {
const normalized = normalizeEnemyAttack({
id: input.id,
name: input.id,
spellId: input.spellId,
damageOrigin: "wow335",
delivery: "projectile",
target: "primary",
school: input.school,
...(input.attackKind ? { attackKind: input.attackKind } : {}),
...(input.periodicTick ? { periodicTick: true } : {}),
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 30_000,
initialCooldownMs: [0, 0],
damageMultiplier: 1,
});
// Runtime periodic pulses carry this marker after the source attack creates
// their periodic effect; keep it on this focused pulse fixture explicitly.
return input.periodicTick ? { ...normalized, periodicTick: true } : normalized;
}
describe("WoW335 Hunter Deterrence incoming combat integration", () => {
it.each([
["actual imported Frostbolt 12675", importedFrostbolt],
] as const)("deflects %s before damage, absorbs, and damage-taken procs", (_label, imported) => {
expect(imported).toBeDefined();
if (!imported) throw new Error("The selected imported WoW source attack is missing.");
expect(imported.damageOrigin).toBe("wow335");
expect(imported.wowHitSource).toBeDefined();
prepareHunter();
castDeterrence();
applyAbsorbAndDamageTakenProbe();
const absorbBefore = useCombatStore.getState().auras.find((aura) => (
aura.definition.id === "integration:deterrence-absorb-and-proc"
))?.absorbs[0]?.remaining;
const outcome = resolveAttack(imported, landedAttackLabel(imported, _label.replaceAll(" ", "-")));
expect(outcome.event).toMatchObject({
outcome: "deflect",
rawAmount: expect.any(Number),
effectiveAmount: 0,
absorbed: 0,
critical: false,
});
expect(outcome.event?.rawAmount).toBeGreaterThan(0);
expect(outcome.result).toMatchObject({
outcome: "deflect",
landed: false,
deflected: true,
critical: false,
blocked: false,
rawDamage: expect.any(Number),
finalDamage: 0,
});
expect(outcome.result?.rawDamage).toBeGreaterThan(0);
expect(outcome.healthAfter).toBe(outcome.healthBefore);
expect(useCombatStore.getState().controlMechanic).not.toBe("slow");
expect(useCombatStore.getState().auras.some((aura) => (
aura.sourceId === outcome.mobId && aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.startsWith("enemy-control:")
))).toBe(false);
expect(useCombatStore.getState().auras.find((aura) => (
aura.definition.id === "integration:deterrence-absorb-and-proc"
))?.absorbs[0]?.remaining).toBe(absorbBefore);
expect(useCombatStore.getState().resourcePools.focus).toBe(0);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "absorb" && event.sourceActorId === outcome.mobId
))).toBe(false);
});
it("keeps an actual ranged-source miss and recognizes its exact source row after a landed hit", () => {
expect(rangedSpell).toBeDefined();
if (!rangedSpell) throw new Error("The selected imported WoW ranged source attack is missing.");
expect(rangedSpell).toMatchObject({ damageOrigin: "wow335", damageClass: 3, attackKind: "ranged" });
prepareHunter();
castDeterrence();
const outcome = resolveAttack(rangedSpell, "actual-imported-ranged-source");
expect(outcome.event?.outcome).toBe("miss");
expect(outcome.result?.deflected).not.toBe(true);
expect(outcome.healthAfter).toBe(outcome.healthBefore);
const ability = abilitiesForCharacter("hunter", null)
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID)!;
expect(resolveWow335DeterrenceIncoming({
ability,
auras: useCombatStore.getState().auras,
defenderId: PLAYER_AGGRO_ID,
nowMs: NOW + 1,
incoming: rangedSpell,
baseOutcome: "hit",
defenderStunned: false,
attackerInFront: true,
})).toEqual({ outcome: "deflect", reason: "deflected" });
});
it("lets the sourced Aura185 penalty make a ranged auto miss without treating it as a spell deflect", () => {
prepareHunter();
castDeterrence();
const outcome = resolveAttack(rangedAutoAttack(), "ranged-auto");
expect(outcome.attack.wowHitSource).toBeUndefined();
expect(outcome.event).toMatchObject({ outcome: "miss", effectiveAmount: 0, absorbed: 0 });
expect(outcome.result).toMatchObject({ outcome: "miss", landed: false });
expect(outcome.result?.deflected).not.toBe(true);
expect(outcome.healthAfter).toBe(outcome.healthBefore);
});
it("cleans a torn player Aura185 package without borrowing its miss penalty or removing another Hunter's package", () => {
const attack = rangedAutoAttack();
const label = "aura185-owner-cleanup-ranged-auto";
prepareHunter();
const baseline = resolveAttack(attack, label);
const baselineMissChance = baseline.result?.attackTable.missPercent;
const baselineOutcome = baseline.event?.outcome;
expect(baselineMissChance).toBeTypeOf("number");
expect(baselineOutcome).toBeDefined();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
prepareHunter();
castDeterrence();
const npcMobId = "deterrence-aura185-second-owner-source";
const npc = prepareHunterNpcDefender(npcMobId);
advanceDungeonPartyCombat(NOW);
expect(useCombatStore.getState().auras.filter((aura) => (
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
&& aura.sourceId === npc.id && aura.targetId === npc.id
))).toHaveLength(5);
const aura185Id = WOW335_DETERRENCE_LINKED_AURA_IDS[1];
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !(aura.definition.id === aura185Id
&& aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID)),
}));
const afterTornPackage = resolveAttack(attack, label);
expect(afterTornPackage.event?.outcome).toBe(baselineOutcome);
expect(afterTornPackage.result?.attackTable.missPercent).toBe(baselineMissChance);
expect(afterTornPackage.result?.deflected).not.toBe(true);
useCombatStore.getState().tick(0.001, NOW + 1);
expect(useCombatStore.getState().auras.filter((aura) => (
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
&& aura.sourceId === PLAYER_AGGRO_ID
))).toHaveLength(0);
expect(useCombatStore.getState().auras.filter((aura) => (
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
&& aura.sourceId === npc.id && aura.targetId === npc.id
))).toHaveLength(5);
});
it("parries melee autos and leaves imported periodic and AlwaysHit source packets outside deflection", () => {
prepareHunter();
castDeterrence();
const meleeAuto: EnemyAttackDefinition = {
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
attackKind: "melee",
attackHand: "mainhand",
animation: "attack",
castTimeMs: 0,
range: 4.5,
cooldownMs: 30_000,
initialCooldownMs: [0, 0],
damageMultiplier: 1,
};
const parryLabel = Array.from({ length: 10_000 }, (_, index) => `melee-parry-${index}`)
.find((label) => {
const mobId = `deterrence-source-${label}`;
const attackId = `${label}:${meleeAuto.id}`;
const roll = stableCombatUnit(`${mobId}:${attackId}:${PLAYER_AGGRO_ID}:0:${NOW}:outcome`);
return roll >= 0.2 && roll < 0.8;
});
expect(parryLabel).toBeDefined();
const melee = resolveAttack(meleeAuto, parryLabel!);
expect(melee.event?.outcome).toBe("parry");
expect(melee.result).toMatchObject({ outcome: "parry", parried: true });
expect(melee.result?.deflected).not.toBe(true);
expect(melee.healthAfter).toBe(melee.healthBefore);
const periodic = sourceAttack({ id: "periodic-source-122", spellId: 122, school: "frost", periodicTick: true });
expect(periodic.wowHitSource).toBeDefined();
const periodicOutcome = resolveAttack(periodic, "periodic", NOW + 1_000);
expect(periodicOutcome.event?.outcome).not.toBe("deflect");
expect(periodicOutcome.healthAfter).toBeLessThan(periodicOutcome.healthBefore);
const alwaysHit = sourceAttack({ id: "always-hit-source-38163", spellId: 38_163, school: "shadow" });
expect(alwaysHit.wowHitSource?.attributes3).toBe(0x0004_0000);
const alwaysHitOutcome = resolveAttack(alwaysHit, "always-hit", NOW + 2_000);
expect(alwaysHitOutcome.event?.outcome).not.toBe("deflect");
expect(alwaysHitOutcome.healthAfter).toBeLessThan(alwaysHitOutcome.healthBefore);
});
it("deflects an actual imported Frostbolt from an engaged mob targeting a configured Hunter NPC", () => {
prepareHunter();
const mobId = "deterrence-npc-frostbolt-source";
const npc = prepareHunterNpcDefender(mobId);
advanceDungeonPartyCombat(NOW);
const packageAuras = useCombatStore.getState().auras.filter((aura) => (
(WOW335_DETERRENCE_PACKAGE_AURA_IDS as readonly string[]).includes(aura.definition.id)
&& aura.sourceId === npc.id && aura.targetId === npc.id
));
expect(packageAuras).toHaveLength(5);
const healthBefore = usePartyStore.getState().members[0]!.health;
useCombatStore.getState().advanceMobCombat(NOW + 1);
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
candidate.sourceActorId === mobId && candidate.targetActorId === npc.id && candidate.kind === "damage"
));
expect(event).toMatchObject({ outcome: "deflect", effectiveAmount: 0, absorbed: 0 });
expect(event?.rawAmount).toBeGreaterThan(0);
expect(usePartyStore.getState().members[0]!.health).toBe(healthBefore);
});
it("does not deflect a landed sourced spell while stunned and fails closed for unknown or native source records", () => {
prepareHunter();
castDeterrence();
useCombatStore.setState({ controlledUntil: NOW + 5_000, controlMechanic: "stun" });
const stunned = resolveAttack(importedFrostbolt!, "stunned-frostbolt");
expect(stunned.event?.outcome).not.toBe("deflect");
expect(stunned.healthAfter).toBeLessThan(stunned.healthBefore);
const ability = abilitiesForCharacter("hunter", null)
.find((candidate) => candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID)!;
const auras = useCombatStore.getState().auras;
const unknown = resolveWow335DeterrenceIncoming({
ability,
auras,
defenderId: PLAYER_AGGRO_ID,
nowMs: NOW + 1,
incoming: {
spellId: 999_999,
damageOrigin: "wow335",
attackKind: "spell",
},
baseOutcome: "hit",
defenderStunned: false,
attackerInFront: true,
});
expect(unknown).toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
const nativeAttack = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"].combat.attacks
.find((attack) => "spellId" in attack && attack.spellId === 492_289);
expect(nativeAttack).toBeDefined();
const nativeSource = enemyDamageTypeSourceForSpell("runewaker", 492_289);
expect(nativeSource).toBeDefined();
const nativeInput: Wow335DeterrenceIncomingSource = {
spellId: 492_289,
damageOrigin: "runewaker",
};
expect(resolveWow335DeterrenceIncoming({
ability,
auras,
defenderId: PLAYER_AGGRO_ID,
nowMs: NOW + 1,
incoming: nativeInput,
baseOutcome: "hit",
defenderStunned: false,
attackerInFront: true,
})).toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
const absentSource = enemyDamageTypeSourceForSpell("wow335", 999_999);
expect(absentSource).toBeUndefined();
});
});
+314
View File
@@ -0,0 +1,314 @@
import { describe, expect, it } from "vitest";
import { applyAura, type ActiveAura } from "./combatAuras";
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
WOW335_DETERRENCE_ALWAYS_HIT_ATTR3,
WOW335_DETERRENCE_LINKED_AURA_IDS,
WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0,
WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
WOW335_HUNTER_DETERRENCE_ABILITY_ID,
WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
removeWow335DeterrencePackageAuras,
resolveWow335DeterrenceIncoming,
wow335DeterrenceAbilityContract,
wow335DeterrenceActivePackage,
wow335DeterrenceSourceBypassReason,
type Wow335DeterrenceIncomingSource,
} from "./wow335DeterrenceRuntime";
const DEFENDER_ID = "deterrence-hunter";
const APPLIED_AT = 4_000;
function selectedAbility() {
const ability = WOW335_ABILITIES_BY_CLASS.hunter.find((candidate) => (
candidate.id === WOW335_HUNTER_DETERRENCE_ABILITY_ID
));
if (!ability) throw new Error("The selected WoW335 Hunter Deterrence mapping is missing.");
return ability;
}
function packageAuras(options: {
readonly sourceId?: string;
readonly targetId?: string;
readonly appliedAt?: number;
} = {}): ActiveAura[] {
const sourceId = options.sourceId ?? DEFENDER_ID;
const targetId = options.targetId ?? DEFENDER_ID;
const now = options.appliedAt ?? APPLIED_AT;
let auras: readonly ActiveAura[] = [];
for (const effect of [
...WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
...WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
]) {
if (effect.kind !== "apply-aura") throw new Error("Deterrence package descriptor is not an aura.");
auras = applyAura(auras, { definition: effect.aura, sourceId, targetId, now }).auras;
}
return [...auras];
}
function updateAura(
auras: readonly ActiveAura[],
id: string,
update: (aura: ActiveAura) => ActiveAura,
): ActiveAura[] {
return auras.map((aura) => aura.definition.id === id ? update(aura) : aura);
}
function exactIncoming(spellId: number): Wow335DeterrenceIncomingSource {
const source = enemyDamageTypeSourceForSpell("wow335", spellId);
if (!source || !("damageClass" in source) || !source.hitSource) {
throw new Error(`Expected an exact hit-source fixture for WoW spell ${spellId}.`);
}
return {
spellId,
damageOrigin: "wow335",
damageClass: source.damageClass,
schoolMask: source.schoolMask,
attackKind: source.attackKind,
wowHitSource: source.hitSource,
};
}
function resolveIncoming(input: {
readonly spellId: number;
readonly auras?: readonly ActiveAura[];
readonly baseOutcome?: "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
readonly defenderStunned?: boolean;
readonly attackerInFront?: boolean;
readonly incomingPatch?: Partial<Wow335DeterrenceIncomingSource>;
readonly incomingOverride?: Wow335DeterrenceIncomingSource;
}) {
return resolveWow335DeterrenceIncoming({
ability: selectedAbility(),
auras: input.auras ?? packageAuras(),
defenderId: DEFENDER_ID,
nowMs: APPLIED_AT + 1,
incoming: input.incomingOverride ?? { ...exactIncoming(input.spellId), ...input.incomingPatch },
baseOutcome: input.baseOutcome ?? "hit",
defenderStunned: input.defenderStunned ?? false,
attackerInFront: input.attackerInFront ?? true,
});
}
describe("WoW335 Hunter Deterrence runtime contract", () => {
it("binds only the selected catalog row and the audited parent/linked source package", () => {
const ability = selectedAbility();
expect(wow335DeterrenceAbilityContract(ability)).toBe(true);
expect(wow335DeterrenceAbilityContract({ ...ability, id: "deterrence" })).toBe(false);
expect(wow335DeterrenceAbilityContract({ ...ability, source: "ascension-progression" })).toBe(false);
expect(wow335DeterrenceAbilityContract({ ...ability, dbcSpellId: 65871 })).toBe(false);
expect(wow335DeterrenceAbilityContract({
...ability,
effects: ability.effects.map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, description: "Presentation copy", icon: "/test/icon.png" } }
: { ...effect }),
})).toBe(true);
expect(wow335DeterrenceAbilityContract({
...ability,
effects: ability.effects.map((effect) => effect.kind === "apply-aura"
&& effect.sourceAuraType === 47
? { ...effect, aura: { ...effect.aura, modifiers: [] } }
: effect),
})).toBe(false);
expect(WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS.map((effect) => (
effect.kind === "apply-aura" ? [effect.sourceAuraType, effect.sourceBasePoints, effect.aura.id] : null
))).toEqual([
[47, 99, "wow335:19263:effect:0:aura:47"],
[287, 99, "wow335:19263:effect:1:aura:287"],
[25, -1, "wow335:19263:effect:2:aura:25"],
]);
expect(WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.map((effect) => (
effect.kind === "apply-aura" ? [effect.sourceAuraType, effect.sourceBasePoints, effect.aura.id] : null
))).toEqual([
[288, 99, "wow335:67801:effect:0:aura:288"],
[185, -101, "wow335:67801:effect:1:aura:185"],
]);
const active = wow335DeterrenceActivePackage({
ability,
auras: packageAuras(),
defenderId: DEFENDER_ID,
nowMs: APPLIED_AT + 1,
});
expect(active).toMatchObject({
sourceProfile: "wow335",
parentSpellId: 19263,
linkedAuraSpellId: 67801,
sourceId: DEFENDER_ID,
targetId: DEFENDER_ID,
appliedAt: APPLIED_AT,
expiresAt: APPLIED_AT + 5_000,
parryAuraAmount: 100,
deflectAuraAmount: 100,
pacified: true,
ignoreHitDirection: true,
rangedAttackerHitChanceModifierPercent: -100,
});
const secondHunterId = "second-deterrence-hunter";
const twoHunterPackages = [...packageAuras(), ...packageAuras({
sourceId: secondHunterId,
targetId: secondHunterId,
})];
expect(wow335DeterrenceActivePackage({
ability,
auras: twoHunterPackages,
defenderId: DEFENDER_ID,
nowMs: APPLIED_AT + 1,
})?.sourceId).toBe(DEFENDER_ID);
expect(wow335DeterrenceActivePackage({
ability,
auras: twoHunterPackages,
defenderId: secondHunterId,
nowMs: APPLIED_AT + 1,
})?.sourceId).toBe(secondHunterId);
const presentationDefinitions = packageAuras().map((aura) => ({
...aura,
definition: {
...aura.definition,
description: "HUD description",
icon: "/test/icon.png",
},
}));
expect(wow335DeterrenceActivePackage({
ability,
auras: presentationDefinitions,
defenderId: DEFENDER_ID,
nowMs: APPLIED_AT + 1,
})).toBeDefined();
});
it("requires all five same-owner, same-target aura instances from one five-second application", () => {
const complete = packageAuras();
const linkedOnly = complete.filter((aura) => WOW335_DETERRENCE_LINKED_AURA_IDS.includes(
aura.definition.id as (typeof WOW335_DETERRENCE_LINKED_AURA_IDS)[number],
));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: linkedOnly, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
const noIgnoreFacingChild = complete.filter((aura) => (
aura.definition.id !== WOW335_DETERRENCE_LINKED_AURA_IDS[0]
));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: noIgnoreFacingChild, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
expect(resolveIncoming({ spellId: 122, auras: noIgnoreFacingChild, attackerInFront: false }))
.toEqual({ outcome: "unchanged", reason: "inactive-package" });
const mismatchedOwner = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[0], (aura) => ({
...aura,
sourceId: "another-hunter",
}));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: mismatchedOwner, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
const mismatchedTarget = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
...aura,
targetId: "another-defender",
}));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: mismatchedTarget, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
const mismatchedTime = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
...aura,
appliedAt: APPLIED_AT + 1,
}));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: mismatchedTime, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
const badLifetime = updateAura(complete, WOW335_DETERRENCE_LINKED_AURA_IDS[1], (aura) => ({
...aura,
expiresAt: APPLIED_AT + 6_000,
}));
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: badLifetime, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 1,
})).toBeUndefined();
expect(wow335DeterrenceActivePackage({
ability: selectedAbility(), auras: complete, defenderId: DEFENDER_ID, nowMs: APPLIED_AT + 5_000,
})).toBeUndefined();
});
it("deflects eligible landed Magic and ranged DamageClass spells after exact source gates", () => {
expect(resolveIncoming({ spellId: 122, attackerInFront: false }))
.toEqual({ outcome: "deflect", reason: "deflected" });
expect(resolveIncoming({ spellId: 2643, attackerInFront: true, baseOutcome: "block" }))
.toEqual({ outcome: "deflect", reason: "deflected" });
});
it("preserves miss/resist outcomes and excludes stunned, periodic, autos, foreign, unknown, NONE, and melee inputs", () => {
expect(resolveIncoming({ spellId: 122, baseOutcome: "miss" }))
.toEqual({ outcome: "unchanged", reason: "source-miss-or-resist" });
expect(resolveIncoming({ spellId: 122, baseOutcome: "resist" }))
.toEqual({ outcome: "unchanged", reason: "source-miss-or-resist" });
expect(resolveIncoming({ spellId: 122, defenderStunned: true }))
.toEqual({ outcome: "unchanged", reason: "stunned" });
expect(resolveIncoming({ spellId: 122, incomingPatch: { periodicTick: true } }))
.toEqual({ outcome: "unchanged", reason: "periodic-or-auto-attack" });
expect(resolveIncoming({ spellId: 2643, incomingPatch: { isAutoAttack: true, attackHand: "mainhand" } }))
.toEqual({ outcome: "unchanged", reason: "non-spell-delivery" });
const aura185 = WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS.find((effect) => (
effect.kind === "apply-aura" && effect.sourceAuraType === 185
));
expect(aura185?.kind === "apply-aura" ? aura185.aura.modifiers : undefined)
.toEqual([{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 }]);
expect(resolveIncoming({ spellId: 122, incomingPatch: { damageOrigin: "runewaker" } }))
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
expect(resolveIncoming({ spellId: 999_999, incomingOverride: {
spellId: 999_999,
damageOrigin: "wow335",
damageClass: 1,
schoolMask: 1,
attackKind: "spell",
wowHitSource: { attributes0: 0, attributes3: 0, attributes7: 0 },
} }))
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
expect(resolveIncoming({ spellId: 5416 }))
.toEqual({ outcome: "unchanged", reason: "unsupported-damage-class" });
expect(resolveIncoming({ spellId: 3242 }))
.toEqual({ outcome: "unchanged", reason: "unsupported-damage-class" });
});
it("rejects borrowed hit flags and models branch-specific source bypass masks", () => {
expect(resolveIncoming({ spellId: 122, incomingPatch: { wowHitSource: { attributes0: 0, attributes3: 1, attributes7: 0 } } }))
.toEqual({ outcome: "unchanged", reason: "foreign-or-unknown-source" });
expect(wow335DeterrenceSourceBypassReason({
damageClass: 1, attributes0: 0, attributes3: WOW335_DETERRENCE_ALWAYS_HIT_ATTR3, attributes7: 0,
})).toBe("source-always-hit");
expect(wow335DeterrenceSourceBypassReason({
damageClass: 3, attributes0: WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0, attributes3: 0, attributes7: 0,
})).toBe("source-no-active-defense");
expect(wow335DeterrenceSourceBypassReason({
damageClass: 1, attributes0: 0, attributes3: 0, attributes7: WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
})).toBe("source-no-attack-miss");
expect(wow335DeterrenceSourceBypassReason({
damageClass: 1, attributes0: WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0, attributes3: 0, attributes7: 0,
})).toBeUndefined();
expect(wow335DeterrenceSourceBypassReason({
damageClass: 3, attributes0: 0, attributes3: 0, attributes7: WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7,
})).toBeUndefined();
});
it("removes only package auras for the matching self-cast owner, target, and application", () => {
const auras = [
...packageAuras(),
...packageAuras({ sourceId: "other-hunter", targetId: "other-hunter", appliedAt: APPLIED_AT }),
...packageAuras({ sourceId: DEFENDER_ID, targetId: DEFENDER_ID, appliedAt: APPLIED_AT + 1 }),
];
const remaining = removeWow335DeterrencePackageAuras(auras, {
sourceId: DEFENDER_ID,
defenderId: DEFENDER_ID,
appliedAt: APPLIED_AT,
});
expect(remaining).toHaveLength(10);
expect(remaining.every((aura) => aura.appliedAt !== APPLIED_AT
|| aura.sourceId !== DEFENDER_ID
|| aura.targetId !== DEFENDER_ID)).toBe(true);
});
});
+461
View File
@@ -0,0 +1,461 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { ActiveAura, AuraDefinition } from "./combatAuras";
import { enemyDamageTypeSourceForSpell } from "./mobDamageTypes";
import {
WOW335_DETERRENCE_AURA_LINK,
WOW335_DETERRENCE_SOURCE_SIGNATURES,
wow335DeterrenceSourceContract,
} from "./wow335Deterrence";
export const WOW335_HUNTER_DETERRENCE_ABILITY_ID = "wow335-hunter-deterrence";
export const WOW335_DETERRENCE_AURA_DURATION_MS = 5_000;
export const WOW335_DETERRENCE_ALWAYS_HIT_ATTR3 = 0x00040000;
export const WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0 = 0x00200000;
export const WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7 = 0x02000000;
export const WOW335_DETERRENCE_PARENT_AURA_IDS = Object.freeze([
"wow335:19263:effect:0:aura:47",
"wow335:19263:effect:1:aura:287",
"wow335:19263:effect:2:aura:25",
] as const);
export const WOW335_DETERRENCE_LINKED_AURA_IDS = Object.freeze([
"wow335:67801:effect:0:aura:288",
"wow335:67801:effect:1:aura:185",
] as const);
export const WOW335_DETERRENCE_PACKAGE_AURA_IDS: readonly string[] = Object.freeze([
...WOW335_DETERRENCE_PARENT_AURA_IDS,
...WOW335_DETERRENCE_LINKED_AURA_IDS,
]);
const makeAura = (
id: string,
name: string,
modifiers?: AuraDefinition["modifiers"],
utilityTags?: readonly string[],
): AuraDefinition => Object.freeze({
id,
name,
disposition: "buff",
durationMs: WOW335_DETERRENCE_AURA_DURATION_MS,
maxStacks: 1,
stackBehavior: "refresh",
uniqueBySource: true,
...(modifiers?.length ? { modifiers: Object.freeze([...modifiers]) } : {}),
...(utilityTags?.length ? { utilityTags: Object.freeze([...utilityTags]) } : {}),
});
function applyAuraEffect(input: {
readonly aura: AuraDefinition;
readonly sourceAuraType: number;
readonly sourceBasePoints: number;
}): AbilityEffect {
const { aura, sourceAuraType, sourceBasePoints } = input;
return Object.freeze({
kind: "apply-aura",
aura,
recipient: "ability-target",
sourceEffectType: 6,
sourceAuraType,
sourceBasePoints,
sourceDieSides: 1,
sourcePointsPerLevel: 0,
miscValue: 0,
triggerSpellId: 0,
}) as AbilityEffect;
}
const parentParry = makeAura(
WOW335_DETERRENCE_PARENT_AURA_IDS[0],
"Deterrence (DBC 19263, Aura 47)",
[{ kind: "stat", stat: "parry", operation: "flat", value: 100 }],
);
const parentDeflect = makeAura(
WOW335_DETERRENCE_PARENT_AURA_IDS[1],
"Deterrence (DBC 19263, Aura 287)",
);
const parentPacify = makeAura(
WOW335_DETERRENCE_PARENT_AURA_IDS[2],
"Deterrence (DBC 19263, Aura 25)",
undefined,
["pacified"],
);
const linkedIgnoreFacing = makeAura(
WOW335_DETERRENCE_LINKED_AURA_IDS[0],
"Deterrence (DBC 67801, Aura 288)",
);
const linkedRangedHitPenalty = makeAura(
WOW335_DETERRENCE_LINKED_AURA_IDS[1],
"Deterrence (DBC 67801, Aura 185)",
[{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: -100 }],
);
/** Exact three source effects for selected Hunter Spell.dbc row 19263. */
export const WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS: readonly AbilityEffect[] = Object.freeze([
applyAuraEffect({ aura: parentParry, sourceAuraType: 47, sourceBasePoints: 99 }),
applyAuraEffect({ aura: parentDeflect, sourceAuraType: 287, sourceBasePoints: 99 }),
applyAuraEffect({ aura: parentPacify, sourceAuraType: 25, sourceBasePoints: -1 }),
]);
/** Exact two effects from the linked spell_linked_spell Aura row 67801. */
export const WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS: readonly AbilityEffect[] = Object.freeze([
applyAuraEffect({ aura: linkedIgnoreFacing, sourceAuraType: 288, sourceBasePoints: 99 }),
applyAuraEffect({ aura: linkedRangedHitPenalty, sourceAuraType: 185, sourceBasePoints: -101 }),
]);
const selectedSourcePackage = wow335DeterrenceSourceContract({
sourceProfile: "wow335",
parent: WOW335_DETERRENCE_SOURCE_SIGNATURES.selectedParent,
linkedAura: WOW335_DETERRENCE_SOURCE_SIGNATURES.linkedAura,
linkedRows: [WOW335_DETERRENCE_AURA_LINK],
});
const expectedPackageAuraDefinitions = Object.freeze([
...WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS,
...WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS,
].map((effect) => isApplyAuraEffect(effect) ? effect.aura : undefined));
export interface Wow335DeterrenceIncomingSource {
readonly spellId?: number;
readonly damageOrigin?: "wow335" | "runewaker";
readonly damageClass?: number;
readonly schoolMask?: number;
readonly attackKind?: "melee" | "ranged" | "spell";
readonly attackHand?: string;
readonly isAutoAttack?: boolean;
readonly periodicTick?: boolean;
readonly wowHitSource?: {
readonly attributes0: number;
readonly attributes3: number;
readonly attributes7: number;
};
}
export interface Wow335DeterrenceActivePackage {
readonly sourceProfile: "wow335";
readonly parentSpellId: 19263;
readonly linkedAuraSpellId: 67801;
readonly sourceId: string;
readonly targetId: string;
readonly appliedAt: number;
readonly expiresAt: number;
readonly parryAuraAmount: 100;
readonly deflectAuraAmount: 100;
readonly pacified: true;
readonly ignoreHitDirection: true;
/** Already applied by Aura 185 through the normal incoming-hit modifier path. */
readonly rangedAttackerHitChanceModifierPercent: -100;
readonly auraInstances: readonly [ActiveAura, ActiveAura, ActiveAura, ActiveAura, ActiveAura];
}
function isApplyAuraEffect(effect: AbilityEffect | undefined): effect is Extract<AbilityEffect, { kind: "apply-aura" }> {
return effect?.kind === "apply-aura";
}
function structurallyEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((value, index) => structurallyEqual(value, right[index]));
}
if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false;
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) => key === rightKeys[index]
&& structurallyEqual(leftRecord[key], rightRecord[key]));
}
function auraBehavior(definition: AuraDefinition): Readonly<Record<string, unknown>> {
const {
name: _name,
description: _description,
icon: _icon,
hideFromAuraStrip: _hideFromAuraStrip,
...behavior
} = definition;
return behavior;
}
function sameAuraBehavior(actual: AuraDefinition, expected: AuraDefinition): boolean {
return structurallyEqual(auraBehavior(actual), auraBehavior(expected));
}
function sameApplyAuraEffect(
actual: Extract<AbilityEffect, { kind: "apply-aura" }>,
expected: Extract<AbilityEffect, { kind: "apply-aura" }>,
): boolean {
const { aura: actualAura, ...actualFields } = actual;
const { aura: expectedAura, ...expectedFields } = expected;
return structurallyEqual(actualFields, expectedFields)
&& sameAuraBehavior(actualAura, expectedAura);
}
function isSelectedParentDefense(
defense: AbilityDefinition["wowSpellDefense"] | undefined,
): boolean {
return defense?.sourceProfile === "wow335"
&& defense.damageClass === 0
&& defense.schoolMask === 1
&& defense.attributes0 === 327_696
&& defense.attributes1 === 16
&& defense.attributes3 === 0
&& defense.attributes5 === 0
&& defense.directDamageFromEffects === false
&& defense.customAttributes === undefined
&& defense.customAttributeEvidence === undefined;
}
function matchesParentEffectList(effects: readonly AbilityEffect[] | undefined): boolean {
return effects?.length === WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS.length
&& effects.every((effect, index) => {
const expected = WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS[index];
return Boolean(expected && isApplyAuraEffect(effect) && isApplyAuraEffect(expected)
&& sameApplyAuraEffect(effect, expected));
});
}
function matchesSelectedAbility(ability: AbilityDefinition): boolean {
if (!selectedSourcePackage
|| ability.id !== WOW335_HUNTER_DETERRENCE_ABILITY_ID
|| ability.classId !== "hunter"
|| ability.source !== "wow335-progression"
|| ability.dbcSpellId !== 19263
|| ability.name !== "Deterrence"
|| ability.target !== "self"
|| ability.range.min !== 0
|| ability.range.max !== 0
|| ability.castMode !== "instant"
|| ability.castTimeMs !== 0
|| ability.cooldownMs !== 90_000
|| ability.gcdMs !== 0
|| ability.cost.amount !== 0
|| ability.passive === true
|| !isSelectedParentDefense(ability.wowSpellDefense)
|| ability.ranks?.length !== 1
|| !matchesParentEffectList(ability.effects)) return false;
const [rank] = ability.ranks;
return rank?.spellId === 19263
&& rank.level === 60
&& rank.castTimeMs === 0
&& rank.cooldownMs === 90_000
&& rank.gcdMs === 0
&& rank.cost.amount === 0
&& isSelectedParentDefense(rank.wowSpellDefense)
&& matchesParentEffectList(rank.effects);
}
/** Validate the exact selected Hunter catalog mapping before casting 19263. */
export function wow335DeterrenceAbilityContract(ability: AbilityDefinition): boolean {
return matchesSelectedAbility(ability);
}
/**
* Return the two verified 67801 child effects for the original target. The
* caller should apply them atomically with the accepted 19263 parent cast.
*/
export function wow335DeterrenceLinkedAuraEffects(): readonly AbilityEffect[] {
return WOW335_HUNTER_DETERRENCE_LINKED_AURA_EFFECTS;
}
function activeAuraById(auras: readonly ActiveAura[], id: string, ownerId: string): ActiveAura | undefined {
const matches = auras.filter((aura) => aura.definition.id === id
&& aura.sourceId === ownerId
&& aura.targetId === ownerId);
return matches.length === 1 ? matches[0] : undefined;
}
/**
* Resolve only a complete live 19263 + linked 67801 aura package on the
* selected self-cast ability. All five actual aura instances must share the
* same owner, target, application time, and exact five-second lifetime.
*/
export function wow335DeterrenceActivePackage(input: {
readonly ability: AbilityDefinition;
readonly auras: readonly ActiveAura[];
readonly defenderId: string;
readonly nowMs: number;
}): Wow335DeterrenceActivePackage | undefined {
if (!matchesSelectedAbility(input.ability)
|| !input.defenderId
|| !Number.isFinite(input.nowMs)) return undefined;
const instances = WOW335_DETERRENCE_PACKAGE_AURA_IDS.map((id) => (
activeAuraById(input.auras, id, input.defenderId)
));
if (instances.some((aura) => !aura)) return undefined;
const [parry, deflect, pacify, ignoreFacing, rangedPenalty] = instances as [
ActiveAura, ActiveAura, ActiveAura, ActiveAura, ActiveAura,
];
const group = [parry, deflect, pacify, ignoreFacing, rangedPenalty] as const;
const [first] = group;
if (!first) return undefined;
const appliedAt = first.appliedAt;
const expiresAt = first.expiresAt;
if (!Number.isFinite(appliedAt)
|| expiresAt === null
|| !Number.isFinite(expiresAt)
|| expiresAt !== appliedAt + WOW335_DETERRENCE_AURA_DURATION_MS
|| input.nowMs < appliedAt
|| input.nowMs >= expiresAt) return undefined;
const allUniqueInstances = new Set<string>();
for (let index = 0; index < group.length; index += 1) {
const aura = group[index];
const expectedId = WOW335_DETERRENCE_PACKAGE_AURA_IDS[index];
const expectedDefinition = expectedPackageAuraDefinitions[index];
if (!aura || !expectedId
|| !expectedDefinition
|| aura.definition.id !== expectedId
|| !sameAuraBehavior(aura.definition, expectedDefinition)
|| aura.definition.durationMs !== WOW335_DETERRENCE_AURA_DURATION_MS
|| aura.sourceId !== input.defenderId
|| aura.targetId !== input.defenderId
|| aura.appliedAt !== appliedAt
|| aura.expiresAt !== expiresAt
|| aura.stacks !== 1
|| !aura.instanceId
|| allUniqueInstances.has(aura.instanceId)) return undefined;
allUniqueInstances.add(aura.instanceId);
}
return Object.freeze({
sourceProfile: "wow335",
parentSpellId: 19263,
linkedAuraSpellId: 67801,
sourceId: input.defenderId,
targetId: input.defenderId,
appliedAt,
expiresAt,
parryAuraAmount: 100,
deflectAuraAmount: 100,
pacified: true,
ignoreHitDirection: true,
rangedAttackerHitChanceModifierPercent: -100,
auraInstances: Object.freeze([...group]) as unknown as Wow335DeterrenceActivePackage["auraInstances"],
});
}
export type Wow335DeterrenceDeflectReason =
| "deflected"
| "inactive-package"
| "foreign-or-unknown-source"
| "unsupported-damage-class"
| "non-spell-delivery"
| "periodic-or-auto-attack"
| "source-miss-or-resist"
| "stunned"
| "source-always-hit"
| "source-no-active-defense"
| "source-no-attack-miss"
| "front-arc-required";
export interface Wow335DeterrenceDeflectResolution {
readonly outcome: "deflect" | "unchanged";
readonly reason: Wow335DeterrenceDeflectReason;
}
/** The source-owned hit-check bits that suppress aura-287 deflection. */
export function wow335DeterrenceSourceBypassReason(input: {
readonly damageClass: number;
readonly attributes0: number;
readonly attributes3: number;
readonly attributes7: number;
}): Wow335DeterrenceDeflectReason | undefined {
if ((input.attributes3 & WOW335_DETERRENCE_ALWAYS_HIT_ATTR3) !== 0) return "source-always-hit";
if (input.damageClass === 3
&& (input.attributes0 & WOW335_DETERRENCE_NO_ACTIVE_DEFENSE_ATTR0) !== 0) {
return "source-no-active-defense";
}
if (input.damageClass === 1
&& (input.attributes7 & WOW335_DETERRENCE_NO_ATTACK_MISS_ATTR7) !== 0) {
return "source-no-attack-miss";
}
return undefined;
}
function sourceHitFlagsMatch(
incoming: Wow335DeterrenceIncomingSource,
): boolean {
if (incoming.damageOrigin !== "wow335"
|| !Number.isSafeInteger(incoming.spellId)
|| incoming.spellId === undefined
|| !incoming.wowHitSource) return false;
const source = enemyDamageTypeSourceForSpell("wow335", incoming.spellId);
if (!source || !("hitSource" in source) || !source.hitSource) return false;
return incoming.damageClass === source.damageClass
&& incoming.schoolMask === source.schoolMask
&& incoming.wowHitSource.attributes0 === source.hitSource.attributes0
&& incoming.wowHitSource.attributes3 === source.hitSource.attributes3
&& incoming.wowHitSource.attributes7 === source.hitSource.attributes7;
}
/**
* Evaluate only the final source-defined deflect gate. The existing combat
* result remains responsible for miss, dodge, resistance, and other outcomes.
*/
export function resolveWow335DeterrenceIncoming(input: {
readonly ability: AbilityDefinition;
readonly auras: readonly ActiveAura[];
readonly defenderId: string;
readonly nowMs: number;
readonly incoming: Wow335DeterrenceIncomingSource;
readonly baseOutcome: "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
readonly defenderStunned: boolean;
readonly attackerInFront: boolean;
}): Wow335DeterrenceDeflectResolution {
const packageState = wow335DeterrenceActivePackage({
ability: input.ability,
auras: input.auras,
defenderId: input.defenderId,
nowMs: input.nowMs,
});
if (!packageState) return { outcome: "unchanged", reason: "inactive-package" };
const { incoming } = input;
if (!sourceHitFlagsMatch(incoming)) return { outcome: "unchanged", reason: "foreign-or-unknown-source" };
if (incoming.damageClass !== 1 && incoming.damageClass !== 3) {
return { outcome: "unchanged", reason: "unsupported-damage-class" };
}
if (incoming.attackHand !== undefined || incoming.isAutoAttack === true
|| (incoming.damageClass === 1 && incoming.attackKind !== "spell")
|| (incoming.damageClass === 3 && incoming.attackKind !== "ranged" && incoming.attackKind !== "spell")) {
return { outcome: "unchanged", reason: "non-spell-delivery" };
}
if (incoming.periodicTick === true) return { outcome: "unchanged", reason: "periodic-or-auto-attack" };
if (input.baseOutcome !== "hit" && input.baseOutcome !== "crit" && input.baseOutcome !== "block") {
return { outcome: "unchanged", reason: "source-miss-or-resist" };
}
if (input.defenderStunned) return { outcome: "unchanged", reason: "stunned" };
const sourceBypass = wow335DeterrenceSourceBypassReason({
damageClass: incoming.damageClass,
...incoming.wowHitSource!,
});
if (sourceBypass) return { outcome: "unchanged", reason: sourceBypass };
if (!packageState.ignoreHitDirection && !input.attackerInFront) {
return { outcome: "unchanged", reason: "front-arc-required" };
}
return { outcome: "deflect", reason: "deflected" };
}
/**
* Remove only this self-cast's exact Deterrence package members. The caller
* uses the removed parent's appliedAt to synchronize linked-aura removal.
*/
export function removeWow335DeterrencePackageAuras(
auras: readonly ActiveAura[],
input: { readonly sourceId: string; readonly defenderId: string; readonly appliedAt: number },
): readonly ActiveAura[] {
if (!input.sourceId || input.sourceId !== input.defenderId || !Number.isFinite(input.appliedAt)) return auras;
const ids = new Set(WOW335_DETERRENCE_PACKAGE_AURA_IDS);
return auras.filter((aura) => !(ids.has(aura.definition.id)
&& aura.sourceId === input.sourceId
&& aura.targetId === input.defenderId
&& aura.appliedAt === input.appliedAt));
}
@@ -0,0 +1,111 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore, stableCombatUnit } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const TARGET = "synthetic-interrupt-block-creature";
const FRONT = [0, 0, -2] as const;
const ORIGIN = [0, 0, 0] as const;
const CASES = [
{ classId: "rogue" as const, name: "Kick", spellId: 1766, lockoutMs: 5_000 },
{ classId: "warrior" as const, name: "Pummel", spellId: 6552, lockoutMs: 4_000 },
];
function prepare(classId: "rogue" | "warrior", name: string) {
useCombatStore.getState().initializeCharacter({ classId, level: 40 });
usePartyStore.getState().initializeParty("interrupt-block-fixture", 40,
useCombatStore.getState().health, "damage", 1, "wow");
useCombatStore.getState().setPlayerPosition(FRONT);
useCombatStore.getState().registerMob(TARGET, { name: "Ordinary source NPC fixture",
serverEntry: 1666, level: 25, maxHealth: 100_000, xpReward: 0 });
registerMobRuntime(TARGET, ORIGIN, Math.PI);
registerCombatSpatialProvider({ actor: (id) => id === TARGET
? { id, position: ORIGIN, yaw: Math.PI } : { id, position: FRONT, yaw: 0 },
hasLineOfSight: () => true, projectGroundPoint: (position) => position });
useCombatStore.getState().selectMob(TARGET);
useCombatStore.setState((state) => ({ resource: state.maxResource,
resourcePools: { ...state.resourcePools, [classId === "warrior" ? "rage" : "energy"]: state.maxResource } }));
return abilityAtLevel(abilitiesForClass(classId)
.find((ability) => ability.id === (classId === "rogue" ? "rogue-kick" : "wow335-warrior-pummel"))!, 40);
}
function blockedTime(actorId: string, abilityId: string) {
for (let now = 1_000; now < 100_000; now++) {
if (stableCombatUnit(`${actorId}:${abilityId}:${TARGET}:${now}:utility-hit:outcome`) < 0.01) return now;
}
throw new Error("No deterministic source utility block roll");
}
function blockedEvents(actorId: string, abilityId: string) {
return useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === actorId
&& event.targetActorId === TARGET && event.abilityId === abilityId && event.blocked);
}
describe("exact selected Kick/Pummel ordinary NPC full blocking", () => {
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "rogue"]); });
afterEach(() => {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetCombatSpatialProvider(); resetPartyRuntimeClock();
});
it.each(CASES)("records a blocked actual player $name while committing cost/cooldown", ({ classId, name, spellId }) => {
const ability = prepare(classId, name);
expect(ability.dbcSpellId).toBe(spellId);
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
const before = useCombatStore.getState();
const cast = before.castAbility(ability.id, { now, targetActorId: TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "block", blocked: true, finalDamage: 0 });
expect(blockedEvents(PLAYER_AGGRO_ID, ability.id)).toHaveLength(1);
expect(useCombatStore.getState().resource).toBeLessThan(before.resource);
expect(useCombatStore.getState().cooldowns[ability.id]).toBeGreaterThan(now);
expect(useCombatStore.getState().mobs[TARGET]!.statuses).toHaveLength(0);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
});
it.each(CASES)("respects the casting-defender block gate and real $name school lockout", ({ classId, name, lockoutMs }) => {
const ability = prepare(classId, name);
const now = blockedTime(PLAYER_AGGRO_ID, ability.id);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET]: { ...state.mobs[TARGET]!,
activeCast: { attackId: "synthetic-fire-cast", name: "Synthetic interruptible Fire cast",
school: "fire", interruptible: true, targetActorId: PLAYER_AGGRO_ID,
sourcePosition: ORIGIN, targetPosition: FRONT, sourceYaw: Math.PI,
startedAt: now - 1, completesAt: now + 5_000 },
} } }));
const cast = useCombatStore.getState().castAbility(ability.id, { now, targetActorId: TARGET });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome: "hit", blocked: false });
expect(useCombatStore.getState().mobs[TARGET]!.activeCast).toBeNull();
expect(useCombatStore.getState().mobs[TARGET]!.schoolLockedUntil.fire).toBe(now + lockoutMs);
});
it.each(CASES)("blocks accepted companion $name completion without inventing damage", ({ classId, name }) => {
const ability = prepare(classId, name);
const member = createConfiguredPartyMember({ name: "Configured interrupt companion", classId,
specialization: classId === "warrior" ? "Protection" : "Assassination",
specializationId: classId === "warrior" ? "warrior:protection" : "rogue:assassination",
talentTreeId: classId === "warrior" ? "warrior-protection" : "rogue-assassination",
talentRanks: {}, role: "damage" }, 40, 0, "interrupt-block-fixture");
const now = blockedTime(member.id, ability.id);
// Synthetic already-paid pending cast; actual target validation/completion
// consumes the selected source ability through the companion runtime.
usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000,
cooldowns: { [ability.id]: now + 10_000 }, activeCastTargetId: TARGET,
activeCast: { abilityId: ability.id, school: "physical", mode: "cast",
startedAt: now - 1, completesAt: now, originalDurationMs: 1,
pushbackHits: 0, origin: FRONT, targetId: TARGET },
}], command: "attack", activeMobId: TARGET });
registerPartyRuntimePosition(member.id, FRONT);
updatePartyRuntimeTargetVisibility(member.id, TARGET, true);
useCombatStore.getState().damageMob(TARGET, 1, now - 1);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(now);
expect(blockedEvents(member.id, ability.id)).toHaveLength(1);
expect(usePartyStore.getState().members[0]!.activeCast).toBeNull();
expect(usePartyStore.getState().members[0]!.cooldowns[ability.id]).toBe(now + 10_000);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === ability.id && event.kind === "damage")).toBe(false);
});
});
@@ -0,0 +1,161 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const NOW = 2_000;
// Ten valid first-row Frost points satisfy Lichborne's current tree gate.
const TALENTS = { "talent-2031": 3, "talent-2020": 2, "talent-1968": 5, "talent-2215": 1 };
const LICHBORNE_ID = "wow335-death-knight-lichborne";
function prepare(options: {
readonly recipient: "player" | "party";
readonly spellId?: 19134 | 20511;
readonly learned?: boolean;
readonly genericControl?: "fear" | "stun" | "confuse" | "sleep";
readonly standaloneControl?: boolean;
readonly cooldownReadyAt?: number;
readonly globalCooldownEndsAt?: number;
}) {
const spellId = options.spellId ?? 19134;
const mechanic = options.standaloneControl ? options.genericControl!
: spellId === 20511 ? "stun" : "fear";
const controlledUntil = options.standaloneControl ? 4_000 : 9_000;
const talentRanks = options.learned === false ? {} : TALENTS;
useCombatStore.getState().initializeCharacter({
classId: options.recipient === "player" ? "death-knight" : "priest",
level: 80,
talentRanks: options.recipient === "player" ? talentRanks : {},
});
usePartyStore.getState().initializeParty("lichborne-escape-fixture", 80,
useCombatStore.getState().health, "damage", 1);
const member = createConfiguredPartyMember({
name: "Configured Lichborne Knight", classId: "death-knight",
specialization: "Frost", specializationId: "death-knight:frost",
talentTreeId: "death-knight-frost", talentRanks, role: "damage",
}, 80, 0, "lichborne-escape-fixture");
usePartyStore.setState({ members: options.recipient === "party" ? [{
...member, controlledUntil, controlMechanic: mechanic, status: "holding",
cooldowns: options.cooldownReadyAt === undefined ? member.cooldowns
: { ...member.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt },
globalCooldownEndsAt: options.globalCooldownEndsAt ?? 0,
}] : [] });
const targetId = options.recipient === "player" ? PLAYER_AGGRO_ID : member.id;
if (options.recipient === "party") registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.setState((state) => {
let auras = options.standaloneControl ? state.auras : applyAura(state.auras, { definition: {
id: `enemy-control:source-spell-${spellId}`, name: "Pinned source shout fixture",
disposition: "debuff", durationMs: 8_000,
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15 }],
}, sourceId: "synthetic-enemy", targetId, now: 1_000 }).auras;
if (options.genericControl) auras = applyAura(auras, { definition: {
id: `enemy-control:separate-enemy:separate-cast:${options.genericControl}`, name: "Independent control fixture",
disposition: "debuff", durationMs: 3_000,
}, sourceId: "separate-enemy", targetId, now: 1_000 }).auras;
return {
auras: [...auras],
controlledUntil: options.recipient === "player" ? controlledUntil : 0,
controlMechanic: options.recipient === "player" ? mechanic : null,
cooldowns: options.recipient === "player" && options.cooldownReadyAt !== undefined
? { ...state.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt } : state.cooldowns,
globalCooldownEndsAt: options.recipient === "player" ? options.globalCooldownEndsAt ?? 0 : 0,
};
});
return targetId;
}
function hasSourceControl(targetId: string) {
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
&& aura.definition.id.startsWith("enemy-control:source-spell-"));
}
function hasLichborneFearGrant(targetId: string) {
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
&& aura.definition.id.startsWith("wow335:49039:")
&& aura.definition.controlImmunities?.includes("fear"));
}
describe("source-bound Lichborne player and configured companion escape", () => {
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["death-knight"]); });
afterEach(() => {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
});
it.each([19134, 20511] as const)("casts learned player Lichborne through source control %s", (spellId) => {
const id = prepare({ recipient: "player", spellId });
const selected = abilityAtLevel(abilitiesForClass("death-knight")
.find((ability) => ability.id === LICHBORNE_ID)!, 80);
expect(selected.dbcSpellId).toBe(49039);
expect(useCombatStore.getState().talentRanks["talent-2215"]).toBe(1);
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(hasSourceControl(id)).toBe(false);
expect(hasLichborneFearGrant(id)).toBe(true);
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
useCombatStore.getState().tick(0.1, NOW + 100);
expect(hasSourceControl(id)).toBe(false);
});
it.each([19134, 20511] as const)("uses learned companion Lichborne through source control %s", (spellId) => {
const id = prepare({ recipient: "party", spellId });
advanceDungeonPartyCombat(NOW);
expect(hasSourceControl(id)).toBe(false);
expect(hasLichborneFearGrant(id)).toBe(true);
expect(usePartyStore.getState().members[0]).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().actors[id]?.controlledUntil).toBe(0);
expect(usePartyStore.getState().members[0]!.cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
});
it.each(["player", "party"] as const)("keeps %s controlled when Lichborne is unlearned, cooling down or blocked by unsupported sleep", (recipient) => {
for (const condition of [{ learned: false }, { cooldownReadyAt: 10_000 }, { genericControl: "sleep" as const }]) {
const id = prepare({ recipient, ...condition });
if (recipient === "player") expect(useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW }).ok).toBe(false);
else advanceDungeonPartyCombat(NOW);
expect(hasSourceControl(id)).toBe(true);
expect(hasLichborneFearGrant(id)).toBe(false);
if (condition.genericControl) expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
&& aura.definition.id === "enemy-control:separate-enemy:separate-cast:sleep")).toBe(true);
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
}
});
it.each(["player", "party"] as const)("permits %s casts through source-authorized fear/stun/confuse while preserving unrelated control", (recipient) => {
for (const genericControl of ["fear", "stun", "confuse"] as const) {
for (const standaloneControl of [false, true]) {
const id = prepare({ recipient, genericControl, standaloneControl });
if (recipient === "player") {
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
}
else advanceDungeonPartyCombat(NOW);
expect(hasSourceControl(id)).toBe(false);
expect(hasLichborneFearGrant(id)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
&& aura.definition.id === `enemy-control:separate-enemy:separate-cast:${genericControl}`)).toBe(true);
const actor = recipient === "player" ? useCombatStore.getState() : usePartyStore.getState().members[0]!;
expect(actor.controlledUntil).toBe(4_000);
expect(actor.controlMechanic).toBe(genericControl);
if (recipient === "party") expect(usePartyStore.getState().members[0]!.status).toBe("holding");
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
}
}
});
it.each(["player", "party"] as const)("preserves the existing %s GCD when source off-GCD Lichborne escapes", (recipient) => {
const id = prepare({ recipient, globalCooldownEndsAt: 10_000 });
if (recipient === "player") {
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
}
else advanceDungeonPartyCombat(NOW);
expect(hasSourceControl(id)).toBe(false);
expect(recipient === "player" ? useCombatStore.getState().globalCooldownEndsAt
: usePartyStore.getState().members[0]!.globalCooldownEndsAt).toBe(10_000);
});
});