Update Healer Man server
This commit is contained in:
1 parent
69809f7228
commit
ff20ab8779
101 files changed
+16713
-945
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()) }
|
||||
)),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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]),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "H4sIAAAAAAACCuy9a5PjNpY2+FcYendjZyIkFO6XmtgPdrm77Z32tNfltx0bHRUTEAlmsosicygqy+WO+u8bByAlSNRdcrkrm/PB0yUkyQPg3M+Dg39MlumjW9i/umZZ1NXkNZlOlvWqSd3k9T8mqV082eKh+t5WRe6W7eT1JFtVD66uZk/FkyuLyr3qf+j/Fv19WVeT6cRVz0VTVwtXtT/Y9L19cMvJ679Nlk366sEu3KsHV7nGti57lRfP7ofSfnTNN+Fd3y2e6qZd9m/a88jCVnbZ1s3iq+XSrd/vH0j+rXXwvG0+Jrkty7lN3//75B0QlNarqnXNG9vasn6YvD765h9XVVssXPfHPS32V9fU7eObunFv6sWigCXhmclIxudCUeY0MalImcG5TC0hSlHNsnk+145tP/7Wr/Kf6/T9fkL2/ul6SZ5cWa7n8Y+JXaauChs4QejVn21WvF9+/8P/+4esaOvmVVoWrmpn6appXNW+so2zM0FfPdk2fZx98+qbr/9YlG75xv/Vq7fwbpTNU9jEpzp9DO/81tnSNd/bava2rRv74F71LDD7UDfvez5YvvLPzMInD77606fpJK2fHbxo8vpv/+j56rsMVuPRZvWH3FYPs/fOPU2mk4V9ghHGom1cTl4r+GdbtAXwFgPWbW1bpG+f7IdqOXlNsJlOmtouiuoBeGQ5eU35hr/73Ujrxk0+TXdoAB74UFTZbNnW6XubuYgMvk2GiMkgAzIM26UCn0lE5my2KCq3jL4tjywBpYMlUMMlYGd+/YMtyqJ6mKX22TVVRAPf2Qa9RQPepYEKukuDOpOExv5aN7mrZu8buyojCtQxRtBnrAKhZ5IwL236PrftY72YZc49xeugt6mQW+ugBuuAyYCKczlhVWZ2YavNtxW+aAUYplevwENVL1zjHuLPm53P8+OSyNRg6oycK4mr6r2rZqDVy0gIvXAfJoENVp+LXRL4pVyY1R9iOSDUHGMAMWBDwwebcK4wLlPblK6dLWqwT675GJGhzRF9xAZ8yIDrdsiQZ5Lx66rMbdPY95vPU3zs85ScoQ4Iv0QYmzp9P1s+FU3EDXR3K8gWFWogEELKgWKkF1ORuaf2MeIIynbkgrCYDLnHRPHhZoizeeKxLuuFrdJ4JXa5QZ/QDKCoriWgbUAxlovYOO7uhIm/LwbWgbOBaoA3nPX9hW3sKqur2DQfnf1AKwg60AoUn60WHlxeNG6WPtrlIqJBH9NMQ/+ADHfgXHnI4POLbdM4kAS6JQkDraTEQBLEuRuwfLRt6xqXzR5tWUaCIPiOIGzJARv6KUZfrRrnZV1ns3zVVDYWBMHpERLIQC0SZq4m4dGVpWeFBqKgpt1aiR1vaWs7yGAltLjaUC9bZxfPdlW28efFZauwRx2c662tqsw18/oh/ro8Igp73FVJrnZXl6V9drMnV20tvjoye7pHFAe2iZ27+rZJbdvYX6OvC3ps9ub01p8rh/O6tVWR2vjb7JhVNkMXmVzN/QuXPtrKNvHX+UVcp/nVLkEIFmelnX9siqp9jIkQlzGfvl4BLF376N6/H6hBIY8Jv9zjDlwdsULmYtbWi/nW94/xv1/j7e8Tc7UzYFfpo6tsMUubj09bGlDoY4vAT8fLTJ29Bg8FeMfLWeuaZtsc6KOKcBgz8oEilJf4h7NF3dhl7BtKc4SAPeboaq/ALVP75GZ5AyHrqqke6zJOXwh5zDYPVQPTww05l5RV+2CbzO3kcYQ6ph72SCah1xuljoKnoqpsGkeQQh3TD+coqXNXoX10s8r9soqFQsljseNAM1B2vYqEz9fpqtz+vj42+XO24FzDmK5Kn0eq89m+gEGYo2p6mE4iV+fTvHYOdNTVFgnmIk5Q6movJWvs+/bRNnW1IxMS44uslTFXB/H217/X5axyzWoef58c04+DyEnha/lhvQtl8fDYVkX1EFNBL2KGfWx5bmLpYVVlW5kMiY8ldocf1+rqfN5zUUM2BzRz/H19ZAsGgZu5VgzsY/XePrp2VpfZ7H1RPWR1FL5KcjSfM/QZGLvaZ8jr5sF5gaxXsd8kGT3mMgyYkQ+cV32ubmwKWwIFoCTTR7t4KuJ8ghT4yGIMtkRcuyVPReuXARRD/PVjDMGG0iD11cHjWiobl5cubbeXQR514YaEXK2i26J675rWpY+z5WP9ATKuGzI03dFRJCZjyJnyGBVPTZ26bNXYctbXCYf82dS/umrWOJs+xslv/ZnJqJuHum1dNYNqUJT5Vfq3XpB300nm8qIqgCGWvlQ3rBgX+6t2bdGW8OK365HkPwf1vDJ/6Mu+GbxfQ6n2lyfblzPT0i6XRbpdUf6+zjwjZe3s8eO8KUCJZkWeF+mqbD/+1TaFrdpAraetqpuFLbf+KP51CevzvV2+97Mp3bMrf7SVr0wSPWX4XbcMkFr5Zv2G77bebJ9tUdp5URb+3d0/vb/51Lilq2AnYEb/mJR12v3vPUvTProFzO0vT66CymqZJe6XJ5cVnUQuV4uFbeATfwTzmbSPTb16eEx23jRNMpc72yZFu0zWHJI0NYRH08RWWQK7lLSPLoGM1cfElsWzQ5PppKxtVlQP37vl0ldnJz807sk2RfWw+xGE4O9XVfpom9ZlXzXO/pf11P/v/rdkOENgrIemXlWwfv8La5xh2N48lLLTuqwbP2AJJXgynVTONqHwmcP/oBJ/mk7sYg4l5T/DGmw9ZvWcG/BriqqFmjjsBkaUfppOHt2iWD49usatH1u+//imf1Irk2pIoQTi1r9jg+fY7L5R8k9Tn3/1qtKWQ0oymiqTbT9HppM6z5euBdaiU4qnM/XuEwhE68ASLf0bVm29an/wqVUvc5P//pr8H4BhCCOwxvDzu+lkXroq2/5L6v/SD0R/aDPgoGe3/bfc/20/tv5zT9ATkPKhyNpHSNJjWL4wRQae6hM8VD0EhyCtq7ZeNf6l31OeUCySN1omhOiEcJpQrhKKdUIUSd4yxhNFEo5lQqicTCffM51QZpI3BJuEaJYQRRMqdUI59o+9ZZwnRibMqESpyTsAD1gAfvjVei6WK1tuZL3793SSF6Xr2DH8hh5KYIxVU05eT16FNwTYxRr18Yqx2UaRvdp6Ln106fvlajF5PaGpkDSfSyKMtILJDGAeWqQ8p/Msz1JGGc55rrnLOdZKzI0VUjuiraYu90yxLH51kPKTkoM5AT+keijdGxBUH6MTbkDtpHVZFkEX9jNc/zTDGG9PdGvo8vnuezyaNjZ5KlOMHbXE5DnXKcnmPJtr4+bOYCIYnmdGE0VzZtRcckdMppjFAueZkng9bSIIN3IwaykEk2D8BtMkh6dJbpsmGU5TSqIVEdYym2kr6dwoLbAhxCpJpBFCYsU0ZhllmgqWE4LnKU0pyTHNUiPiaUpItpw9TXp4mvS2adLhNJUggvJc6DlR0joyn4t5RmVmlVAZSxXOqBJsnimtdGqEwJbPrbAiTx3Dws6jaVJM9QXTZIenyW6bJhtOc86IlTynnIoUZ6kQuSKEckopU9jkJsuEddYInGMpLBfKWZ3mBKu5yV1q3dZu6kumyQ9Pk982TT6cJpkTnlvD51SniucaYwdTwZRrrnNhudKcYYVtPk+zuTHzeepcPpep0emcMrEtm/SCaYrD0xS3TVPsYVpiDJ9brYTN5yqlVNOcppLYueCaYUOwcFRbK0ym59nc0XlOsnQuZG5tSpyJpsmNvGQ35eFpytumKfcYmDwnOU0VzRhWmZQESzF3VuU5TqlgQDwhxFHsuJFkzsmcMZprrCifS0N4NE2h+SXTVIenqW6bphpOM5sLqzCmNrUsNdYSnROFncSOmVxSlnJNhEulcVJS7kTOqXLp3GQZY8bMdTRNySF7c/Y09eFp6tumqfcYlMzOM8kYx5mkUs+ltFRZa52iGAsxzx2Z08w5aY0g1HGaYi1zmrNcS250PE2GxSWyaQ5P09w2TTOcphZYY0dzotJUauGstk6neca0dWyeunSuhGCOcKOEdDl2AnNttDWKM2Xz2KAIQS+wm+SwF0Ru84LIHi/IYDpnOnOUs/k8Z87mLnPc6GyeU5XPsWQk48TgLGcqpyTFGGec5OAaUDJXm2kyI/Qe309JZszeaR72gshtXhDZ5wVRnTI9F5qnMs20pY5qaogxQjutOLE4l5mY59ZpJzlXmKUSwM8Zds7x2KAYTS7xgshhL4jc5gWRPV6QSKVRBmJlM5cyVQpr4ubKKeloPudEUscdkRw7inPMMGXU0ZTPLTZcqsigUMLYPk0rleZ7p3nYCyK3eUFkjxdkSOpUmhqVKUGlmFOcESOZkFIQkhmsjRbUEMvS1OaZdlwJifMUp5I5nLNIBXFGOBn67qC59m/nYTeI3OYGkT1ukAR/dj7n4CAwmeVzyqSUaWatZrnGREtNrDSaUZOJ1FInDWfS5pLmWBtlN9tJFdsjnFIxLfZO87AbRG5zg8geNwjPmaZzBvGIpNq6OU65lSlmVnCirUupY4rTucsyOtfU4XRucmYzNbc5zlXsBhGjLrAo5LAbRG5zg8geN0g7l+LMOJxjYYxUVNC5Zjw1UuQM3Abh5mSO87nUc2dSaqkRwS3kwhC2mSaTTO6ZpuIG7xfOw24Quc0NInvcIKFTqbUTTJO5FSl1GiaKtbCZVKnjmvLMMErT3ArOJU6lIM4ZzjmdE67ohmml5vtUrdZ0/zQPu0HkNjeI7HGDlM60ltZk2hBBFU7nnHDHMbNzwVJtOaE4NfNc5dSkVHBpjRTU0UwykZKcracpMDZ86O0ZReh+w3nYDSK3uUFkjxvEskwTkvJMcmbAaM6VZvM0FfNMGYIN5YxgZlWqs0xbyKPMM2GzPKMpVrlRG6bVku3ZTSUxY/umSQ+7QfQ2N4jucYP4HNvMCiMwZRysC5PKKsFym4pMCKuVtoa6DDNhZK7lnGeMC5KpfJ5TjaMQRWnDL/AP6GE3iN7mBtE9blBmTU7zLCXKzB3PFQNLwnO
Line truncated
|
||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcYendjZyIkFO6XmtgPdrm77Z32tNfltx0bHRUTEAlmsosicygqy+WO+u8bByAlSNRdcrkrm/PB0yUkyQPg3M+Dg39MlumjW9i/umZZ1NXkNZlOlvWqSd3k9T8mqV082eKh+t5WRe6W7eT1JFtVD66uZk/FkyuLyr3qf+j/Fv19WVeT6cRVz0VTVwtXtT/Y9L19cMvJ679Nlk366sEu3KsHV7nGti57lRfP7ofSfnTNN+Fd3y2e6qZd9m/a88jCVnbZ1s3iq+XSrd/vH0j+rXXwvG0+Jrkty7lN3//75B0QlNarqnXNG9vasn6YvD765h9XVVssXPfHPS32V9fU7eObunFv6sWigCXhmclIxudCUeY0MalImcG5TC0hSlHNsnk+145tP/7Wr/Kf6/T9fkL2/ul6SZ5cWa7n8Y+JXaauChs4QejVn21WvF9+/8P/+4esaOvmVVoWrmpn6appXNW+so2zM0FfPdk2fZx98+qbr/9YlG75xv/Vq7fwbpTNU9jEpzp9DO/81tnSNd/bava2rRv74F71LDD7UDfvez5YvvLPzMInD77606fpJK2fHbxo8vpv/+j56rsMVuPRZvWH3FYPs/fOPU2mk4V9ghHGom1cTl4r+GdbtAXwFgPWbW1bpG+f7IdqOXlNsJlOmtouiuoBeGQ5eU35hr/73Ujrxk0+TXdoAB74UFTZbNnW6XubuYgMvk2GiMkgAzIM26UCn0lE5my2KCq3jL4tjywBpYMlUMMlYGd+/YMtyqJ6mKX22TVVRAPf2Qa9RQPepYEKukuDOpOExv5aN7mrZu8buyojCtQxRtBnrAKhZ5IwL236PrftY72YZc49xeugt6mQW+ugBuuAyYCKczlhVWZ2YavNtxW+aAUYplevwENVL1zjHuLPm53P8+OSyNRg6oycK4mr6r2rZqDVy0gIvXAfJoENVp+LXRL4pVyY1R9iOSDUHGMAMWBDwwebcK4wLlPblK6dLWqwT675GJGhzRF9xAZ8yIDrdsiQZ5Lx66rMbdPY95vPU3zs85ScoQ4Iv0QYmzp9P1s+FU3EDXR3K8gWFWogEELKgWKkF1ORuaf2MeIIynbkgrCYDLnHRPHhZoizeeKxLuuFrdJ4JXa5QZ/QDKCoriWgbUAxlovYOO7uhIm/LwbWgbOBaoA3nPX9hW3sKqur2DQfnf1AKwg60AoUn60WHlxeNG6WPtrlIqJBH9NMQ/+ADHfgXHnI4POLbdM4kAS6JQkDraTEQBLEuRuwfLRt6xqXzR5tWUaCIPiOIGzJARv6KUZfrRrnZV1ns3zVVDYWBMHpERLIQC0SZq4m4dGVpWeFBqKgpt1aiR1vaWs7yGAltLjaUC9bZxfPdlW28efFZauwRx2c662tqsw18/oh/ro8Igp73FVJrnZXl6V9drMnV20tvjoye7pHFAe2iZ27+rZJbdvYX6OvC3ps9ub01p8rh/O6tVWR2vjb7JhVNkMXmVzN/QuXPtrKNvHX+UVcp/nVLkEIFmelnX9siqp9jIkQlzGfvl4BLF376N6/H6hBIY8Jv9zjDlwdsULmYtbWi/nW94/xv1/j7e8Tc7UzYFfpo6tsMUubj09bGlDoY4vAT8fLTJ29Bg8FeMfLWeuaZtsc6KOKcBgz8oEilJf4h7NF3dhl7BtKc4SAPeboaq/ALVP75GZ5AyHrqqke6zJOXwh5zDYPVQPTww05l5RV+2CbzO3kcYQ6ph72SCah1xuljoKnoqpsGkeQQh3TD+coqXNXoX10s8r9soqFQsljseNAM1B2vYqEz9fpqtz+vj42+XO24FzDmK5Kn0eq89m+gEGYo2p6mE4iV+fTvHYOdNTVFgnmIk5Q6movJWvs+/bRNnW1IxMS44uslTFXB/H217/X5axyzWoef58c04+DyEnha/lhvQtl8fDYVkX1EFNBL2KGfWx5bmLpYVVlW5kMiY8ldocf1+rqfN5zUUM2BzRz/H19ZAsGgZu5VgzsY/XePrp2VpfZ7H1RPWR1FL5KcjSfM/QZGLvaZ8jr5sF5gaxXsd8kGT3mMgyYkQ+cV32ubmwKWwIFoCTTR7t4KuJ8ghT4yGIMtkRcuyVPReuXARRD/PVjDMGG0iD11cHjWiobl5cubbeXQR514YaEXK2i26J675rWpY+z5WP9ATKuGzI03dFRJCZjyJnyGBVPTZ26bNXYctbXCYf82dS/umrWOJs+xslv/ZnJqJuHum1dNYNqUJT5Vfq3XpB300nm8qIqgCGWvlQ3rBgX+6t2bdGW8OK365HkPwf1vDJ/6Mu+GbxfQ6n2lyfblzPT0i6XRbpdUf6+zjwjZe3s8eO8KUCJZkWeF+mqbD/+1TaFrdpAraetqpuFLbf+KP51CevzvV2+97Mp3bMrf7SVr0wSPWX4XbcMkFr5Zv2G77bebJ9tUdp5URb+3d0/vb/51Lilq2AnYEb/mJR12v3vPUvTProFzO0vT66CymqZJe6XJ5cVnUQuV4uFbeATfwTzmbSPTb16eEx23jRNMpc72yZFu0zWHJI0NYRH08RWWQK7lLSPLoGM1cfElsWzQ5PppKxtVlQP37vl0ldnJz807sk2RfWw+xGE4O9XVfpom9ZlXzXO/pf11P/v/rdkOENgrIemXlWwfv8La5xh2N48lLLTuqwbP2AJJXgynVTONqHwmcP/oBJ/mk7sYg4l5T/DGmw9ZvWcG/BriqqFmjjsBkaUfppOHt2iWD49usatH1u+//imf1Irk2pIoQTi1r9jg+fY7L5R8k9Tn3/1qtKWQ0oymiqTbT9HppM6z5euBdaiU4qnM/XuEwhE68ASLf0bVm29an/wqVUvc5P//pr8H4BhCCOwxvDzu+lkXroq2/5L6v/SD0R/aDPgoGe3/bfc/20/tv5zT9ATkPKhyNpHSNJjWL4wRQae6hM8VD0EhyCtq7ZeNf6l31OeUCySN1omhOiEcJpQrhKKdUIUSd4yxhNFEo5lQqicTCffM51QZpI3BJuEaJYQRRMqdUI59o+9ZZwnRibMqESpyTsAD1gAfvjVei6WK1tuZL3793SSF6Xr2DH8hh5KYIxVU05eT16FNwTYxRr18Yqx2UaRvdp6Ln106fvlajF5PZGpNLlihmmWp0bnnOOcaKK0SHlOVapyZtI8nROdGavnwqaaakolxQrPcyVAjotf3eS11pgpzqfeD6keSvcGBNXH6ERLUDtpXZZF0IX9DNc/zTDG2xPdGrp8vvsej6aNTZ7KFGNHLTF5znVKsjnP5tq4uTOYCIbnmdFE0ZwZNZfcEZMpZrHAeaYkXk+bCMKNHMxaCsEkGL/BNMnhaZLbpkn27K4kWhFhLbOZtpLOjdICG0KskkQaISRWTGOWUaapYDkheJ7SlJIc0yw1Ip6mhGTL2dOkh6dJb5smHU5TCSIoz4WeEyWtI/O5mGdUZlYJlbFU4YwqweaZ0kqnRghs+dwKK/LUMSzsPJomxVRfME12eJrstmmy4TTnjFjJc8qpSHGWCpErQiinlDKFTW6yTFhnjcA5lsJyoZzVaU6wmpvcpdZt7aa+ZJr88DT5bdPkw2mSOeG5NXxOdap4rjF2MBVMueY6F5YrzRlW2ObzNJsbM5+nzuVzmRqdzikT27JJL5imODxNcds0xR6mJcbwudVK2HyuUko1zWkqiZ0Lrhk2BAtHtbXCZHqezR2d5yRL50Lm1qbEmWia3MhLdlMenqa8bZpyOE2a5ySnqaIZwyqTkmAp5s6qPMcpFQyIJ4Q4ih03ksw5mTNGc40V5XNpCI+mKTS/ZJrq8DTVbdNUw2lmc2EVxtSmlqXGWqJzorCT2DGTS8pSrolwqTROSsqdyDlVLp2bLGPMmLmOpik55hdMUx+epr5tmnqPQcnsPJOMcZxJKvVcSkuVtdYpirEQ89yROc2ck9YIQh2nKdYypznLteRGx9NkWFwim+bwNM1t0zTDaWqBNXY0JypNpRbOaut0mmdMW8fmqUvnSgjmCDdKSJdjJzDXRlujOFM2jw2KEPQCu0kOe0HkNi+I7PGCDKZzpjNHOZvPc+Zs7jLHjc7mOVX5HEtGMk4MznKmckpSjHHGSQ6uASVztZkmM0LjoWwqyYzZO83DXhC5zQsi+7wgqlOm50LzVKaZttRRTQ0xRminFScW5zIT89w67STnCrNUAvg5w845HhsUo8klXhA57AWR27wgsscLEqk0ykCsbOZSpkphTdxcOSUdzeecSOq4I5JjR3GOGaaMOpryucWGSxUZFEoY26dppdJ87zQPe0HkNi+I7PGCDEmdSlOjMiWoFHOKM2IkE1IKQjKDtdGCGmJZmto8044rIXGe4lQyh3MWqSDOCCdD3x001/7tPOwGkdvcILLHDZLgz87nHBwEJrN8TpmUMs2s1SzXEFRpYqXRjJpMpJY6aTiTNpc0x9oou9lOqtge4ZSKabF3mofdIHKbG0T2uEF4zjSdM4hHJNXWzXHKrUwxs4ITbV1KHVOczl2W0bmmDqdzkzObqbnNca5iN4gYdYFFIYfdIHKbG0T2uEHauRRnxuEcC2OkooLONeOpkSJn4DYINydznM+lnjuTUkuNCG4hF4awzTSZZHLPNBU3eL9wHnaDyG1uENnjBgmdSq2dYJrMrUip0zBRrIXNpEod15RnhlGa5lZwLnEqBXHOcM7pnHBFN0wrNd+narWm+6d52A0it7lBZI8bpHSmtbQm04YIqnA654Q7jpmdC5ZqywnFqZnnKqcmpYJLa6SgjmaSiZTkbD1NgbHZkzQxitD9hvOwG0Ruc4PIHjeIZZkmJOWZ5MyA0ZwrzeZpKuaZMgQbyhnBzKpUZ5m2kEeZZ8JmeUZTrHKjNkyrJduzm0pixvZNkx52g+htbhDd4wbxObaZFUZgyjhYFyaVVYLlNhWZEFYrbQ11GWbCyFzLOc8YFyRT+TynGkchitKGX+Af0MNuEL3NDaJ73KDMmpzmWUqUmTueKwaWhOc
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user