Files
healer-man/src/game/combatStore.ts
T

10237 lines
413 KiB
TypeScript

import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
import {
baseClassFor,
isRomClassId,
type ClassId,
type RaceId,
} from "../app/characterCatalog";
import {
abilitiesForClass,
abilitiesForCharacter,
abilitiesUnlockedBetweenLevels,
abilityAtLevel,
abilityById,
abilityRankForLevel,
abilityTalentNodeId,
defaultActionBarForClass,
defaultActionBarForCharacter,
isAbilityUnlocked,
learnedAbilityForBuild,
learnedAbilityRank,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityAmountScaling,
type AbilityDamageResource,
type AbilityDefinition,
type AbilityEffect,
type MobStatusKind,
type ResourceType,
} from "./abilityCatalog";
import {
abilityPushbackResistance,
abilityRequiresOutOfCombat,
canAbilityCastWhileMoving,
runeCostForAbility,
targetingRulesForAbility,
} from "./abilityRules";
import {
combatHasLineOfSight,
combatSpatialActor,
isPositionInFacingArc,
isSourceBehindTarget,
projectCombatGroundPoint,
} from "./combatSpatial";
import {
appendCombatEvent,
type CombatEventInput,
type StructuredCombatEvent,
} from "./combatEvents";
import {
createDeathKnightRunes,
normalizeActorResourcePool,
type CombatActorState,
type DeathKnightRuneState,
} from "./combatActors";
import {
companionActorFromMember,
companionActorWithMemberRuntime,
memberFromCompanionActor,
} from "./combatActorAuthority";
import {
activateDeathKnightRunes,
advanceCombatResourcePool,
spendDeathKnightRunes,
} from "./combatResources";
import { cooldownResetCandidates } from "./cooldownReset";
import { resolveHealing, type HealingResolution } from "./combatHealing";
import {
awardExperience,
healerManExperienceAward,
normalizeProgression,
playerCombatPower,
xpRequiredForNextLevel,
} from "./progression";
import {
runtimeAllocateTalentPoint as allocateTalentPoint,
runtimeNormalizeTalentRanks as normalizeTalentRanks,
runtimeResetTalentRanks as resetTalentRanks,
runtimeTalentNodeById as talentNodeById,
runtimeTalentPointsForLevel as talentPointsForLevel,
type RuntimeTalentRanks as TalentRanks,
} from "./talentRuntimeCatalog";
import {
applyTalentModifierValue,
parseTalentModifiers,
talentModifiersForRanks,
type TalentModifier,
type TalentModifierContext,
} from "./talentModifiers";
import {
getMobPosition,
getMobRuntimeTransform,
mobRuntimeDistanceSquared,
nearbyMobRuntimeIds,
removeMobPosition,
setMobPosition,
type CombatPosition,
} from "./mobRuntimeRegistry";
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
import {
getPartyRuntimePosition,
partyRuntimeDistanceSquared,
updatePartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import {
actionBindingId,
defaultActionBindings,
normalizeActionBindings,
resetActionBindingLayer as restoreActionBindingLayer,
withActionBinding,
type ActionBindingControl,
type ActionBindingLayer,
type ActionBindings,
} from "./actionBindings";
import type {
CharacterCombatAnimationEvent,
CharacterCombatAnimationKind,
} from "./combatAnimation";
import {
absorbDamage as absorbAuraDamage,
applyAura as applyCombatAura,
dispelAuras as dispelCombatAuras,
evaluateAuraProcs,
expireAuras,
resolveDamageValue as resolveAuraDamageValue,
resolveHealingValue as resolveAuraHealingValue,
resolveResourceValue as resolveAuraResourceValue,
resolveStatValue as resolveAuraStatValue,
type ActiveAura,
type AuraDefinition,
type DispelCategory,
type ProcEvent,
type TriggeredAuraProc,
} from "./combatAuras";
import { rollBossLoot } from "./lootCatalog";
import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
equipInventoryItem,
normalizeEquipment,
unequipInventorySlot,
type EquipmentAssignments,
type EquipmentSlot,
} from "./equipment";
import {
resolvedEquipmentLoadout,
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset, romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
import { romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import {
EMPTY_ITEM_STATS,
itemStatSummary,
type DamageSchool,
type ItemStats,
} from "./itemStats";
import {
baseResourcesFor,
damageAfterEquipmentMitigation,
deriveCharacterStats,
equipmentAttackPower,
hastedDuration,
healthFromTotalStamina,
ratingPercent,
type CharacterStats,
type WotlkAttackKind,
} from "./wotlkStats";
import { healingRecipientIds } from "./healingAbilityRules";
import {
ROGUE_DEADLY_POISON_AURA,
ROGUE_DEADLY_POISON_COATING_TAG,
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
ROGUE_INSTANT_POISON_TAG,
hasActiveAuraTag,
matchingAuraStacks,
rogueDeadlyPoisonTickDamage,
rogueEnvenomPreservesDeadlyPoison,
rogueInstantPoisonDamage,
roguePoisonApplicationChances,
} from "./roguePoison";
import {
baseMissChancePercent,
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
resolveCombatResult,
type CombatAttackerStats,
type CombatDefenderStats,
type CombatResult,
type CombatResultRules,
} from "./combatResults";
import {
DEFAULT_ENEMY_ATTACK,
enemyControlPreventsActions,
maximumEnemyAttackRange,
normalizeEnemyAttacks,
type EnemyAttackAnimation,
type EnemyAttackDefinition,
type EnemyControlMechanic,
type EnemyDamageSchool,
type EnemyMeleeAutoCriticalProc,
} from "./enemyAttacks";
import {
creatureRuntimeBaseStats,
creatureTemplateReference,
type AzerothCoreMechanic,
type AzerothCoreSchool,
} from "./azerothCoreReference";
import {
dispatchDungeonEncounterSignal,
dungeonEncounterStateMatches,
} from "./dungeonEncounterRuntime";
import {
PLAYER_AGGRO_ID,
addActorThreat,
pruneThreatTable,
resolveAggroTarget,
seedActorThreat,
tauntThreat,
threatFromDamage,
threatFromHealing,
type AggroActorId,
type ForcedTarget,
type ThreatSource,
type ThreatTable,
} from "./aggro";
import { distanceSquared, planarDistanceSquared } from "./mobAi";
import {
onlinePlayerRuntimeActor,
onlinePlayerRuntimeActors,
} from "./onlineSessionRuntime";
import {
abilityIsPrecombatPreparation,
precombatPreparationEffects,
rotationProfileFor,
} from "./combatRotation";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
presentationSchoolForAbility,
presentationSourceForAbility,
presentationSourceForSpellId,
resetCombatPresentationSession,
type CombatPresentationPhase,
} from "./combatPresentation";
/** Stable target id used by named player auras and HUD consumers. */
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
const WEAKENED_SOUL_AURA: AuraDefinition = {
id: "wow335:priest:weakened-soul", name: "Weakened Soul", disposition: "debuff",
durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh", dispelCategory: "magic",
};
function hasWeakenedSoul(auras: readonly ActiveAura[], targetId: string, now: number): boolean {
return auras.some((aura) => aura.targetId === targetId
&& aura.definition.id === WEAKENED_SOUL_AURA.id
&& (aura.expiresAt === null || aura.expiresAt > now));
}
function consumableEnrage(aura: ActiveAura, targetId: string, now: number): boolean {
return aura.targetId === targetId && aura.definition.disposition === "buff"
&& aura.definition.dispelCategory === "enrage"
&& !aura.definition.name.startsWith("Enraged Regeneration")
&& (aura.expiresAt === null || aura.expiresAt > now);
}
import {
DEFAULT_THREAT_METER_POSITION,
normalizeHudPosition,
type NormalizedHudPosition,
} from "./hudLayout";
import {
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
normalizeGameplayFrameRateLimit,
normalizePreviewFrameRateLimit,
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "./frameRateSettings";
export type MinimapRotation = "north-up" | "player-up";
export interface GameplaySettings {
readonly showMobHealthBars: boolean;
readonly showMobAggroRanges: boolean;
readonly showTargetFrame: boolean;
readonly showThreatMeter: boolean;
readonly threatMeterPosition: NormalizedHudPosition;
readonly showFloatingCombatText: boolean;
readonly autoTargetNearest: boolean;
readonly enableScreenShake: boolean;
readonly masterVolume: number;
readonly uiScale: number;
readonly minimapRotation: MinimapRotation;
readonly gameplayFrameRate: GameplayFrameRateLimit;
readonly previewFrameRate: PreviewFrameRateLimit;
}
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showMobHealthBars: true,
showMobAggroRanges: false,
showTargetFrame: true,
showThreatMeter: false,
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
showFloatingCombatText: true,
autoTargetNearest: true,
enableScreenShake: true,
masterVolume: 0,
uiScale: 1,
minimapRotation: "player-up",
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
});
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
/** Empty corpses remain visible long enough to read the death, then clean themselves up. */
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
export interface MobStatus {
readonly kind: MobStatusKind;
readonly sourceAbilityId: string;
readonly sourceActorId?: string;
readonly endsAt: number;
readonly magnitude?: number;
readonly breakOnDamage?: boolean;
}
export interface MobAura {
readonly id: string;
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: number;
readonly lifeStealPercent?: number;
/** Persistent driver for a content-authored main-hand auto-attack proc. */
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
/** A charged modifier consumed by a successful main-hand auto attack. */
readonly nextMeleeCriticalChancePercent?: number;
readonly charges?: number;
}
export interface MobMagicResistances {
readonly arcane: number;
readonly fire: number;
readonly frost: number;
readonly nature: number;
readonly shadow: number;
}
/** Public enemy cast state used by combat logic now and cast-bar UI later. */
export interface EnemyActiveCast {
readonly attackId: string;
readonly name: string;
readonly spellId?: number;
readonly school: EnemyDamageSchool;
readonly interruptible: boolean;
readonly targetActorId: AggroActorId | null;
readonly sourcePosition: CombatPosition;
readonly targetPosition: CombatPosition;
readonly sourceYaw: number;
readonly startedAt: number;
readonly completesAt: number;
}
export interface DiminishingReturnState {
readonly applications: number;
readonly resetAt: number;
}
export interface MobCombatState {
/** When the hostile actor is a player, preserve its actual defense and resources. */
readonly characterDefender?: CombatDefenderStats;
readonly characterShield?: number;
readonly characterResources?: Readonly<Partial<Record<ResourceType, number>>>;
readonly id: string;
readonly name: string;
readonly serverEntry: number | null;
readonly creatureFamily: CombatCreatureFamily;
readonly summonedBy?: string;
readonly spawnGroupId?: string;
readonly boss: boolean;
readonly lootSourceId: string | null;
readonly level: number;
readonly armor: number;
readonly maxMana: number;
readonly attackPower: number;
readonly mechanicImmunities: readonly AzerothCoreMechanic[];
readonly schoolImmunities: readonly AzerothCoreSchool[];
readonly resistances: MobMagicResistances;
readonly health: number;
readonly maxHealth: number;
readonly healthMultiplier: number;
readonly damageMultiplier: number;
readonly bonusLootChance: number;
readonly dead: boolean;
readonly hasLoot: boolean;
readonly pendingLoot: readonly InventoryItem[];
readonly bossCardDrop?: BossCardDrop;
readonly looted: boolean;
readonly despawned: boolean;
readonly diedAt: number | null;
readonly corpseDespawnAt: number | null;
readonly xpReward: number;
readonly experienceGranted: boolean;
readonly statuses: readonly MobStatus[];
readonly controlDiminishingReturns?: Readonly<Partial<Record<MobStatusKind, DiminishingReturnState>>>;
readonly tauntDiminishingReturns?: DiminishingReturnState;
readonly auras: readonly MobAura[];
readonly threatByActor: ThreatTable;
readonly targetActorId: AggroActorId | null;
readonly forcedTarget: ForcedTarget | null;
readonly lastDamageAt: number;
readonly engaged: boolean;
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
readonly navigationEvadeUntil?: number;
readonly homePosition: CombatPosition | null;
readonly nextAttackAt: number;
readonly abilityReadyAt: Readonly<Record<string, number>>;
readonly abilityCastCounts: Readonly<Record<string, number>>;
readonly activeCast: EnemyActiveCast | null;
readonly schoolLockedUntil: Readonly<Partial<Record<EnemyDamageSchool, number>>>;
readonly attackRange: number;
readonly attackDamage: number;
readonly swingMs: number;
readonly aggroRange: number;
readonly leashRange: number;
readonly moveSpeed: number;
readonly attackRevision: number;
readonly attackAnimation: EnemyAttackAnimation;
readonly attacks: readonly EnemyAttackDefinition[];
readonly nextAttackIndex: number;
readonly lastAttackId: string | null;
readonly woundRevision: number;
readonly nextWoundAnimationAt: number;
readonly deathRevision: number;
}
export interface ActiveCast {
readonly id: number;
readonly abilityId: string;
readonly targetId: string | null;
readonly mode: "cast" | "channel";
readonly startedAt: number;
readonly completesAt: number;
readonly originalDurationMs: number;
readonly pushbackHits: number;
readonly school: DamageSchool;
readonly canCastWhileMoving: boolean;
readonly origin: CombatPosition;
readonly groundPosition: CombatPosition | null;
readonly totalTicks: number;
readonly ticksCompleted: number;
readonly nextTickAt: number;
}
export interface TimedCombatEffect extends AbilityDamageResource {
readonly id: string;
readonly kind: "dot" | "hot";
readonly sourceAbilityId: string;
readonly sourceActorId: AggroActorId;
readonly targetId: string | null;
readonly coefficient: number;
/** Pre-scaled amount for non-player actors whose equipment is not the player loadout. */
readonly fixedAmount?: number;
readonly basePoints?: number;
readonly dieSides?: number;
readonly pointsPerLevel?: number;
readonly coefficientPerLevel?: number;
readonly bonusPowerCoefficient?: number;
readonly attackPowerCoefficient?: number;
readonly spiritCoefficient?: number;
readonly sourceLevel?: number;
readonly maxScalingLevel?: number;
readonly remainingTicks: number;
readonly intervalMs: number;
readonly nextTickAt: number;
readonly damageSchool?: DamageSchool;
readonly spellPenetration?: number;
readonly tag?: string;
}
export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot";
export interface CombatFeedback {
readonly id: number;
readonly kind: CombatFeedbackKind;
readonly message: string;
readonly abilityId?: string;
readonly targetId?: string;
readonly amount?: number;
}
export interface BossLootReward {
readonly id: number;
readonly bossId: string;
readonly bossName: string;
readonly items: readonly InventoryItem[];
readonly cards?: BossCardDrop;
readonly acquiredAt: number;
}
export type CastFailureReason =
| "unknown-ability"
| "wrong-class"
| "not-learned"
| "passive"
| "unsupported"
| "level-locked"
| "player-dead"
| "player-controlled"
| "already-casting"
| "global-cooldown"
| "cooldown"
| "not-enough-resource"
| "not-enough-runes"
| "condition-not-met"
| "no-target"
| "target-dead"
| "target-unavailable"
| "line-of-sight"
| "not-facing"
| "must-be-behind"
| "invalid-destination"
| "school-locked"
| "too-close"
| "out-of-range";
export interface CastRequest {
readonly origin?: CombatPosition;
readonly now?: number;
readonly targetActorId?: string | null;
readonly groundPosition?: CombatPosition;
readonly completingCast?: ActiveCast;
}
export interface CastResult {
readonly ok: boolean;
readonly abilityId?: string;
readonly reason?: CastFailureReason;
readonly affectedMobIds: readonly string[];
readonly damage: number;
readonly healing: number;
readonly levelsGained: number;
}
export interface ActorHealingRequest {
readonly sourceActorId: string;
readonly abilityId?: string;
readonly school?: DamageSchool;
readonly criticalChance?: number;
readonly criticalMultiplier?: number;
readonly seed?: string;
readonly now?: number;
readonly amountAlreadyModified?: boolean;
}
export interface CharacterCombatSeed {
readonly classId: ClassId;
readonly specializationId?: string | null;
readonly role?: PartyRole;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly level?: number;
readonly xp?: number;
/** Backward-compatible profile field accepted by the integration bridge. */
readonly experience?: number;
readonly talentRanks?: TalentRanks;
readonly actionBindings?: unknown;
readonly inventory?: unknown;
readonly bossCards?: unknown;
readonly equipment?: unknown;
readonly settings?: Partial<GameplaySettings>;
}
export interface MobRegistration {
readonly name: string;
readonly serverEntry?: number;
readonly creatureFamily?: CombatCreatureFamily;
readonly boss?: boolean;
readonly level?: number;
readonly resistances?: Partial<MobMagicResistances>;
readonly maxHealth?: number;
readonly xpReward?: number;
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
readonly bonusLootChance?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
readonly attacks?: readonly EnemyAttackDefinition[];
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
readonly schoolImmunities?: readonly AzerothCoreSchool[];
readonly hasLoot?: boolean;
/** Stable SQL boss key, independent from this spawned mob's runtime id. */
readonly lootSourceId?: string;
}
export type TargetCycleDirection = "next" | "previous" | 1 | -1;
export interface DamageResult {
readonly amount: number;
readonly killed: boolean;
readonly xpAwarded: number;
readonly levelsGained: number;
readonly combatResult?: CombatResult;
}
export interface RoguePoisonProcResult {
readonly instantProcs: number;
readonly deadlyApplications: number;
readonly deadlyStacks: number;
readonly damage: number;
}
interface PlayerDamageRollContext {
readonly attackKind: WotlkAttackKind;
readonly seed: string;
readonly abilityId?: string;
readonly periodic?: boolean;
readonly rules?: CombatResultRules;
readonly criticalChanceBonusPercent?: number;
}
export interface CombatSummon {
readonly id: string;
readonly sourceAbilityId: string;
readonly creatureId: number;
readonly targetId: string | null;
readonly expiresAt: number;
readonly kind: "pet" | "totem" | "summon";
readonly name: string;
readonly ownerActorId: AggroActorId;
readonly health: number;
readonly maxHealth: number;
readonly position: CombatPosition;
readonly nextActionAt: number;
readonly pulseIntervalMs: number;
readonly attackDamage: number;
readonly abilityIds: readonly string[];
readonly resources: CombatActorState["resources"];
readonly cooldowns: CombatActorState["cooldowns"];
readonly globalCooldownEndsAt: number;
readonly cast: CombatActorState["cast"];
readonly controlledUntil: number;
readonly schoolLockedUntil: CombatActorState["schoolLockedUntil"];
readonly exclusiveGroup?: string;
}
export interface CombatAreaEffect {
readonly id: string;
readonly sourceActorId: AggroActorId;
readonly sourceAbilityId: string;
readonly position: CombatPosition;
readonly radius: number;
readonly createdAt: number;
readonly expiresAt: number;
readonly tickIntervalMs: number;
readonly nextTickAt: number;
readonly fixedDamagePerTick?: number;
readonly fixedHealingPerTick?: number;
}
export interface PendingGroundTarget {
readonly abilityId: string;
readonly maximumRange: number;
readonly radius: number;
readonly position: CombatPosition;
readonly valid: boolean;
}
export interface CombatState {
classId: ClassId;
specializationId: string | null;
gearRole: PartyRole;
secondaryClassId: ClassId | null;
raceId: RaceId;
level: number;
xp: number;
xpToNext: number;
health: number;
maxHealth: number;
controlledUntil: number;
controlMechanic: EnemyControlMechanic | null;
shield: number;
shieldExpiresAt: number;
resource: number;
maxResource: number;
resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
maxResourcePools: Readonly<Partial<Record<ResourceType, number>>>;
lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
resourceName: string;
comboPoints: number;
comboTargetId: string | null;
deathKnightRunes: readonly DeathKnightRuneState[];
abilities: readonly AbilityDefinition[];
cooldowns: Readonly<Record<string, number>>;
globalCooldownEndsAt: number;
activeCast: ActiveCast | null;
schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
playerAnimationEvent: CharacterCombatAnimationEvent | null;
autoAttackTargetId: string | null;
nextAutoAttackAt: number;
mobs: Readonly<Record<string, MobCombatState>>;
selectedTargetId: string | null;
selectedFriendlyActorId: string | null;
effects: readonly TimedCombatEffect[];
/** Named buffs/debuffs for the player, mobs, and party members. */
auras: readonly ActiveAura[];
summons: readonly CombatSummon[];
areaEffects: readonly CombatAreaEffect[];
pendingGroundTarget: PendingGroundTarget | null;
actors: Readonly<Record<string, CombatActorState>>;
combatEvents: readonly StructuredCombatEvent[];
feedback: CombatFeedback | null;
/** Most recent discrete attack result, retained for proc and combat-log consumers. */
lastCombatResult: CombatResult | null;
bossLootRewards: readonly BossLootReward[];
talentRanks: TalentRanks;
talentModifiers: readonly TalentModifier[];
actionBindings: ActionBindings;
inventory: readonly InventoryItem[];
bossCards: BossCardCollection;
collectSharedBossCards: (drop: BossCardDrop, bossName: string, now: number) => void;
equipment: EquipmentAssignments;
gearStats: ItemStats;
settings: GameplaySettings;
playerPosition: CombatPosition;
initializeCharacter: (profile: CharacterCombatSeed) => void;
refreshResolvedGear: () => void;
registerMob: (id: string, registration: MobRegistration) => void;
removeMob: (id: string) => void;
lootMob: (id: string, now?: number) => boolean;
dismissBossLootReward: (id?: number) => void;
equipItem: (instanceId: string, ownerId?: string) => boolean;
unequipItem: (ownerId: string, slot: EquipmentSlot) => boolean;
selectMob: (id: string) => boolean;
selectNearestTarget: (origin?: CombatPosition, maxRange?: number) => string | null;
cycleTarget: (direction?: TargetCycleDirection, origin?: CombatPosition, maxRange?: number) => string | null;
clearTarget: () => void;
selectFriendlyActor: (id: string | null) => boolean;
setPlayerPosition: (position: CombatPosition) => void;
castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
beginGroundTarget: (abilityId: string) => boolean;
moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void;
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
cancelGroundTarget: () => void;
synchronizeActors: () => void;
/** Grants learned long-term class stat buffs to the full party without setup casts. */
applyDefaultPartyBuffs: (now?: number) => void;
/** Applies specialization maintenance buffs and permanent pets before the first pull. */
applyPrecombatPreparation: (now?: number) => void;
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, origin?: CombatPosition, now?: number) => CastResult;
damageMob: (
id: string,
amount: number,
now?: number,
source?: ThreatSource,
school?: DamageSchool,
rollContext?: PlayerDamageRollContext,
) => DamageResult;
consumeActorAuraTag: (actorId: string, tag: string) => void;
consumeActorEnrage: (actorId: string, now?: number) => boolean;
triggerChimeraShot: (sourceActorId: string, targetId: string, abilityId: string, now?: number) => number;
triggerRogueWeaponPoisons: (
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount?: number,
now?: number,
) => RoguePoisonProcResult;
consumeRogueDeadlyPoison: (
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now?: number,
) => number;
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
applyActorThreatEffect: (
actorId: AggroActorId,
effect: Extract<AbilityEffect, { readonly kind: "threat" }>,
redirectTargetActorId?: AggroActorId | null,
now?: number,
) => void;
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean;
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string) => boolean;
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
restoreActorResource: (id: string, resource: ResourceType, amount: number, percentage?: boolean, sourceActorId?: string, abilityId?: string, now?: number) => number;
shieldActor: (id: string, amount: number, durationMs: number, sourceActorId: string, ability: AbilityDefinition, now?: number) => number;
applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "apply-aura" }>, now?: number) => boolean;
applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>, now?: number) => boolean;
summonActor: (ownerActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "summon" }>, position: CombatPosition, targetId?: string | null, now?: number) => string | null;
createActorAreaEffect: (sourceActorId: string, ability: AbilityDefinition, position: CombatPosition, now?: number) => string | null;
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
damageSummon: (id: string, amount: number) => number;
healPlayer: (amount: number) => number;
revivePlayer: (percentMaxHealth?: number) => number;
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
resetActionBindings: () => void;
updateSettings: (patch: Partial<GameplaySettings>) => void;
resetSettings: () => void;
}
interface MutableProgression {
level: number;
xp: number;
xpToNext: number;
health: number;
maxHealth: number;
levelsGained: number;
}
interface MutableCastWork {
classId: ClassId;
abilities: readonly AbilityDefinition[];
mobs: Record<string, MobCombatState>;
effects: TimedCombatEffect[];
auras: ActiveAura[];
summons: CombatSummon[];
areaEffects: CombatAreaEffect[];
combatEvents: StructuredCombatEvent[];
actors: Record<string, CombatActorState>;
cooldowns: Record<string, number>;
health: number;
maxHealth: number;
shield: number;
shieldExpiresAt: number;
resource: number;
maxResource: number;
resourcePools: Partial<Record<ResourceType, number>>;
maxResourcePools: Partial<Record<ResourceType, number>>;
lastResourceSpentAt: Partial<Record<ResourceType, number>>;
comboPoints: number;
comboTargetId: string | null;
deathKnightRunes: readonly DeathKnightRuneState[];
progression: MutableProgression;
totalDamage: number;
totalHealing: number;
combatResults: CombatResult[];
talentRanks: TalentRanks;
talentModifiers: readonly TalentModifier[];
gearStats: ItemStats;
raceId: RaceId;
secondaryClassId: ClassId | null;
/** Active synchronous spell/source/target keys used to break proc cycles. */
triggeredSpellStack?: string[];
triggeredProcKeys?: Set<string>;
}
let feedbackSequence = 0;
let bossLootRewardSequence = 0;
let effectSequence = 0;
let castSequence = 0;
let animationSequence = 0;
let summonSequence = 0;
let areaEffectSequence = 0;
let combatEventSequence = 0;
function initialSummonCombatRuntime(): Pick<CombatSummon,
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
> {
return {
abilityIds: [],
resources: {},
cooldowns: {},
globalCooldownEndsAt: 0,
cast: null,
controlledUntil: 0,
schoolLockedUntil: {},
};
}
function combatActorFromSummon(
summon: CombatSummon,
level: number,
auras: readonly ActiveAura[],
): CombatActorState {
return {
id: summon.id,
threatActorId: summon.id,
kind: summon.kind,
ownerId: summon.ownerActorId,
level,
health: summon.health,
maxHealth: summon.maxHealth,
absorb: entityAbsorbAmount(auras, summon.id),
resources: summon.resources,
cooldowns: summon.cooldowns,
globalCooldownEndsAt: summon.globalCooldownEndsAt,
cast: summon.cast,
controlledUntil: summon.controlledUntil,
schoolLockedUntil: summon.schoolLockedUntil,
comboPoints: 0,
comboTargetId: null,
runes: [],
abilityIds: summon.abilityIds,
alive: summon.health > 0,
};
}
function recordCombatEvent(
events: readonly StructuredCombatEvent[],
input: CombatEventInput,
): readonly StructuredCombatEvent[] {
combatEventSequence += 1;
return appendCombatEvent(events, input, combatEventSequence);
}
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
}
function nextFeedback(
kind: CombatFeedbackKind,
message: string,
details: Omit<CombatFeedback, "id" | "kind" | "message"> = {},
): CombatFeedback {
feedbackSequence += 1;
return { id: feedbackSequence, kind, message, ...details };
}
function nextAnimationEvent(
kind: CharacterCombatAnimationKind,
abilityId?: string,
): CharacterCombatAnimationEvent {
animationSequence += 1;
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
}
function emitPlayerAbilityPresentation(
ability: AbilityDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
instantCast = false,
): void {
const delivery = presentationDeliveryForAbility(ability);
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery,
origin,
targetPosition: targetPosition ?? origin,
...(ability.radius === undefined ? {} : { radius: ability.radius }),
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
}
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
const ability = abilityById(abilityId);
if (!ability) return;
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
}
function emitPlayerAbilityResolutionPresentation(
ability: AbilityDefinition,
occurredAt: number,
origin: CombatPosition,
targetId: string | null,
targetPosition: CombatPosition | null,
instantCast: boolean,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
occurredAt,
origin,
targetId,
targetPosition,
instantCast,
);
}
function emitEnemyAttackPresentation(
mob: MobCombatState,
attack: EnemyAttackDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
instantCast = false,
): void {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "enemy",
sourceActorId: mob.id,
...(targetActorId ? { targetActorId } : {}),
abilityId: attack.id,
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
name: attack.name,
source: presentationSourceForSpellId(attack.spellId),
school: attack.school,
delivery: attack.delivery,
origin,
targetPosition: targetPosition ?? origin,
...(attack.radius === undefined ? {} : { radius: attack.radius }),
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
...(instantCast ? { instantCast: true } : {}),
});
}
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
const ROM_RAGE_CLASSES = new Set<ClassId>(["rom-warrior", "rom-champion"]);
const RAGE_PER_AUTO_ATTACK = 6;
const RAGE_PER_HIT_TAKEN = 7;
const ROM_CHAMPION_RAGE_PER_SECOND = 6;
const PLAYER_AUTO_ATTACK_RANGE = 5;
function hasRomClassPool(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
resourceClassId: ClassId,
): boolean {
return classId === resourceClassId || secondaryClassId === resourceClassId;
}
function hasRomRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
return ROM_RAGE_CLASSES.has(classId)
|| Boolean(secondaryClassId && ROM_RAGE_CLASSES.has(secondaryClassId));
}
function hasRomWarriorRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
return hasRomClassPool(classId, secondaryClassId, "rom-warrior");
}
function hasRomChampionRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
return hasRomClassPool(classId, secondaryClassId, "rom-champion");
}
function gainRomRage(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
amount: number,
resource: number,
maxResource: number,
resourcePools: Partial<Record<ResourceType, number>>,
maxResourcePools: Partial<Record<ResourceType, number>>,
): { resource: number; resourcePools: Partial<Record<ResourceType, number>> } {
const activeType = resourceProfileForClass(classId).type;
const hasRage = hasRomRagePool(classId, secondaryClassId)
|| activeType === "rage"
|| maxResourcePools.rage !== undefined;
if (!hasRage || amount <= 0) return { resource, resourcePools };
const maximum = maxResourcePools.rage ?? (activeType === "rage" ? maxResource : 100);
const rage = clamp((resourcePools.rage ?? (activeType === "rage" ? resource : 0)) + amount, 0, maximum);
return {
resource: activeType === "rage" ? rage : resource,
resourcePools: { ...resourcePools, rage },
};
}
function playerAutoAttackProfile(
state: Pick<CombatState, "classId" | "secondaryClassId" | "raceId" | "level" | "gearStats">,
): { damage: number; intervalMs: number; hasteMultiplier: number } {
const stats = deriveCharacterStats(
state.classId,
state.level,
state.gearStats,
state.raceId,
{},
state.secondaryClassId,
);
const weapon = state.gearStats.meleeWeapon;
const speedMs = weapon?.speedMs ?? 2_400;
const speedSeconds = speedMs / 1_000;
const weaponDamage = weapon
? (weapon.damageMin + weapon.damageMax) / 2
: playerCombatPower(state.level) * 0.35;
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
return {
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
intervalMs,
hasteMultiplier: speedMs / intervalMs,
};
}
function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext {
const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase();
const tags = new Set<string>();
tags.add(ability.castTimeMs > 0 ? "cast-time" : "instant");
if (periodic) tags.add("periodic");
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot")) tags.add("damage");
if (ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield")) tags.add("healing");
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
if (searchable.includes(school)) tags.add(school);
}
return { abilityName: ability.name, tags: [...tags] };
}
function playerThreatSource(
state: Pick<CombatState, "talentModifiers">,
abilityId?: string,
periodic = false,
): ThreatSource {
const ability = abilityId ? abilityById(abilityId) : null;
const threatScale = ability
? applyTalentModifierValue(
1,
state.talentModifiers,
"threat-percent",
abilityTalentContext(ability, periodic),
)
: 1;
return {
actorId: PLAYER_AGGRO_ID,
role: usePartyStore.getState().playerRole,
...(abilityId ? { abilityId } : {}),
threatScale,
};
}
function threatSourceForActor(
state: Pick<CombatState, "talentModifiers">,
actorId: AggroActorId,
abilityId?: string,
periodic = false,
): ThreatSource {
if (actorId === PLAYER_AGGRO_ID) return playerThreatSource(state, abilityId, periodic);
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
return {
actorId,
role: member?.role ?? "damage",
...(abilityId ? { abilityId } : {}),
};
}
function actorPosition(state: Pick<CombatState, "playerPosition" | "summons">, actorId: AggroActorId): CombatPosition | null {
if (actorId === PLAYER_AGGRO_ID) return state.playerPosition;
return getPartyRuntimePosition(actorId)
?? state.summons.find((summon) => summon.id === actorId && summon.health > 0)?.position
?? null;
}
function eligibleAggroActorIds(
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
mob: Pick<MobCombatState, "homePosition" | "leashRange">,
): Set<AggroActorId> {
const eligible = new Set<AggroActorId>();
const home = mob.homePosition;
const withinLeash = (position: CombatPosition | null) => {
if (!position) return false;
if (!home) return true;
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
};
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
for (const member of usePartyStore.getState().members) {
if (member.health > 0 && withinLeash(getPartyRuntimePosition(member.id))) eligible.add(member.id);
}
for (const actor of onlinePlayerRuntimeActors()) {
if (actor.health > 0 && withinLeash(getPartyRuntimePosition(actor.id))) eligible.add(actor.id);
}
for (const summon of state.summons) {
if (summon.health > 0 && withinLeash(summon.position)) eligible.add(summon.id);
}
return eligible;
}
function addHealingThreatInWork(
work: MutableCastWork,
source: ThreatSource,
effectiveHealing: number,
): void {
const engagedIds = Object.values(work.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.map((mob) => mob.id);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
* (source.threatScale ?? 1)
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat");
if (perMob <= 0) return;
for (const id of engagedIds) {
const mob = work.mobs[id];
work.mobs[id] = {
...mob,
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
};
}
}
function talentAdjustedAmount(
baseValue: number,
modifiers: readonly TalentModifier[],
kind: "damage-percent" | "healing-percent",
ability: AbilityDefinition,
periodic = false,
): number {
const context = abilityTalentContext(ability, periodic);
const scalar = applyTalentModifierValue(
baseValue,
modifiers,
kind,
context,
);
return Math.max(0, Math.round(scalar));
}
function talentAdjustedMaxHealth(
classId: ClassId,
level: number,
modifiers: readonly TalentModifier[],
gearStats: ItemStats = EMPTY_ITEM_STATS,
raceId: RaceId = "human",
secondaryClassId?: ClassId | null,
): number {
const playerModifiers = modifiers.filter((modifier) => (
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
));
const staminaModifiers = playerModifiers.filter((modifier) => (
modifier.kind !== "attribute-percent" || modifier.attribute === "stamina"
));
const staminaPercent = applyTalentModifierValue(100, staminaModifiers, "attribute-percent") - 100;
const maxHealthPercent = applyTalentModifierValue(100, playerModifiers, "max-health-percent") - 100;
return Math.max(1, deriveCharacterStats(classId, level, gearStats, raceId, {
stamina: staminaPercent,
maxHealth: maxHealthPercent,
}, secondaryClassId).health);
}
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
return "spell";
}
}
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
const healing = ability.effects.some((effect) => (
effect.kind === "heal"
|| effect.kind === "hot"
|| effect.kind === "shield"
|| effect.kind === "resurrection"
));
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
if (healing && ability.target !== "hostile") return "spell";
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
return "spell";
}
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school;
}
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
if (/\b(?:frost|ice|chill|blizzard)\b/.test(searchable)) return "frost";
if (/\b(?:shadow|curse|corruption|drain|haunt)\b/.test(searchable)) return "shadow";
if (/\b(?:arcane|starfire|moonfire)\b/.test(searchable)) return "arcane";
if (/\b(?:nature|lightning|wrath|insect|earth|storm)\b/.test(searchable)) return "nature";
if (classId === "paladin" || classId === "priest") return "holy";
if (classId === "warlock") return "shadow";
if (classId === "shaman" || classId === "druid") return "nature";
return "arcane";
}
export function equipmentAdjustedCombatAmount(
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
level: number,
gearStats: ItemStats,
classId: ClassId,
ability: AbilityDefinition,
): number {
const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical"
? "spell" : abilityAttackKind(classId, ability);
const attackPower = equipmentAttackPower(classId, gearStats, kind);
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : 0;
const equipmentPower = kind === "spell"
? attackPower
: attackPower / 14 * weaponSpeed + weaponDamage * 0.35;
const sourceLevel = Math.max(1, typeof scaling === "number" ? level : scaling.sourceLevel ?? level);
const scalingLevel = Math.min(level, typeof scaling === "number" ? level : scaling.maxScalingLevel || level);
const coefficient = typeof scaling === "number"
? scaling
: scaling.coefficient + Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.coefficientPerLevel ?? 0);
if (typeof scaling === "number" || scaling.basePoints === undefined) {
return Math.max(
0,
Math.round(coefficient * (playerCombatPower(level) + equipmentPower)),
);
}
const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0);
const averageRoll = Math.abs(scaling.basePoints)
+ Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2
+ levelGrowth;
// Starter-kit spells are granted early, but their raw DBC values were tuned
// for their original client level. Scale that base down to the player's
// current combat budget; once the source level is reached, use the DBC value.
const earlyLevelScale = level < sourceLevel
? playerCombatPower(level) / playerCombatPower(sourceLevel)
: 1;
const bonusPower = equipmentPower * Math.max(
0,
scaling.bonusPowerCoefficient ?? coefficient,
);
const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient
? deriveCharacterStats(classId, level, gearStats) : null;
const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0)
+ (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0);
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower));
}
function playerCharacterStats(
classId: ClassId,
level: number,
gearStats: ItemStats,
raceId: RaceId,
secondaryClassId: ClassId | null,
modifiers: readonly TalentModifier[],
): CharacterStats {
const attributePercent = (attribute: "agility" | "intellect" | "spirit" | "stamina" | "strength") => (
applyTalentModifierValue(
100,
modifiers.filter((modifier) => modifier.kind !== "attribute-percent" || modifier.attribute === attribute),
"attribute-percent",
) - 100
);
const armorPercent = applyTalentModifierValue(100, modifiers, "armor-percent") - 100;
const stats = deriveCharacterStats(classId, level, gearStats, raceId, {
agility: attributePercent("agility"),
intellect: attributePercent("intellect"),
spirit: attributePercent("spirit"),
stamina: attributePercent("stamina"),
strength: attributePercent("strength"),
}, secondaryClassId);
return armorPercent === 0
? stats
: Object.freeze({
...stats,
armor: Math.max(0, Math.round(stats.armor * (1 + armorPercent / 100))),
});
}
function npcCombatDefender(mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[]): CombatDefenderStats {
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
const resistance = (value: number, school: DamageSchool): number => Math.max(0,
resolveAuraStatValue(resolveAuraStatValue(value, auras, `resistance:${school}`), auras, "magical-defense"),
);
return {
level: mob.level,
armor: Math.max(0, resolveAuraStatValue(mob.armor, auras, "armor")),
dodgePercent: 5 + levelAdvantage * 0.5,
parryPercent: 5 + levelAdvantage * 0.5,
blockPercent: 0,
blockValue: 0,
resistances: {
arcane: resistance(mob.resistances.arcane, "arcane"),
fire: resistance(mob.resistances.fire, "fire"),
frost: resistance(mob.resistances.frost, "frost"),
nature: resistance(mob.resistances.nature, "nature"),
shadow: resistance(mob.resistances.shadow, "shadow"),
},
};
}
function stableCombatRoll(seed: string) {
return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`);
}
function resolvePlayerDamageResult(
classId: ClassId,
work: MutableCastWork,
sourceActorId: string,
mob: MobCombatState,
amount: number,
attackKind: WotlkAttackKind,
school: DamageSchool,
seed: string,
ability?: AbilityDefinition,
periodic = false,
rules: CombatResultRules = {},
criticalChanceBonusPercent = 0,
): CombatResult {
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const baseStats = sourceMember
? deriveCharacterStats(sourceMember.classId, sourceMember.level, sourceMember.gearStats, sourceMember.raceId)
: playerCharacterStats(
classId,
work.progression.level,
work.gearStats,
work.raceId,
work.secondaryClassId,
work.talentModifiers,
);
const stats = applyAuraStats(baseStats, aurasForEntity(work.auras, sourceActorId));
const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind);
const nextCritical = !periodic && Boolean(ability && aurasForEntity(work.auras, sourceActorId).some((aura) => (
aura.definition.utilityTags?.includes("next-offensive-critical")
)));
const premeditation = nextCritical && aurasForEntity(work.auras, sourceActorId).some((aura) => (
aura.definition.utilityTags?.includes("rom-premeditation")
));
const criticalChanceFromTalents = sourceMember ? 0 : ability
? applyTalentModifierValue(
0,
work.talentModifiers,
"critical-chance-percent",
abilityTalentContext(ability, periodic),
)
: applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent");
const attacker: CombatAttackerStats = {
...baseAttacker,
criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents
+ criticalChanceBonusPercent + (nextCritical ? 100 : 0),
criticalDamageMultiplier: (sourceMember ? 2 : applyTalentModifierValue(
2,
work.talentModifiers,
"critical-damage-percent",
ability ? abilityTalentContext(ability, periodic) : {},
)) * (premeditation ? 1.4 : 1),
};
const immune = mob.schoolImmunities.includes(school);
return resolveCombatResult({
amount,
attackKind,
damageSchool: school,
attacker,
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id)),
rules: immune
? {
...rules,
immune: true,
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
resistanceMode: "binary",
}
: rules,
roll: stableCombatRoll(seed),
});
}
function combatResultDescription(result: CombatResult): string {
if (result.outcome === "miss") return "missed";
if (result.outcome === "dodge") return "was dodged";
if (result.outcome === "parry") return "was parried";
if (result.outcome === "resist") return "was resisted";
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`;
if (result.partiallyResisted) return `hit for ${result.finalDamage} (${result.resistedAmount} resisted)`;
return `hit for ${result.finalDamage}`;
}
function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
if (attack.delivery === "melee") return "melee";
if (attack.delivery === "projectile" && attack.school === "physical") return "ranged";
return "spell";
}
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
return attack.id === DEFAULT_ENEMY_ATTACK.id
&& attack.delivery === "melee"
&& attack.animation === "attack"
&& (attack.castTimeMs ?? 0) === 0;
}
function enemyMeleeCriticalCharge(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
): MobAura | undefined {
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
return mob.auras.find((aura) => (
(aura.endsAt <= 0 || aura.endsAt > now)
&& (aura.charges ?? 0) > 0
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
));
}
function advanceEnemyMeleeAutoProcAuras(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
attackRevision: number,
landed: boolean,
consumedAuraId?: string,
): readonly MobAura[] {
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
let changed = false;
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
if (aura.id !== consumedAuraId) return [aura];
changed = true;
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
return charges > 0 ? [{ ...aura, charges }] : [];
});
for (const driver of mob.auras) {
const proc = driver.meleeAutoCriticalProc;
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
if (
chance <= 0
|| stableCombatUnit(
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
) >= chance
) {
continue;
}
const triggeredAura = proc.triggeredAura.trim();
const criticalChancePercent = Math.max(
0,
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
);
const charges = Math.max(1, Math.round(
Number.isFinite(proc.charges) ? proc.charges : 1,
));
if (!triggeredAura || criticalChancePercent <= 0) continue;
nextAuras = [
...nextAuras.filter((aura) => aura.id !== triggeredAura),
{
id: triggeredAura,
sourceAbilityId: driver.sourceAbilityId,
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
nextMeleeCriticalChancePercent: criticalChancePercent,
charges,
},
];
changed = true;
}
return changed ? nextAuras : mob.auras;
}
function resolveEnemyDamageResult(
mob: MobCombatState,
attack: EnemyAttackDefinition,
defender: CharacterStats,
amount: number,
actorId: AggroActorId,
attackRevision: number,
now: number,
bonusCriticalChancePercent = 0,
defenderAuras: readonly ActiveAura[] = [],
): CombatResult {
const attackKind = enemyAttackKind(attack);
const incomingModifier = (stat: string) => defenderAuras.reduce((total, aura) => total
+ (aura.definition.modifiers ?? []).reduce((sum, modifier) => modifier.kind === "stat"
&& modifier.stat === stat && modifier.operation === "flat"
? sum + modifier.value * (modifier.perStack === false ? 1 : aura.stacks)
: sum, 0), 0);
const incomingHit = incomingModifier(`incoming-${attackKind}-hit`);
const incomingCrit = incomingModifier("incoming-critical-strike")
+ incomingModifier(`incoming-${attackKind}-critical-strike`);
const defenderStats = combatDefenderFromCharacterStats(defender);
return resolveCombatResult({
amount,
attackKind,
damageSchool: attack.school,
attacker: {
level: mob.level,
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
},
defender: {
...defenderStats,
criticalAvoidancePercent: (defenderStats.criticalAvoidancePercent ?? 0) - incomingCrit,
},
rules: {
isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
canDodge: attackKind === "melee",
canParry: attackKind === "melee",
canBlock: attackKind !== "spell",
baseMissChancePercent: baseMissChancePercent(attackKind, mob.level, defender.level) - incomingHit,
},
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
});
}
function talentAdjustedMaxResource(
classId: ClassId,
level: number,
modifiers: readonly TalentModifier[],
gearStats: ItemStats = EMPTY_ITEM_STATS,
raceId: RaceId = "human",
secondaryClassId?: ClassId | null,
): number {
const profile = resourceProfileForClass(classId);
const playerModifiers = modifiers.filter((modifier) => (
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
));
const primaryResourceModifiers = playerModifiers.filter((modifier) => {
if (modifier.kind !== "max-resource-percent") return true;
const scope = modifier.scope.toLowerCase();
const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/.test(scope);
if (!mentionsSpecificResource) return true;
if (profile.type === "mana") return /\bmana\b/.test(scope);
if (profile.type === "rage") return /\b(?:rage|felfury|deathfire)\b/.test(scope);
if (profile.type === "energy") return /\b(?:energy|focus)\b/.test(scope);
return /\brunic power\b/.test(scope);
});
const maxResourcePercent = applyTalentModifierValue(
100,
primaryResourceModifiers,
"max-resource-percent",
) - 100;
if (profile.type !== "mana") {
return Math.max(0, Math.round(profile.maximum * (1 + maxResourcePercent / 100)));
}
const intellectModifiers = primaryResourceModifiers.filter((modifier) => (
modifier.kind !== "attribute-percent" || modifier.attribute === "intellect"
));
const intellectPercent = applyTalentModifierValue(100, intellectModifiers, "attribute-percent") - 100;
return Math.max(0, deriveCharacterStats(classId, level, gearStats, raceId, {
intellect: intellectPercent,
maxMana: maxResourcePercent,
}, secondaryClassId).mana);
}
const AUTOMATIC_CLASS_PASSIVE_PREFIX = "class-passive:";
function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
const statedLevel = ability.description.match(/\bLevel\s+(\d+)\s+Passive\b/i);
return Math.max(ability.unlockLevel, statedLevel ? Number(statedLevel[1]) : 1);
}
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
if (!ability.passive) return false;
if (ability.source !== "coa-tree") return true;
const nodeId = abilityTalentNodeId(ability);
const node = nodeId ? talentNodeById(nodeId) : null;
return Boolean(
node?.isPassive
&& (node.abilityEssenceCost ?? 0) === 0
&& (node.talentEssenceCost ?? 0) === 0,
);
}
function automaticClassPassiveModifiers(
classId: ClassId,
level: number,
): readonly TalentModifier[] {
const modifiers: TalentModifier[] = [];
const seenNames = new Set<string>();
for (const definition of abilitiesForClass(classId)) {
const ability = abilityAtLevel(definition, level);
if (
!isAutomaticClassPassive(ability)
|| level < automaticClassPassiveUnlockLevel(ability)
|| seenNames.has(ability.name.toLowerCase())
) {
continue;
}
seenNames.add(ability.name.toLowerCase());
modifiers.push(...parseTalentModifiers(ability.description).map((modifier) => ({
...modifier,
sourceTalentId: `${AUTOMATIC_CLASS_PASSIVE_PREFIX}${ability.id}`,
sourceTalentName: ability.name,
rank: abilityRankForLevel(ability, level)?.rank ?? 1,
description: ability.description,
})));
}
return modifiers;
}
function characterStatModifiers(
classId: ClassId,
level: number,
ranks: TalentRanks,
): readonly TalentModifier[] {
return [
...automaticClassPassiveModifiers(classId, level),
...talentModifiersForRanks(ranks, classId),
];
}
function initialResourceForMaximum(classId: ClassId, maximum: number): number {
const profile = resourceProfileForClass(classId);
return Math.round(maximum * (profile.maximum > 0 ? profile.initial / profile.maximum : 0));
}
function defaultResourceMaximum(type: ResourceType): number {
if (type === "nature-power") return 10;
if (type === "psi") return 6;
return 100;
}
function initialResourcePools(
classId: ClassId,
abilities: readonly AbilityDefinition[],
activeMaximum: number,
manaMaximum = 100,
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
const activeProfile = resourceProfileForClass(classId);
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
for (const ability of abilities) for (const cost of ability.costs ?? [ability.cost]) resourceTypes.add(cost.resource);
const maximums: Partial<Record<ResourceType, number>> = {};
const pools: Partial<Record<ResourceType, number>> = {};
for (const type of resourceTypes) {
if (type === "health") continue;
const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type);
maximums[type] = maximum;
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
? 0
: maximum;
}
return { pools, maximums };
}
export function resolvedAbilityCosts(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras" | "level">>,
): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>();
const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.percentageBasis === "base-mana"
? baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana
: cost.resource === "health"
? state.maxHealth
: cost.resource === activeResourceType
? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount,
modifiers,
"resource-cost-percent",
abilityTalentContext(ability),
)));
const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID);
const resourceAdjusted = resolveAuraResourceValue(
resolveAuraResourceValue(rawAmount, playerAuras, cost.resource, "cost"),
playerAuras,
"power",
"cost",
);
const amount = Math.max(0, Math.round(resourceAdjusted));
aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount);
}
return [...aggregated].map(([resource, amount]) => ({ resource, amount }));
}
function talentAdjustedResourceCost(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
): number {
return Math.max(0, Math.round(applyTalentModifierValue(
ability.cost.amount,
modifiers,
"resource-cost-percent",
abilityTalentContext(ability),
)));
}
function talentAdjustedCastTime(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
gearStats: ItemStats = EMPTY_ITEM_STATS,
level = 1,
classId: ClassId = "warrior",
): number {
if (ability.castTimeMs <= 0) return 0;
const context = abilityTalentContext(ability);
let duration = applyTalentModifierValue(ability.castTimeMs, modifiers, "cast-time-flat-ms", context);
duration = applyTalentModifierValue(duration, modifiers, "cast-time-percent", context);
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", context);
duration = hastedDuration(
duration,
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
);
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
}
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
if (ability.cooldownMs <= 0) return 0;
const context = abilityTalentContext(ability);
let duration = applyTalentModifierValue(ability.cooldownMs, modifiers, "cooldown-flat-ms", context);
duration = applyTalentModifierValue(duration, modifiers, "cooldown-percent", context);
return Math.max(0, Math.round(duration));
}
function talentAdjustedGcd(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
gearStats: ItemStats = EMPTY_ITEM_STATS,
level = 1,
classId: ClassId = "warrior",
): number {
if (ability.gcdMs <= 0) return 0;
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
const duration = hastedDuration(
ability.gcdMs,
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
1_000,
);
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
}
function talentAdjustedDuration(
durationMs: number,
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
): number {
return Math.max(0, Math.round(applyTalentModifierValue(
durationMs, modifiers, "duration-percent", abilityTalentContext(ability),
)));
}
function talentAdjustedRange(
range: number,
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
): number {
return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability)));
}
function talentAdjustedIncomingDamage(amount: number, modifiers: readonly TalentModifier[]): number {
return Math.max(0, Math.round(
applyTalentModifierValue(amount, modifiers, "damage-taken-percent"),
));
}
/** Renderer-independent player defenses for hostile-player combat adapters. */
export function characterCombatDefender(state: CombatState): CombatDefenderStats {
const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats,
state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID));
const defender = combatDefenderFromCharacterStats(stats);
const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000;
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
}
function normalizedSettings(
current: GameplaySettings,
patch: Partial<GameplaySettings>,
): GameplaySettings {
return {
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
threatMeterPosition: normalizeHudPosition(
patch.threatMeterPosition ?? current.threatMeterPosition,
),
showFloatingCombatText: patch.showFloatingCombatText ?? current.showFloatingCombatText,
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
uiScale: clamp(Number.isFinite(patch.uiScale) ? patch.uiScale! : current.uiScale, 0.75, 1.25),
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
? patch.minimapRotation
: current.minimapRotation,
gameplayFrameRate: normalizeGameplayFrameRateLimit(
patch.gameplayFrameRate,
current.gameplayFrameRate,
),
previewFrameRate: normalizePreviewFrameRateLimit(
patch.previewFrameRate,
current.previewFrameRate,
),
};
}
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
return left.showMobHealthBars === right.showMobHealthBars
&& left.showMobAggroRanges === right.showMobAggroRanges
&& left.showTargetFrame === right.showTargetFrame
&& left.showThreatMeter === right.showThreatMeter
&& left.threatMeterPosition.x === right.threatMeterPosition.x
&& left.threatMeterPosition.y === right.threatMeterPosition.y
&& left.showFloatingCombatText === right.showFloatingCombatText
&& left.autoTargetNearest === right.autoTargetNearest
&& left.enableScreenShake === right.enableScreenShake
&& left.masterVolume === right.masterVolume
&& left.uiScale === right.uiScale
&& left.minimapRotation === right.minimapRotation
&& left.gameplayFrameRate === right.gameplayFrameRate
&& left.previewFrameRate === right.previewFrameRate;
}
function defaultMobStats(
playerLevel: number,
boss: boolean,
serverEntry?: number,
): {
maxHealth: number;
xpReward: number;
armor: number;
maxMana: number;
attackPower: number;
attackDamage: number;
swingMs: number;
mechanicImmunities: readonly AzerothCoreMechanic[];
schoolImmunities: readonly AzerothCoreSchool[];
} {
const server = serverEntry ? creatureRuntimeBaseStats(serverEntry, playerLevel) : null;
const legacy = boss
? { maxHealth: 260 + playerLevel * 65, xpReward: 110 + playerLevel * 35 }
: { maxHealth: 72 + playerLevel * 22, xpReward: 28 + playerLevel * 7 };
return {
maxHealth: server?.health ?? legacy.maxHealth,
xpReward: legacy.xpReward,
armor: server?.armor ?? 0,
maxMana: server?.mana ?? 0,
attackPower: server?.attackPower ?? 0,
attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2),
swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900),
mechanicImmunities: server?.mechanicImmunities ?? [],
schoolImmunities: server?.schoolImmunities ?? [],
};
}
function stableCombatUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function applyWrathCastPushback(
cast: ActiveCast | null,
now: number,
seed: string,
): ActiveCast | null {
if (!cast || cast.pushbackHits >= 2) return cast;
const ability = abilityById(cast.abilityId);
const resistance = ability ? abilityPushbackResistance(ability) : 0;
if (resistance >= 1 || stableCombatUnit(`${seed}:pushback`) < resistance) return cast;
const pushbackHits = cast.pushbackHits + 1;
if (cast.mode === "channel") {
const lostTicks = Math.max(1, Math.ceil(cast.totalTicks * 0.25));
const totalTicks = Math.max(cast.ticksCompleted, cast.totalTicks - lostTicks);
return {
...cast,
pushbackHits,
totalTicks,
completesAt: Math.max(now, cast.completesAt - cast.originalDurationMs * 0.25),
};
}
return { ...cast, pushbackHits, completesAt: cast.completesAt + 500 };
}
export function deterministicEnemyCooldown(
runtimeId: string,
abilityId: string,
activation: number,
range: readonly [number, number],
): number {
const minimum = Math.max(0, Math.round(range[0]));
const maximum = Math.max(minimum, Math.round(range[1]));
if (maximum === minimum) return minimum;
return minimum + Math.floor(
stableCombatUnit(`${runtimeId}:${abilityId}:${activation}`) * (maximum - minimum + 1),
);
}
function enemyAbilityConditionMet(
mob: MobCombatState,
mobs: Readonly<Record<string, MobCombatState>>,
attack: EnemyAttackDefinition,
): boolean {
const condition = attack.condition;
if (!condition || condition.kind === "in-combat") return true;
if (condition.kind === "self-health-percent") {
const percent = mob.maxHealth > 0 ? mob.health / mob.maxHealth * 100 : 0;
return percent >= condition.minimum && percent <= condition.maximum;
}
if (condition.kind === "encounter-state") {
return dungeonEncounterStateMatches(condition.encounterId, condition.states);
}
const origin = getMobPosition(mob.id);
if (!origin) return false;
return Object.values(mobs).some((candidate) => {
if (candidate.dead || candidate.health > candidate.maxHealth - condition.amount) return false;
const position = getMobPosition(candidate.id);
if (!position) return false;
const dx = position[0] - origin[0];
const dy = position[1] - origin[1];
const dz = position[2] - origin[2];
return dx * dx + dy * dy + dz * dz <= condition.range * condition.range;
});
}
function enemyAbilityNeedsHostileRange(attack: EnemyAttackDefinition): boolean {
return attack.target === "primary"
|| attack.target === "random-party"
|| attack.target === "nearby-party";
}
function initialEnemyAbilityReadyAt(
mob: MobCombatState,
now: number,
): Readonly<Record<string, number>> {
return Object.fromEntries(mob.attacks.map((attack) => {
const range = attack.initialCooldownMs ?? [450, 450] as const;
return [
attack.id,
now + deterministicEnemyCooldown(mob.id, attack.id, mob.attackRevision, range),
];
}));
}
function summonedMobCombatState(
id: string,
summoner: MobCombatState,
creatureEntry: number,
targetActorId: AggroActorId,
position: CombatPosition,
spawnGroupId?: string,
): MobCombatState {
const stats = defaultMobStats(summoner.level, false, creatureEntry);
const template = creatureTemplateReference(creatureEntry);
const attack: EnemyAttackDefinition = normalizeEnemyAttacks([{
...DEFAULT_ENEMY_ATTACK,
cooldownMs: stats.swingMs,
repeatCooldownMs: [stats.swingMs, stats.swingMs],
initialCooldownMs: [450, 450],
}])[0];
return {
id,
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
serverEntry: creatureEntry,
creatureFamily: romCreatureFamilyForServerEntry(creatureEntry),
summonedBy: summoner.id,
...(spawnGroupId ? { spawnGroupId } : {}),
boss: false,
lootSourceId: null,
level: summoner.level,
armor: stats.armor,
maxMana: stats.maxMana,
attackPower: stats.attackPower,
mechanicImmunities: stats.mechanicImmunities,
schoolImmunities: stats.schoolImmunities,
resistances: { arcane: 0, fire: 0, frost: 0, nature: 0, shadow: 0 },
health: stats.maxHealth,
maxHealth: stats.maxHealth,
healthMultiplier: 1,
damageMultiplier: 1,
bonusLootChance: 0,
dead: false,
hasLoot: false,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
xpReward: 0,
experienceGranted: false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: seedActorThreat({}, targetActorId),
targetActorId,
forcedTarget: null,
lastDamageAt: 0,
engaged: true,
combatPhase: "chasing",
homePosition: position,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
attackRange: attack.range,
attackDamage: stats.attackDamage,
swingMs: stats.swingMs,
aggroRange: 0,
leashRange: summoner.leashRange,
moveSpeed: summoner.moveSpeed,
attackRevision: 0,
attackAnimation: "attack",
attacks: [attack],
nextAttackIndex: 0,
lastAttackId: null,
woundRevision: 0,
nextWoundAnimationAt: 0,
deathRevision: 0,
};
}
function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosition {
return origin ?? state.playerPosition;
}
function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveAura[] {
return auras.filter((aura) => aura.targetId === entityId);
}
function hasControlImmunity(
auras: readonly ActiveAura[],
entityId: string,
mechanic: EnemyControlMechanic,
): boolean {
return aurasForEntity(auras, entityId).some((aura) => (
aura.definition.controlImmunities?.includes(mechanic)
));
}
function auraDefinitionForAbility(
definition: AuraDefinition,
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
return {
...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
...(defaultPartyBuffFamily ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
};
}
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
const stat = (value: number, ...names: string[]) => names.reduce(
(current, name) => resolveAuraStatValue(current, auras, name),
value,
);
const primary = (value: number, name: string, alias?: string) => stat(
value,
name,
...(alias ? [alias] : []),
"all-primary",
"all-primary-attributes",
);
const resistance = (value: number, school: string) => stat(
value,
`resistance:${school}`,
"resistance:all",
"all-resistance",
"magical-defense",
);
const stamina = primary(base.stamina, "stamina");
const spirit = primary(base.spirit, "spirit", "wisdom");
const spiritCritRating = spirit * resolveAuraStatValue(0, auras, "spirit-to-spell-crit-rating");
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
return {
...base,
strength: primary(base.strength, "strength"),
agility: primary(base.agility, "agility", "dexterity"),
stamina,
intellect: primary(base.intellect, "intellect"),
spirit,
health: stat(base.health + staminaHealth, "maximum-health"),
mana: stat(base.mana, "maximum-mana"),
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
spellPower: stat(base.spellPower, "spell-power", "magical-attack"),
armor: stat(base.armor, "armor", "physical-defense"),
blockValue: stat(base.blockValue, "block-value"),
meleeCritPercent: stat(base.meleeCritPercent, "critical-strike", "physical-critical-strike"),
rangedCritPercent: stat(base.rangedCritPercent, "critical-strike", "physical-critical-strike"),
spellCritPercent: stat(
base.spellCritPercent + ratingPercent(spiritCritRating, "spellCrit", base.level, "mage"),
"spell-critical-strike", "magical-critical-strike",
),
meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"),
rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit"),
spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"),
meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"),
rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"),
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
dodgePercent: stat(base.dodgePercent, "dodge"),
parryPercent: stat(base.parryPercent, "parry"),
blockPercent: stat(base.blockPercent, "block", "block-chance"),
expertise: stat(base.expertise, "expertise"),
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
fireResistance: resistance(base.fireResistance, "fire"),
frostResistance: resistance(base.frostResistance, "frost"),
natureResistance: resistance(base.natureResistance, "nature"),
shadowResistance: resistance(base.shadowResistance, "shadow"),
};
}
function consumeInnerFireCharges(
auras: readonly ActiveAura[], targetId: string, landed: boolean, attackKind: WotlkAttackKind,
): ActiveAura[] {
if (!landed || (attackKind !== "melee" && attackKind !== "ranged")) return [...auras];
return auras.flatMap((aura) => {
if (aura.targetId !== targetId || !aura.definition.name.startsWith("Inner Fire (DBC ")) return [aura];
return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : [];
});
}
function auraMaximumHealthBonus(
base: CharacterStats,
auras: readonly ActiveAura[],
targetId: string,
): number {
return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health);
}
function auraAdjustedPlayerMaxHealth(
classId: ClassId,
level: number,
modifiers: readonly TalentModifier[],
gearStats: ItemStats,
raceId: RaceId,
secondaryClassId: ClassId | null | undefined,
auras: readonly ActiveAura[],
): number {
const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers);
return Math.max(
1,
talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId)
+ auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID),
);
}
const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map<string, string>([
["power word: fortitude", "fortitude"],
["prayer of fortitude", "fortitude"],
["divine spirit", "spirit"],
["prayer of spirit", "spirit"],
["shadow protection", "shadow-protection"],
["prayer of shadow protection", "shadow-protection"],
["arcane intellect", "arcane-intellect"],
["arcane brilliance", "arcane-intellect"],
["mark of the wild", "mark-of-the-wild"],
["gift of the wild", "mark-of-the-wild"],
]));
function defaultPartyBuffPotency(
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[],
): number {
return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce(
(subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1),
0,
), 0);
}
function syncPassiveAuras(
current: readonly ActiveAura[],
abilities: readonly AbilityDefinition[],
level: number,
talentRanks: TalentRanks,
now: number,
): ActiveAura[] {
const definitions = abilities.flatMap((catalogAbility) => {
if (!catalogAbility.passive) return [];
const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks);
if (!ability) return [];
return ability.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
});
const passiveIds = new Set(definitions.map((definition) => definition.id));
let next = current.filter((aura) => (
aura.sourceId !== PLAYER_AURA_ENTITY_ID
|| !abilities.some((ability) => ability.passive && aura.definition.id.includes(`:${ability.dbcSpellId}:`))
|| passiveIds.has(aura.definition.id)
));
for (const definition of definitions) {
if (next.some((aura) => aura.definition.id === definition.id && aura.targetId === PLAYER_AURA_ENTITY_ID)) continue;
next = [...applyCombatAura(next, {
definition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras];
}
return next;
}
function executeTriggeredAuraProcsInWork(
work: MutableCastWork,
triggeredProcs: readonly TriggeredAuraProc[],
now: number,
): void {
for (const triggered of triggeredProcs) {
const action = triggered.action;
if (action.kind === "custom" && action.id.startsWith("rom-")) {
const triggeringAbility = triggered.event.abilityId
? work.abilities.find((ability) => ability.id === triggered.event.abilityId)
?? abilityById(triggered.event.abilityId)
: null;
const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId
&& aura.definition.utilityTags?.includes("rom-poisonous"));
const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous);
if (spec) {
const recipient = spec.recipient === "caster"
? triggered.sourceId : triggered.event.otherId ?? triggered.targetId;
if (spec.kind === "aura" && recipient) {
work.auras = [...applyCombatAura(work.auras, {
definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now,
}).auras];
} else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) {
const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource);
work.resourcePools[spec.resource] = clamp(
(work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum,
);
} else if (spec.kind === "resource" && recipient && work.actors[recipient]) {
const actor = work.actors[recipient];
const pool = actor.resources[spec.resource];
if (pool) {
const next = { ...actor, resources: {
...actor.resources,
[spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) },
} };
work.actors[recipient] = next;
mirrorCompanionActor(next);
}
} else if (spec.kind === "dot" && recipient && work.mobs[recipient]) {
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
&& effect.sourceActorId === triggered.sourceId
&& effect.targetId === recipient && effect.tag === spec.effect.tag));
work.effects.push(timedEffect(
"dot", triggeringAbility?.id ?? triggered.procId,
triggered.sourceId, recipient, spec.effect,
spec.effect.ticks, spec.effect.intervalMs, now,
spec.effect.tag, spec.effect.school ?? "nature",
work.gearStats.spellPenetration,
));
}
continue;
}
}
const actionTarget = action.kind === "custom"
? null
: action.target === "aura-source"
? triggered.sourceId
: action.target === "aura-target"
? triggered.targetId
: triggered.event.otherId ?? null;
if (action.kind === "resource" && actionTarget === PLAYER_AURA_ENTITY_ID) {
const resource = action.resource as ResourceType;
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
work.totalHealing += healPlayerInWork(work, action.amount);
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
damageMobInWork(
work.classId,
work,
actionTarget,
Math.max(0, action.amount),
now,
playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true),
action.school,
);
} else if (action.kind === "apply-aura" && actionTarget) {
const definition = work.auras.find((aura) => aura.definition.id === action.auraId)?.definition;
if (definition) {
work.auras = [...applyCombatAura(work.auras, {
definition,
sourceId: triggered.sourceId,
targetId: actionTarget,
now,
}).auras];
}
} else if (
action.kind === "custom"
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
&& typeof action.data?.spellId === "number"
) {
const spellId = action.data.spellId;
const targetId = triggered.event.otherId ?? triggered.targetId;
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
const triggeredSpellStack = work.triggeredSpellStack ?? [];
if (
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|| triggeredSpellStack.includes(triggerKey)
) continue;
triggeredSpellStack.push(triggerKey);
work.triggeredSpellStack = triggeredSpellStack;
try {
executeTriggeredSpellInWork(
work,
spellId,
triggered.sourceId,
targetId,
now,
0,
true,
);
} finally {
triggeredSpellStack.pop();
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
}
} else if (
action.kind === "custom"
&& action.id === "classic-status"
&& typeof action.data?.status === "string"
) {
const targetId = triggered.event.otherId ?? triggered.targetId;
const mob = work.mobs[targetId];
if (mob) {
work.mobs[targetId] = applyStatus(mob, {
kind: "status",
status: action.data.status as MobStatusKind,
durationMs: typeof action.data.durationMs === "number" ? action.data.durationMs : 3_000,
...(typeof action.data.magnitude === "number" ? { magnitude: action.data.magnitude } : {}),
}, `proc:${triggered.procId}`, now);
}
} else if (action.kind === "custom" && action.id === "rom-shield-discipline-reflect") {
const attackerId = triggered.event.otherId;
if (triggered.event.attackKind === "spell" && attackerId && work.mobs[attackerId]) {
damageMobInWork(work.classId, work, attackerId, 100, now, {
actorId: triggered.sourceId,
role: "tank",
abilityId: "rom-knight-general-7",
}, "holy");
}
} else if (action.kind === "custom" && action.id === "rom-frost-arrow") {
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
const targetId = triggered.event.otherId ?? triggered.targetId;
const mob = work.mobs[targetId];
if (mob && triggeringAbility?.name === "Wind Arrow") {
work.mobs[targetId] = applyStatus(mob, {
kind: "status",
status: "slow",
durationMs: 6_000,
magnitude: 0.4,
}, `proc:${triggered.procId}`, now);
}
} else if (action.kind === "custom" && action.id === "rom-entling-offering") {
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
const targetId = triggered.event.otherId ?? triggered.targetId;
if (work.mobs[targetId] && triggeringAbility?.name === "Shot") {
damageMobInWork(
work.classId,
work,
targetId,
Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
now,
playerThreatSource({ talentModifiers: work.talentModifiers }, "rom-entling-offering", true),
"physical",
);
}
}
}
}
function evaluateProcsInWork(
work: MutableCastWork,
event: ProcEvent,
now: number,
): readonly TriggeredAuraProc[] {
const root = work.triggeredProcKeys === undefined;
const triggeredKeys = work.triggeredProcKeys ?? new Set<string>();
work.triggeredProcKeys = triggeredKeys;
try {
const result = evaluateAuraProcs(
work.auras,
event,
now,
({ aura, proc }) => triggeredKeys.has(`${aura.instanceId}:${proc.id}`)
? 1
: stableCombatUnit(
`${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`,
),
);
// A proc can activate once for the originating event. Its damage must not
// reactivate it through a different branch of the same triggered chain.
for (const proc of result.triggered) triggeredKeys.add(`${proc.auraInstanceId}:${proc.procId}`);
work.auras = [...result.auras];
executeTriggeredAuraProcsInWork(work, result.triggered, now);
return result.triggered;
} finally {
if (root) work.triggeredProcKeys = undefined;
}
}
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[id];
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
});
}
function castFailureMessage(reason: CastFailureReason, resourceName: string, unlockLevel?: number): string {
switch (reason) {
case "unknown-ability": return "That ability is unavailable.";
case "wrong-class": return "Your class cannot use that ability.";
case "not-learned": return "Learn that ability in the Conquest talent tree first.";
case "passive": return "Passive abilities activate automatically and cannot be cast.";
case "unsupported": return "That spell has no executable combat effect.";
case "level-locked": return `That ability unlocks at level ${unlockLevel ?? "a higher level"}.`;
case "player-dead": return "You cannot act while defeated.";
case "player-controlled": return "You cannot act while under an enemy control effect.";
case "already-casting": return "Another spell is already being cast.";
case "global-cooldown": return "Your abilities are not ready yet.";
case "cooldown": return "That ability is still recharging.";
case "not-enough-resource": return `Not enough ${resourceName}.`;
case "not-enough-runes": return "The required Death Knight runes are still recharging.";
case "condition-not-met": return "That ability's required RuneWaker state is not active.";
case "no-target": return "Select an enemy target.";
case "target-dead": return "That target is already defeated.";
case "target-unavailable": return "That target is not currently visible.";
case "line-of-sight": return "That target is not in line of sight.";
case "not-facing": return "You must face that target.";
case "must-be-behind": return "You must be behind that target.";
case "invalid-destination": return "That ground destination is invalid.";
case "school-locked": return "That spell school is locked out.";
case "too-close": return "That target is too close.";
case "out-of-range": return "That target is out of range.";
}
}
function levelUpMessage(classId: ClassId, previousLevel: number, currentLevel: number): string {
const parts = [`Level ${currentLevel}!`];
const unlocked = abilitiesUnlockedBetweenLevels(classId, previousLevel, currentLevel);
if (unlocked.length === 1) {
parts.push(`New ability unlocked: ${unlocked[0].name}.`);
} else if (unlocked.length > 1) {
parts.push(`New abilities unlocked: ${unlocked.map((ability) => ability.name).join(", ")}.`);
}
if (talentPointsForLevel(currentLevel) > talentPointsForLevel(previousLevel)) {
parts.push("A new talent point is available.");
}
return parts.join(" ");
}
function progressionFromState(state: CombatState): MutableProgression {
return {
level: state.level,
xp: state.xp,
xpToNext: state.xpToNext,
health: state.health,
maxHealth: state.maxHealth,
levelsGained: 0,
};
}
function grantMobExperience(
classId: ClassId,
work: MutableCastWork,
xpReward: number,
): number {
const { progression } = work;
const scaledAward = healerManExperienceAward(xpReward, progression.level);
const result = awardExperience({ level: progression.level, xp: progression.xp }, scaledAward);
progression.level = result.level;
progression.xp = result.xp;
progression.xpToNext = result.xpToNext;
progression.levelsGained += result.levelsGained;
if (result.levelsGained > 0) {
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
progression.maxHealth = auraAdjustedPlayerMaxHealth(
classId,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
work.auras,
);
// A lingering DoT may grant experience after death, but leveling is not resurrection.
progression.health = work.health > 0 ? progression.maxHealth : 0;
work.health = progression.health;
work.maxHealth = progression.maxHealth;
work.maxResource = talentAdjustedMaxResource(
classId,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
);
}
return scaledAward;
}
function mobGroupId(id: string): string {
if (id.startsWith("manastorm:guardian-spawn:")) {
const memberSeparator = id.lastIndexOf(":");
return memberSeparator > 0 ? id.slice(0, memberSeparator) : id;
}
// A Manastorm boss shares an aura with guardians, not their aggro group.
// Keeping its full runtime id lets players choose to pull it early without
// every guardian hit dragging the boss across the stage.
if (id.startsWith("manastorm:spawn:")) return id;
const separator = id.indexOf(":");
return separator > 0 ? id.slice(0, separator) : id;
}
function engageMobGroup(
mobs: Record<string, MobCombatState>,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): boolean {
const groupId = mobGroupId(sourceId);
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
mobGroupId(id) === groupId && mob.engaged && !mob.dead
));
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
const threatByActor = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, sourceActorId);
mobs[id] = {
...mob,
threatByActor,
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
engaged: true,
combatPhase: "chasing",
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(id))
: (getMobPosition(id) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
changed = true;
}
return changed;
}
function engageMobGroupInWork(
work: MutableCastWork,
sourceId: string,
now: number,
sourceActorId: AggroActorId,
): void {
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
}
function nearestAggroActorId(
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
mobPosition: CombatPosition,
aggroRange: number,
): AggroActorId | null {
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
let nearestId: AggroActorId | null = null;
let nearestDistanceSquared = aggroRange * aggroRange;
if (state.health > 0) {
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
nearestId = PLAYER_AGGRO_ID;
nearestDistanceSquared = playerDistanceSquared;
}
}
for (const member of usePartyStore.getState().members) {
if (member.health <= 0) continue;
const position = getPartyRuntimePosition(member.id);
if (!position) continue;
const memberDistanceSquared = distanceSquared(mobPosition, position);
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
nearestId = member.id;
nearestDistanceSquared = memberDistanceSquared;
}
}
for (const actor of onlinePlayerRuntimeActors()) {
if (actor.health <= 0) continue;
const position = getPartyRuntimePosition(actor.id);
if (!position) continue;
const actorDistanceSquared = distanceSquared(mobPosition, position);
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
nearestId = actor.id;
nearestDistanceSquared = actorDistanceSquared;
}
}
for (const summon of state.summons) {
if (summon.health <= 0) continue;
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
nearestId = summon.id;
nearestDistanceSquared = summonDistanceSquared;
}
}
return nearestId;
}
function mobResistanceForSchool(
mob: MobCombatState,
school: DamageSchool,
): number {
if (school === "arcane") return mob.resistances.arcane;
if (school === "fire") return mob.resistances.fire;
if (school === "frost") return mob.resistances.frost;
if (school === "nature") return mob.resistances.nature;
if (school === "shadow") return mob.resistances.shadow;
return 0;
}
function damageAfterMobResistance(
amount: number,
mob: MobCombatState,
school: DamageSchool,
spellPenetration: number,
attackerLevel: number,
): number {
if (mob.schoolImmunities.includes(school)) return 0;
if (school === "physical" || school === "holy") return amount;
const resistance = Math.max(0, mobResistanceForSchool(mob, school) - Math.max(0, spellPenetration));
const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75;
return amount * (1 - Math.min(0.75, resisted));
}
function damageMobInWork(
classId: ClassId,
work: MutableCastWork,
id: string,
amount: number,
now: number,
source: ThreatSource,
school: DamageSchool = "physical",
spellPenetration = 0,
combatResult?: CombatResult,
): DamageResult {
const mob = work.mobs[id];
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
}
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
amount,
mob,
school,
spellPenetration,
work.progression.level,
);
const mitigated = resolveAuraDamageValue(
mitigatedBeforeAuras,
aurasForEntity(work.auras, id),
{ direction: "taken", school, attackKind: "spell" },
);
const auraAbsorb = absorbAuraDamage(work.auras, {
targetId: id,
amount: mitigated,
school,
now,
});
work.auras = [...auraAbsorb.auras];
const characterAbsorbed = Math.min(mob.characterShield ?? 0, auraAbsorb.remainingDamage);
const remainingDamage = Math.max(0, auraAbsorb.remainingDamage - characterAbsorbed);
const adjustedCombatResult = combatResult
? { ...combatResult, finalDamage: Math.max(0, Math.round(remainingDamage)) }
: undefined;
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage)));
recordCombatEventInWork(work, {
occurredAt: now,
kind: "damage",
sourceActorId: source.actorId,
targetActorId: id,
...(source.abilityId ? { abilityId: source.abilityId } : {}),
school,
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
effectiveAmount: applied,
overheal: 0,
absorbed: auraAbsorb.absorbed + characterAbsorbed,
critical: combatResult?.critical ?? false,
outcome: combatResult?.outcome ?? "hit",
});
const thorns = mob.auras.find((aura) => aura.id === "thorns");
if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) {
const reflected = Math.min(
work.health,
Math.max(1, Math.round(applied * (thorns.magnitude ?? 0.08))),
);
work.health -= reflected;
work.progression.health = work.health;
}
const health = mob.health - applied;
const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const testing = useGameStore.getState().gmTestMode;
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
bonusItemChance: mob.bonusLootChance,
})
: mob.pendingLoot;
const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null;
const bossCardDrop = card ? {
bossKey: card.key,
spawnId: mob.id,
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
rarities: rollBossCards(),
} satisfies BossCardDrop : mob.bossCardDrop;
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
engageMobGroupInWork(work, id, now, source.actorId);
const engagedMob = work.mobs[id] ?? mob;
const threatByActor = killed
? {}
: addActorThreat(
engagedMob.threatByActor,
source.actorId,
threatFromDamage(applied, source.role, source.threatScale)
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"),
);
work.mobs[id] = {
...engagedMob,
...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}),
health,
dead: killed,
hasLoot,
pendingLoot,
bossCardDrop,
looted: killed ? false : mob.looted,
despawned: false,
diedAt: killed ? now : mob.diedAt,
corpseDespawnAt: killed
? (hasLoot ? null : now + EMPTY_CORPSE_DESPAWN_MS)
: mob.corpseDespawnAt,
experienceGranted: mob.experienceGranted || killed,
lastDamageAt: now,
statuses: killed ? [] : applied > 0
? mob.statuses.filter((status) => !(status.breakOnDamage
?? ["fear", "sleep", "blind", "confuse"].includes(status.kind)))
: mob.statuses,
threatByActor,
targetActorId: killed ? null : (engagedMob.targetActorId ?? source.actorId),
forcedTarget: killed ? null : engagedMob.forcedTarget,
engaged: !killed,
combatPhase: killed ? "idle" : "chasing",
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast
? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) }
: engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
nextWoundAnimationAt: canPlayWoundAnimation
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
: mob.nextWoundAnimationAt,
deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision,
};
// Pulling a member links the whole authored pack even when the opening hit
// kills that member outright. The dead source remains inert because the
// group helper deliberately skips dead mobs.
work.totalDamage += applied;
if (applied > 0) {
evaluateProcsInWork(work, {
trigger: "damage-taken",
subjectId: id,
otherId: source.actorId,
abilityId: source.abilityId,
amount: applied,
school,
}, now);
}
return {
amount: applied,
killed,
xpAwarded,
levelsGained: work.progression.level - beforeLevel,
...(adjustedCombatResult ? { combatResult: adjustedCombatResult } : {}),
};
}
function resolveAndDamageMobInWork(
classId: ClassId,
work: MutableCastWork,
id: string,
amount: number,
now: number,
source: ThreatSource,
attackKind: WotlkAttackKind,
school: DamageSchool,
seed: string,
ability?: AbilityDefinition,
periodic = false,
rules: CombatResultRules = {},
criticalChanceBonusPercent = 0,
): DamageResult {
const mob = work.mobs[id];
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
}
const sourceAuras = aurasForEntity(work.auras, source.actorId);
const combustion = !periodic && attackKind === "spell" && school === "fire"
? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("fire-crit-stacking"))
: undefined;
const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, {
direction: "dealt",
school,
attackKind,
});
const combatResult = resolvePlayerDamageResult(
classId,
work,
source.actorId,
mob,
modifiedAmount,
attackKind,
school,
seed,
ability,
periodic,
rules,
criticalChanceBonusPercent + (combustion ? Math.max(0, combustion.stacks - 1) : 0),
);
const result = damageMobInWork(
classId,
work,
id,
amount,
now,
source,
school,
work.gearStats.spellPenetration,
combatResult,
);
if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) {
const passiveTags = new Set(aurasForEntity(work.auras, source.actorId)
.flatMap((aura) => aura.definition.utilityTags ?? []));
for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) {
if (rider.kind !== "apply-aura") continue;
work.auras = [...applyCombatAura(work.auras, {
definition: auraDefinitionForAbility(rider.aura, ability),
sourceId: source.actorId,
targetId: id,
now,
}).auras];
}
}
if (combustion && combatResult.landed && result.amount > 0) {
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
? [aura]
: criticalHits >= 3
? []
: [{
...aura,
stacks: Math.min(aura.definition.maxStacks ?? 100, aura.stacks + 1),
combustionCriticalHits: criticalHits,
}]);
}
const trigger = combatResult.outcome === "crit"
? "crit"
: combatResult.outcome === "dodge"
? "dodge"
: combatResult.outcome === "parry"
? "parry"
: combatResult.outcome === "block"
? "block"
: combatResult.landed
? "hit"
: null;
if (trigger) {
evaluateProcsInWork(work, {
trigger,
subjectId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? id : source.actorId,
otherId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? source.actorId : id,
abilityId: ability?.id,
amount: result.amount,
school,
attackKind,
}, now);
}
if (
combatResult.landed
&& result.amount > 0
&& attackKind === "melee"
&& !periodic
&& !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "")
) {
const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding) ? 2 : 1;
applyRogueWeaponPoisonsInWork(
work,
source.actorId,
id,
ability?.id ?? source.abilityId ?? "melee-hit",
hitCount,
now,
);
}
if (result.killed) {
evaluateProcsInWork(work, {
trigger: "kill",
subjectId: source.actorId,
otherId: id,
abilityId: ability?.id,
amount: result.amount,
school,
attackKind,
}, now);
}
return result;
}
function applyStatus(
mob: MobCombatState,
effect: Extract<AbilityEffect, { kind: "status" }>,
abilityId: string,
now: number,
sourceActorId?: string,
): MobCombatState {
if (mob.dead) return mob;
if (mob.mechanicImmunities.includes(effect.status as AzerothCoreMechanic)) return mob;
const baseDurationMs = mob.boss && effect.status === "stun"
? Math.min(3000, effect.durationMs)
: effect.durationMs;
const diminishingStatuses = new Set<MobStatusKind>([
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
]);
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
? previousDiminishing.applications
: 0;
const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
: 1;
const durationMs = Math.round(baseDurationMs * multiplier);
const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? {
...mob.controlDiminishingReturns,
[effect.status]: {
applications: Math.min(3, applications + 1),
resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000,
},
}
: mob.controlDiminishingReturns;
if (effect.status === "interrupt") {
const cast = mob.activeCast;
if (!cast?.interruptible) return mob;
return {
...mob,
activeCast: null,
nextAttackAt: now,
schoolLockedUntil: {
...mob.schoolLockedUntil,
[cast.school]: Math.max(mob.schoolLockedUntil[cast.school] ?? 0, now + durationMs),
},
};
}
if (durationMs <= 0) return { ...mob, controlDiminishingReturns };
const status: MobStatus = {
kind: effect.status,
sourceAbilityId: abilityId,
...(sourceActorId ? { sourceActorId } : {}),
endsAt: now + durationMs,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.breakOnDamage === undefined ? {} : { breakOnDamage: effect.breakOnDamage }),
};
const incapacitated = [
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
].includes(effect.status);
const cancelsActiveCast = Boolean(mob.activeCast && (
incapacitated
|| (effect.status === "silence" && mob.activeCast!.school !== "physical")
|| (effect.status === "disarm" && mob.activeCast!.school === "physical")
));
return {
...mob,
...(cancelsActiveCast
? { activeCast: null, nextAttackAt: now }
: {}),
controlDiminishingReturns,
statuses: [...mob.statuses.filter((entry) => entry.kind !== effect.status), status],
};
}
function executeTriggeredSpellInWork(
work: MutableCastWork,
spellId: number,
sourceId: string,
targetId: string | null,
now: number,
depth: number,
allowCoaHidden = false,
): void {
if (depth > 1) return;
const catalogAbility = work.abilities.find((candidate) => (
candidate.dbcSpellId === spellId
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
)) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden);
if (!catalogAbility) return;
const ability = abilityAtLevel(catalogAbility, work.progression.level);
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
const attackKind = abilityAttackKind(work.classId, ability);
const school = abilityDamageSchool(work.classId, ability);
const mobTargets = targetId && work.mobs[targetId] && !work.mobs[targetId].dead ? [targetId] : [];
for (const effect of ability.effects) {
if (effect.kind === "damage") {
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
work.talentModifiers,
"damage-percent",
ability,
);
for (const mobId of mobTargets) {
const effectSchool = effect.school ?? school;
const result = resolveAndDamageMobInWork(
work.classId,
work,
mobId,
amount,
now,
source,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
ability,
);
grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now);
}
} else if (effect.kind === "heal") {
const rawAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
work.talentModifiers,
"healing-percent",
ability,
);
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
for (const recipient of recipients) {
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
aurasForEntity(work.auras, recipient),
"taken",
));
if (recipient === PLAYER_AURA_ENTITY_ID) {
work.totalHealing += healPlayerInWork(work, amount);
} else {
const healed = healCompanionActorInWork(work, recipient, amount);
work.totalHealing += healed;
}
}
} else if (effect.kind === "status") {
for (const mobId of mobTargets) {
work.mobs[mobId] = applyStatus(work.mobs[mobId], effect, ability.id, now);
}
} else if (effect.kind === "interrupt") {
for (const mobId of mobTargets) {
work.mobs[mobId] = applyStatus(work.mobs[mobId], {
kind: "status",
status: "interrupt",
durationMs: effect.lockoutMs,
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const recipient = effect.recipient === "caster"
? sourceId
: targetId ?? sourceId;
const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
}).auras];
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
if (effect.control && work.mobs[recipient]) {
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
kind: "status",
status: effect.control.kind as MobStatusKind,
durationMs: effect.aura.durationMs ?? 1_000,
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
}, `aura:${effect.aura.id}`, now);
}
} else if (effect.kind === "resource") {
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum);
if (resource === resourceProfileForClass(work.classId).type) {
work.resource = work.resourcePools[resource] ?? work.resource;
}
} else if (effect.kind === "dot") {
for (const mobId of mobTargets) {
work.effects = work.effects.filter((entry) => !(
entry.kind === "dot"
&& entry.targetId === mobId
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
));
work.effects.push(timedEffect(
"dot", ability.id, source.actorId, mobId, effect, effect.ticks, effect.intervalMs, now,
effect.tag, school, work.gearStats.spellPenetration,
));
}
} else if (effect.kind === "hot") {
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
for (const recipient of recipients) {
work.effects.push(timedEffect(
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
));
}
} else if (effect.kind === "shield") {
const amount = equipmentAdjustedCombatAmount(
effect, work.progression.level, work.gearStats, work.classId, ability,
);
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
if (recipient === PLAYER_AURA_ENTITY_ID) {
work.shield = Math.max(work.shield, amount);
work.shieldExpiresAt = now + effect.durationMs;
} else {
work.auras = [...applyCombatAura(work.auras, {
definition: {
id: `coa-triggered-shield:${spellId}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs: effect.durationMs,
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
},
sourceId,
targetId: recipient,
now,
}).auras];
}
} else if (effect.kind === "summon") {
summonSequence += 1;
work.summons.push({
id: `player-triggered-summon-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.creatureId,
targetId,
expiresAt: now + effect.durationMs,
kind: /totem/i.test(ability.name) ? "totem" : "summon",
name: ability.name,
ownerActorId: sourceId,
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
position: getMobPosition(targetId ?? "") ?? [0, 0, 0],
nextActionAt: now + 1_000,
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
});
} else if (effect.kind === "resource-drain") {
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
if (mobTargets.length && !effect.burn) {
const resourceBefore = work.resourcePools[resource] ?? 0;
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
} else if (!mobTargets.length) {
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount);
}
if (resource === resourceProfileForClass(work.classId).type) {
work.resource = work.resourcePools[resource] ?? work.resource;
}
} else if (effect.kind === "pull") {
const playerPosition = useCombatStore.getState().playerPosition;
for (const mobId of mobTargets) {
const position = getMobPosition(mobId);
if (!position || effect.toward !== "caster") continue;
const dx = position[0] - playerPosition[0];
const dz = position[2] - playerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
setMobPosition(mobId, [
playerPosition[0] + dx / length * 1.5,
position[1],
playerPosition[2] + dz / length * 1.5,
]);
}
} else if (effect.kind === "dispel") {
const recipient = targetId ?? sourceId;
const targeted = work.auras.filter((aura) => aura.targetId === recipient);
const untouched = work.auras.filter((aura) => aura.targetId !== recipient);
const result = dispelCombatAuras(targeted, {
relationship: effect.relationship === "target"
? work.mobs[recipient] ? "hostile" : "friendly"
: effect.relationship,
categories: effect.categories,
maxCount: effect.maxCount,
});
work.auras = [...untouched, ...result.auras];
} else if (effect.kind === "execute") {
for (const mobId of mobTargets) {
const mob = work.mobs[mobId];
if (mob && !mob.boss) damageMobInWork(
work.classId,
work,
mobId,
Math.max(mob.health, mob.maxHealth * effect.coefficient),
now,
source,
school,
);
}
} else if (effect.kind === "threat") {
for (const mobId of mobTargets) {
const mob = work.mobs[mobId];
if (!mob) continue;
const current = mob.threatByActor[source.actorId] ?? 0;
work.mobs[mobId] = {
...mob,
threatByActor: {
...mob.threatByActor,
[source.actorId]: effect.mode === "percent"
? current * Math.max(0, 1 + effect.amount / 100)
: effect.mode === "redirect" ? 0 : Math.max(0, current + effect.amount),
},
};
}
} else if (effect.kind === "cooldown-reset") {
const candidates = cooldownResetCandidates(work.abilities, ability.id, effect.abilityName);
for (const candidate of candidates) {
if (effect.mode === "reset") work.cooldowns[candidate.id] = 0;
else {
const readyAt = work.cooldowns[candidate.id] ?? now;
const reduction = effect.amountPercent !== undefined
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent))
: effect.amountMs ?? 0;
work.cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
}
}
} else if (effect.kind === "trigger-spell" && !effect.expanded && depth < 1 && effect.spellId !== spellId) {
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1, allowCoaHidden);
}
}
}
function grantDamageResourceInWork(
work: MutableCastWork, effect: AbilityDamageResource, damage: number,
sourceActorId: string, abilityId: string, now: number,
): void {
const gain = effect.onDamageResource;
if (!gain || damage <= 0 || gain.amount <= 0) return;
const player = sourceActorId === PLAYER_AURA_ENTITY_ID;
const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId];
if (player ? work.health <= 0 : !actor?.alive) return;
const pool = actor?.resources[gain.resource];
const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource)
: pool?.maximum ?? defaultResourceMaximum(gain.resource);
const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0;
const auras = aurasForEntity(work.auras, sourceActorId);
const requested = Math.max(0, resolveAuraResourceValue(
resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation",
));
const restored = Math.max(0, Math.min(requested, maximum - current));
if (player) {
work.resourcePools[gain.resource] = current + restored;
if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored;
} else if (actor) {
const next = { ...actor, resources: { ...actor.resources,
[gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } };
work.actors[sourceActorId] = next;
mirrorCompanionActor(next);
}
recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId,
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
}
function withoutUnlearnedTalentBindings(
bindings: ActionBindings,
abilities: readonly AbilityDefinition[],
talentRanks: TalentRanks,
): ActionBindings {
const unlearned = new Set(abilities
.filter((ability) => abilityTalentNodeId(ability) && !(learnedAbilityRank(ability, talentRanks) ?? 0))
.map((ability) => ability.id));
return Object.fromEntries(Object.entries(bindings).map(([id, abilityId]) => [
id,
abilityId && unlearned.has(abilityId) ? null : abilityId,
])) as ActionBindings;
}
function timedEffect(
kind: "dot" | "hot",
abilityId: string,
sourceActorId: AggroActorId,
targetId: string | null,
scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean },
ticks: number,
intervalMs: number,
now: number,
tag?: string,
damageSchool?: DamageSchool,
spellPenetration?: number,
): TimedCombatEffect {
effectSequence += 1;
return {
id: `${abilityId}:${effectSequence}`,
kind,
sourceAbilityId: abilityId,
sourceActorId,
targetId,
coefficient: scaling.coefficient,
...(scaling.basePoints === undefined ? {} : { basePoints: scaling.basePoints }),
...(scaling.dieSides === undefined ? {} : { dieSides: scaling.dieSides }),
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }),
...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }),
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}),
remainingTicks: ticks,
intervalMs,
nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs),
...(tag ? { tag } : {}),
...(damageSchool ? { damageSchool } : {}),
...(spellPenetration ? { spellPenetration } : {}),
};
}
function detonateLivingBombInWork(work: MutableCastWork, effect: TimedCombatEffect, now: number): void {
if (effect.kind !== "dot" || !effect.targetId) return;
const source = abilityById(effect.sourceAbilityId);
if (source?.name !== "Living Bomb") return;
const center = getMobPosition(effect.targetId);
if (!center) return;
const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId);
const ability = abilityAtLevel(source, sourceMember?.level ?? work.progression.level);
const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount(
effect,
sourceMember?.level ?? work.progression.level,
sourceMember?.gearStats ?? work.gearStats,
sourceMember?.classId ?? work.classId,
ability,
);
const explosionAmount = Math.max(1, Math.round(tickAmount * 2));
for (const id of nearbyMobRuntimeIds(center, 10)) {
if (work.mobs[id]?.dead) continue;
damageMobInWork(work.classId, work, id, explosionAmount, now,
threatSourceForActor(work, effect.sourceActorId, effect.sourceAbilityId, true), "fire",
sourceMember?.gearStats.spellPenetration ?? work.gearStats.spellPenetration);
}
emitCombatPresentation({
occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party",
sourceActorId: effect.sourceActorId, targetActorId: effect.targetId,
abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name,
source: presentationSourceForAbility(ability), school: "fire", delivery: "area",
origin: center, targetPosition: center,
});
}
function detonateLivingBombFromStore(effect: TimedCombatEffect, now: number): void {
if (!effect.targetId || abilityById(effect.sourceAbilityId)?.name !== "Living Bomb") return;
const center = getMobPosition(effect.targetId);
if (!center) return;
const state = useCombatStore.getState();
const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId);
const source = abilityById(effect.sourceAbilityId)!;
const ability = abilityAtLevel(source, sourceMember?.level ?? state.level);
const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount(effect,
sourceMember?.level ?? state.level, sourceMember?.gearStats ?? state.gearStats,
sourceMember?.classId ?? state.classId, ability);
for (const id of nearbyMobRuntimeIds(center, 10)) {
if (useCombatStore.getState().mobs[id]?.dead) continue;
useCombatStore.getState().damageMob(id, Math.max(1, Math.round(tickAmount * 2)), now,
threatSourceForActor(useCombatStore.getState(), effect.sourceActorId, effect.sourceAbilityId, true), "fire");
}
emitCombatPresentation({
occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party",
sourceActorId: effect.sourceActorId, targetActorId: effect.targetId,
abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name,
source: presentationSourceForAbility(ability), school: "fire", delivery: "area",
origin: center, targetPosition: center,
});
}
interface RoguePoisonSourceStats {
readonly classId: ClassId;
readonly level: number;
readonly attackPower: number;
readonly spellPenetration: number;
}
function roguePoisonSourceStats(
work: MutableCastWork,
sourceActorId: AggroActorId,
): RoguePoisonSourceStats | null {
if (sourceActorId === PLAYER_AGGRO_ID) {
if (baseClassFor(work.classId) !== "rogue") return null;
const stats = playerCharacterStats(
work.classId,
work.progression.level,
work.gearStats,
work.raceId,
work.secondaryClassId ?? null,
work.talentModifiers,
);
return {
classId: work.classId,
level: work.progression.level,
attackPower: stats.attackPower,
spellPenetration: work.gearStats.spellPenetration,
};
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
if (!member || baseClassFor(member.classId) !== "rogue") return null;
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
return {
classId: member.classId,
level: member.level,
attackPower: stats.attackPower,
spellPenetration: member.gearStats.spellPenetration,
};
}
function replaceDeadlyPoisonEffect(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
stacks: number,
now: number,
refreshDuration: boolean,
): void {
const ability = abilityById("rogue-deadly-poison");
const source = roguePoisonSourceStats(work, sourceActorId);
const existing = work.effects.find((effect) => (
effect.kind === "dot"
&& effect.sourceActorId === sourceActorId
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rogue-deadly-poison"
));
work.effects = work.effects.filter((effect) => effect !== existing);
if (!ability || !source || stacks <= 0) return;
const amount = rogueDeadlyPoisonTickDamage(source.level, source.attackPower, stacks);
if (!refreshDuration && existing) {
work.effects.push({ ...existing, fixedAmount: amount });
return;
}
work.effects.push({
...timedEffect(
"dot",
ability.id,
sourceActorId,
targetId,
{ coefficient: 0 },
4,
3_000,
now,
ROGUE_DEADLY_POISON_DOSE_TAG,
"nature",
source.spellPenetration,
),
fixedAmount: amount,
});
}
function applyRogueWeaponPoisonsInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount: number,
now: number,
): RoguePoisonProcResult {
const source = roguePoisonSourceStats(work, sourceActorId);
const target = work.mobs[targetId];
if (!source || !target || target.dead) {
return { instantProcs: 0, deadlyApplications: 0, deadlyStacks: 0, damage: 0 };
}
const instantCoating = hasActiveAuraTag(work.auras, ROGUE_INSTANT_POISON_TAG, sourceActorId, now);
const deadlyCoating = hasActiveAuraTag(work.auras, ROGUE_DEADLY_POISON_COATING_TAG, sourceActorId, now);
const envenomBuff = hasActiveAuraTag(work.auras, ROGUE_ENVENOM_BUFF_TAG, sourceActorId, now);
const isShiv = abilityById(sourceAbilityId)?.name === "Shiv";
const chances = roguePoisonApplicationChances(envenomBuff);
let instantProcs = 0;
let deadlyApplications = 0;
let damage = 0;
const applyInstant = (): void => {
if (!instantCoating || work.mobs[targetId]?.dead) return;
const result = damageMobInWork(
work.classId,
work,
targetId,
rogueInstantPoisonDamage(source.level, source.attackPower),
now,
threatSourceForActor(work, sourceActorId, "rogue-instant-poison", true),
"nature",
source.spellPenetration,
);
if (result.amount <= 0) return;
instantProcs += 1;
damage += result.amount;
};
for (let hitIndex = 0; hitIndex < Math.max(1, Math.floor(hitCount)); hitIndex += 1) {
if (work.mobs[targetId]?.dead) break;
if (!isShiv && instantCoating && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:instant`,
) < chances.instant) {
applyInstant();
}
if (!deadlyCoating || (!isShiv && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:deadly`,
) >= chances.deadly)) continue;
const previousStacks = matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
);
const application = applyCombatAura(work.auras, {
definition: ROGUE_DEADLY_POISON_AURA,
sourceId: sourceActorId,
targetId,
now,
});
work.auras = [...application.auras];
deadlyApplications += 1;
recordCombatEventInWork(work, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId: targetId,
abilityId: "rogue-deadly-poison",
school: "nature",
rawAmount: 0,
effectiveAmount: application.aura.stacks,
overheal: 0,
absorbed: 0,
critical: false,
});
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, application.aura.stacks, now, true);
// At five doses, another Deadly Poison application triggers the poison on
// the other weapon instead of creating a sixth dose.
if (previousStacks >= 5) applyInstant();
}
return {
instantProcs,
deadlyApplications,
deadlyStacks: matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
),
damage,
};
}
function consumeRogueDeadlyPoisonInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now: number,
): number {
const index = work.auras.findIndex((aura) => (
aura.sourceId === sourceActorId
&& aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
));
if (index < 0) return 0;
const aura = work.auras[index];
const consumed = Math.min(aura.stacks, Math.max(0, Math.floor(maximumDoses)));
const remaining = aura.stacks - consumed;
if (remaining <= 0) work.auras.splice(index, 1);
else work.auras[index] = { ...aura, stacks: remaining };
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, remaining, now, false);
return consumed;
}
function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number {
return aurasForEntity(auras, entityId).reduce((total, aura) => (
total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0)
), 0);
}
function ensureCompanionActorInWork(work: MutableCastWork, id: string): CombatActorState | null {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
if (!member) return null;
const existing = work.actors[id];
const actor = companionActorFromMember(member, entityAbsorbAmount(work.auras, id), existing);
work.actors[id] = actor;
return actor;
}
function mirrorCompanionActor(actor: CombatActorState): void {
if (actor.kind !== "companion") return;
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actor.id);
if (!member) return;
const mirrored = memberFromCompanionActor(member, actor);
usePartyStore.getState().updateMemberCombat(actor.id, {
health: mirrored.health,
maxHealth: mirrored.maxHealth,
resource: mirrored.resource,
maxResource: mirrored.maxResource,
resourcePools: mirrored.resourcePools,
lastResourceSpentAt: mirrored.lastResourceSpentAt,
cooldowns: mirrored.cooldowns,
globalCooldownEndsAt: mirrored.globalCooldownEndsAt,
activeCast: mirrored.activeCast,
activeCastTargetId: mirrored.activeCastTargetId,
schoolLockedUntil: mirrored.schoolLockedUntil,
runes: mirrored.runes,
controlledUntil: mirrored.controlledUntil,
status: mirrored.status,
});
}
function applyAuraHealthTransitionInWork(
work: MutableCastWork,
targetId: string,
previousAuras: readonly ActiveAura[],
nextAuras: readonly ActiveAura[],
): void {
if (targetId === PLAYER_AURA_ENTITY_ID) {
const maxHealth = auraAdjustedPlayerMaxHealth(
work.classId,
work.progression.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
nextAuras,
);
const delta = maxHealth - work.maxHealth;
if (!delta) return;
work.maxHealth = maxHealth;
work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth);
work.progression.maxHealth = work.maxHealth;
work.progression.health = work.health;
const actor = work.actors[targetId];
if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth };
return;
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId);
const actor = ensureCompanionActorInWork(work, targetId);
if (!member || !actor) return;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, nextAuras, targetId)
- auraMaximumHealthBonus(base, previousAuras, targetId);
if (!delta) return;
const maxHealth = Math.max(1, actor.maxHealth + delta);
const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth);
const nextActor = Object.freeze({ ...actor, health, maxHealth });
work.actors[targetId] = nextActor;
mirrorCompanionActor(nextActor);
}
/** Ordinary healing never transitions a corpse back to a living actor. */
function healPlayerInWork(work: MutableCastWork, amount: number): number {
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (work.health <= 0 || requested <= 0) return 0;
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
work.health += healed;
work.progression.health = work.health;
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
return healed;
}
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (!actor || actor.health <= 0 || requested <= 0) return 0;
const healed = Math.min(requested, actor.maxHealth - actor.health);
if (healed <= 0) return 0;
const next = Object.freeze({ ...actor, health: actor.health + healed, alive: true });
work.actors[id] = next;
mirrorCompanionActor(next);
return healed;
}
function reviveCompanionActorInWork(work: MutableCastWork, id: string, percentMaxHealth: number): number {
const actor = ensureCompanionActorInWork(work, id);
if (!actor || actor.health > 0) return 0;
const health = Math.max(1, Math.round(actor.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth))));
const next = Object.freeze({ ...actor, health, alive: true, controlledUntil: 0, cast: null });
work.actors[id] = next;
mirrorCompanionActor(next);
usePartyStore.setState((party) => ({
members: party.members.map((member) => member.id === id
? { ...member, status: "ready", controlMechanic: null }
: member),
}));
return health;
}
function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (!actor || actor.health <= 0 || requested <= 0) return 0;
const applied = Math.min(actor.health, requested);
const health = actor.health - applied;
const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null });
work.actors[id] = next;
mirrorCompanionActor(next);
if (health <= 0) {
usePartyStore.setState((party) => ({
members: party.members.map((member) => member.id === id
? { ...member, status: "down", activeCast: null, activeCastTargetId: null }
: member),
}));
}
return applied;
}
function clearCompanionControlInWork(work: MutableCastWork, id: string): boolean {
const actor = ensureCompanionActorInWork(work, id);
if (!actor || actor.controlledUntil <= 0) return false;
const next = Object.freeze({ ...actor, controlledUntil: 0 });
work.actors[id] = next;
mirrorCompanionActor(next);
usePartyStore.setState((party) => ({
members: party.members.map((member) => member.id === id
? { ...member, controlMechanic: null }
: member),
}));
return true;
}
function combatActorSnapshots(state: CombatState): Readonly<Record<string, CombatActorState>> {
const actors: Record<string, CombatActorState> = {};
const resourceProfile = resourceProfileForClass(state.classId);
const playerResources = Object.fromEntries(
Object.entries(state.maxResourcePools).map(([id, maximum]) => [id, normalizeActorResourcePool(
id,
state.resourcePools[id as ResourceType] ?? 0,
maximum ?? 0,
id === resourceProfile.type ? resourceProfile.regenerationPerSecond : 0,
state.lastResourceSpentAt[id as ResourceType] ?? 0,
)]),
);
if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = {
id: PLAYER_AURA_ENTITY_ID,
threatActorId: PLAYER_AURA_ENTITY_ID,
kind: "player",
classId: state.classId,
level: state.level,
health: state.health,
maxHealth: state.maxHealth,
absorb: state.shield + entityAbsorbAmount(state.auras, PLAYER_AURA_ENTITY_ID),
resources: playerResources,
cooldowns: state.cooldowns,
globalCooldownEndsAt: state.globalCooldownEndsAt,
cast: state.activeCast ? {
abilityId: state.activeCast.abilityId,
school: state.activeCast.school,
mode: state.activeCast.mode,
startedAt: state.activeCast.startedAt,
completesAt: state.activeCast.completesAt,
originalDurationMs: state.activeCast.originalDurationMs,
pushbackHits: state.activeCast.pushbackHits,
} : null,
controlledUntil: state.controlledUntil,
schoolLockedUntil: state.schoolLockedUntil,
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
runes: state.deathKnightRunes,
alive: state.health > 0,
};
for (const member of usePartyStore.getState().members) {
actors[member.id] = companionActorFromMember(
member,
entityAbsorbAmount(state.auras, member.id),
state.actors[member.id],
);
}
for (const actor of onlinePlayerRuntimeActors()) actors[actor.id] = actor;
for (const mob of Object.values(state.mobs)) {
actors[mob.id] = {
id: mob.id,
threatActorId: mob.id,
kind: "enemy",
level: mob.level,
health: mob.health,
maxHealth: mob.maxHealth,
absorb: entityAbsorbAmount(state.auras, mob.id),
resources: mob.maxMana > 0
? { mana: normalizeActorResourcePool("mana", mob.maxMana, mob.maxMana) }
: {},
cooldowns: mob.abilityReadyAt,
globalCooldownEndsAt: 0,
cast: mob.activeCast ? {
abilityId: mob.activeCast.attackId,
school: mob.activeCast.school,
mode: "cast",
startedAt: mob.activeCast.startedAt,
completesAt: mob.activeCast.completesAt,
originalDurationMs: mob.activeCast.completesAt - mob.activeCast.startedAt,
pushbackHits: 0,
} : null,
controlledUntil: Math.max(0, ...mob.statuses.map((status) => status.endsAt)),
schoolLockedUntil: mob.schoolLockedUntil,
comboPoints: 0,
comboTargetId: null,
runes: [],
alive: !mob.dead,
};
}
for (const summon of state.summons) {
actors[summon.id] = combatActorFromSummon(summon, state.level, state.auras);
}
return Object.freeze(actors);
}
function initialState() {
const classId: ClassId = "priest";
const raceId: RaceId = "human";
const level = 1;
const resource = resourceProfileForClass(classId);
const talentRanks: TalentRanks = {};
const talentModifiers = characterStatModifiers(classId, level, talentRanks);
const maxHealth = talentAdjustedMaxHealth(classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId);
const maxResource = talentAdjustedMaxResource(
classId,
level,
talentModifiers,
EMPTY_ITEM_STATS,
raceId,
);
const abilities = abilitiesForClass(classId);
const initialPools = initialResourcePools(classId, abilities, maxResource);
return {
classId,
specializationId: null,
gearRole: "healer" as PartyRole,
secondaryClassId: null,
raceId,
level,
xp: 0,
xpToNext: xpRequiredForNextLevel(level),
health: maxHealth,
maxHealth,
controlledUntil: 0,
controlMechanic: null,
shield: 0,
shieldExpiresAt: 0,
resource: initialResourceForMaximum(classId, maxResource),
maxResource,
resourcePools: initialPools.pools,
maxResourcePools: initialPools.maximums,
lastResourceSpentAt: {},
resourceName: resource.name,
comboPoints: 0,
comboTargetId: null,
deathKnightRunes: [],
abilities,
cooldowns: {},
globalCooldownEndsAt: 0,
activeCast: null,
schoolLockedUntil: {},
playerAnimationEvent: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
mobs: {},
selectedTargetId: null,
selectedFriendlyActorId: null,
effects: [],
auras: [],
summons: [],
areaEffects: [],
pendingGroundTarget: null,
actors: {},
combatEvents: [],
feedback: null,
lastCombatResult: null,
bossLootRewards: [] as readonly BossLootReward[],
talentRanks,
talentModifiers,
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
inventory: [] as readonly InventoryItem[],
bossCards: emptyBossCardCollection(),
equipment: {} as EquipmentAssignments,
gearStats: EMPTY_ITEM_STATS,
settings: DEFAULT_GAMEPLAY_SETTINGS,
playerPosition: [0, 0, 0] as CombatPosition,
};
}
export const useCombatStore = create<CombatState>((set, get) => ({
...initialState(),
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const specialization = specializationById(profile.specializationId);
const specializationId = specialization?.classId === classId ? specialization.id : null;
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
const resource = resourceProfileForClass(classId);
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, inventory);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
specializationId,
role: gearRole,
level: progression.level,
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
const maxHealth = talentAdjustedMaxHealth(
classId,
progression.level,
talentModifiers,
gearStats,
raceId,
secondaryClassId,
);
const maxResource = talentAdjustedMaxResource(
classId,
progression.level,
talentModifiers,
gearStats,
raceId,
secondaryClassId,
);
// RoM secondary pools use native resource limits, not the mapped WoW class's mana.
const initialPools = initialResourcePools(classId, abilities, maxResource,
isRomClassId(classId) ? defaultResourceMaximum("mana")
: deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana);
set((state) => ({
...initialState(),
classId,
specializationId,
gearRole,
secondaryClassId,
raceId,
level: progression.level,
xp: progression.xp,
xpToNext: xpRequiredForNextLevel(progression.level),
health: maxHealth,
maxHealth,
resource: initialResourceForMaximum(classId, maxResource),
maxResource,
resourcePools: initialPools.pools,
maxResourcePools: initialPools.maximums,
resourceName: resource.name,
abilities,
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks,
talentModifiers,
actionBindings: withoutUnlearnedTalentBindings(
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
abilities,
talentRanks,
),
inventory,
bossCards: normalizeBossCardCollection(profile.bossCards),
equipment,
gearStats,
settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}),
playerPosition: state.playerPosition,
}));
},
refreshResolvedGear: () => set((state) => {
const context: HeirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
};
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
return {
gearStats,
maxHealth,
maxResource,
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
};
}),
registerMob: (id, registration) => set((state) => {
if (!id) return state;
const existing = state.mobs[id];
const boss = registration.boss ?? existing?.boss ?? false;
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
const creatureFamily = registration.creatureFamily
?? (registration.serverEntry === undefined && existing?.creatureFamily
? existing.creatureFamily : romCreatureFamilyForServerEntry(serverEntry));
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
const resistances: MobMagicResistances = {
arcane: Math.max(0, registration.resistances?.arcane ?? existing?.resistances.arcane ?? 0),
fire: Math.max(0, registration.resistances?.fire ?? existing?.resistances.fire ?? 0),
frost: Math.max(0, registration.resistances?.frost ?? existing?.resistances.frost ?? 0),
nature: Math.max(0, registration.resistances?.nature ?? existing?.resistances.nature ?? 0),
shadow: Math.max(0, registration.resistances?.shadow ?? existing?.resistances.shadow ?? 0),
};
const defaults = defaultMobStats(level, boss, serverEntry ?? undefined);
const mechanicImmunities = registration.mechanicImmunities
?? existing?.mechanicImmunities
?? defaults.mechanicImmunities;
const schoolImmunities = registration.schoolImmunities
?? existing?.schoolImmunities
?? defaults.schoolImmunities;
const healthMultiplier = Math.max(0.01, registration.healthMultiplier ?? existing?.healthMultiplier ?? 1);
const damageMultiplier = Math.max(0.01, registration.damageMultiplier ?? existing?.damageMultiplier ?? 1);
const bonusLootChance = clamp(
registration.bonusLootChance ?? existing?.bonusLootChance ?? 0,
0,
1,
);
const maxHealth = Math.max(1, Math.round(
registration.maxHealth
?? existing?.maxHealth
?? defaults.maxHealth * healthMultiplier,
));
const xpReward = Math.max(0, Math.round(registration.xpReward ?? existing?.xpReward ?? defaults.xpReward));
const hasLoot = registration.hasLoot ?? existing?.hasLoot ?? false;
const fallbackAttack = {
...DEFAULT_ENEMY_ATTACK,
range: boss ? 5.2 : 4.3,
cooldownMs: defaults.swingMs,
};
const attacks = registration.attacks
? normalizeEnemyAttacks(registration.attacks, fallbackAttack)
: existing?.attacks ?? normalizeEnemyAttacks(undefined, fallbackAttack);
const attackRange = maximumEnemyAttackRange(attacks);
const swingMs = defaults.swingMs;
const leashRange = Math.max(
attackRange + 1,
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
);
const aggroRange = Math.max(
0,
Math.min(
leashRange,
registration.aggroRange
?? existing?.aggroRange
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
),
);
const moveSpeed = Math.max(
0,
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
);
const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
? Math.max(1, Math.round(
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
))
: (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
defaults.attackDamage * damageMultiplier,
));
const mob: MobCombatState = existing
? {
...existing,
name: registration.name || existing.name,
serverEntry,
creatureFamily,
boss,
lootSourceId,
level,
armor: defaults.armor,
maxMana: defaults.maxMana,
attackPower: defaults.attackPower,
mechanicImmunities,
schoolImmunities,
resistances,
maxHealth,
healthMultiplier,
damageMultiplier,
bonusLootChance,
attacks,
attackRange,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackDamage,
health: existing.dead || existing.health <= 0
? 0
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
xpReward,
hasLoot,
}
: {
id,
name: registration.name || "Unknown Creature",
serverEntry,
creatureFamily,
boss,
lootSourceId,
level,
armor: defaults.armor,
maxMana: defaults.maxMana,
attackPower: defaults.attackPower,
mechanicImmunities,
schoolImmunities,
resistances,
health: maxHealth,
maxHealth,
healthMultiplier,
damageMultiplier,
bonusLootChance,
dead: false,
hasLoot,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
xpReward,
experienceGranted: false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle",
homePosition: getMobPosition(id),
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
attackRange,
attackDamage,
swingMs,
aggroRange,
leashRange,
moveSpeed,
attackRevision: 0,
attackAnimation: "attack",
attacks,
nextAttackIndex: 0,
lastAttackId: null,
woundRevision: 0,
nextWoundAnimationAt: 0,
deathRevision: 0,
};
return { mobs: { ...state.mobs, [id]: mob } };
}),
removeMob: (id) => set((state) => {
if (!state.mobs[id]) return state;
const mobs = { ...state.mobs };
delete mobs[id];
return {
mobs,
selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId,
autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints,
effects: state.effects.filter((effect) => effect.targetId !== id),
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
};
}),
lootMob: (id, now = combatNow()) => {
const mob = get().mobs[id];
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
const lootMessage = mob.pendingLoot.length
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
: `Looted ${mob.name}.`;
set((state) => {
const bossReward = mob.boss
? {
id: ++bossLootRewardSequence,
bossId: id,
bossName: mob.name,
items: mob.pendingLoot,
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
acquiredAt: now,
} satisfies BossLootReward
: null;
return {
inventory: [...state.inventory, ...mob.pendingLoot],
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
mobs: {
...state.mobs,
[id]: {
...state.mobs[id],
looted: true,
corpseDespawnAt,
despawned: now >= corpseDespawnAt,
},
},
...(bossReward
? { bossLootRewards: [...state.bossLootRewards, bossReward] }
: { feedback: nextFeedback("loot", lootMessage, { targetId: id }) }),
};
});
return true;
},
dismissBossLootReward: (id) => set((state) => ({
bossLootRewards: id === undefined
? state.bossLootRewards.slice(1)
: state.bossLootRewards.filter((reward) => reward.id !== id),
})),
collectSharedBossCards: (drop, bossName, now) => set((state) => {
const bossCards = collectBossCardDrop(state.bossCards, drop);
if (bossCards === state.bossCards) return state;
return {
bossCards,
bossLootRewards: [...state.bossLootRewards, {
id: ++bossLootRewardSequence, bossId: drop.spawnId, bossName, items: [], cards: drop, acquiredAt: now,
}],
};
}),
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
const state = get();
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? state.classId
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.classId;
if (!ownerClassId) {
set({ feedback: nextFeedback("error", "That party member is not available.") });
return false;
}
const change = equipInventoryItem(
state.equipment,
state.inventory,
ownerId,
ownerClassId,
instanceId,
);
if (!change.ok) {
set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") });
return false;
}
const item = state.inventory.find((candidate) => candidate.instanceId === instanceId);
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
set({
equipment: change.assignments,
gearStats,
maxHealth,
maxResource,
health: clamp(
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
0,
maxHealth,
),
resource: clamp(
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
0,
maxResource,
),
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
});
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
return true;
},
unequipItem: (ownerId, slot) => {
const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false;
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
set({
equipment: next,
gearStats,
maxHealth,
maxResource,
health: clamp(
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
0,
maxHealth,
),
resource: clamp(
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
0,
maxResource,
),
feedback: nextFeedback("success", "Item returned to the shared inventory."),
});
usePartyStore.getState().syncEquipment(next, state.inventory);
return true;
},
selectMob: (id) => {
const mob = get().mobs[id];
if (!mob || mob.dead) return false;
set((state) => ({
selectedTargetId: id,
autoAttackTargetId: state.autoAttackTargetId ? id : null,
nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id
? 0
: state.nextAutoAttackAt,
comboPoints: state.selectedTargetId === id ? state.comboPoints : 0,
comboTargetId: state.selectedTargetId === id ? state.comboTargetId : id,
feedback: nextFeedback("target", mob.name, { targetId: id }),
}));
return true;
},
selectNearestTarget: (origin, maxRange = 40) => {
const state = get();
const id = eligibleTargetIds(state, currentOrigin(state, origin), maxRange)[0] ?? null;
if (id) state.selectMob(id);
else state.clearTarget();
return id;
},
cycleTarget: (direction = "next", origin, maxRange = 40) => {
const state = get();
const ids = eligibleTargetIds(state, currentOrigin(state, origin), maxRange);
if (!ids.length) {
state.clearTarget();
return null;
}
const step = direction === "previous" || direction === -1 ? -1 : 1;
const currentIndex = state.selectedTargetId ? ids.indexOf(state.selectedTargetId) : -1;
const nextIndex = currentIndex < 0
? (step > 0 ? 0 : ids.length - 1)
: (currentIndex + step + ids.length) % ids.length;
const id = ids[nextIndex];
state.selectMob(id);
return id;
},
clearTarget: () => set((state) => ({
selectedTargetId: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
comboPoints: 0,
comboTargetId: null,
feedback: state.selectedTargetId
? nextFeedback("target", "Target cleared.")
: state.feedback,
})),
selectFriendlyActor: (id) => {
if (id === null) {
set({ selectedFriendlyActorId: null });
return true;
}
const state = get();
const actor = state.actors[id];
if (!actor || !["player", "companion", "pet", "totem", "summon"].includes(actor.kind)) return false;
set({
selectedFriendlyActorId: id,
feedback: nextFeedback("target", `Friendly target selected: ${state.summons.find((summon) => summon.id === id)?.name ?? id}.`, { targetId: id }),
});
return true;
},
setPlayerPosition: (position) => set((state) => {
const next: CombatPosition = [position[0], position[1], position[2]];
const cast = state.activeCast;
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
const positionUnchanged = (
(next[0] - state.playerPosition[0]) ** 2
+ (next[1] - state.playerPosition[1]) ** 2
+ (next[2] - state.playerPosition[2]) ** 2
) <= 1e-8;
if (positionUnchanged && !movedWhileCasting) return state;
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
occurredAt: combatNow(),
kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
abilityId: cast!.abilityId,
school: cast!.school,
rawAmount: 0,
effectiveAmount: 0,
overheal: 0,
absorbed: 0,
critical: false,
outcome: "movement",
}) : state.combatEvents,
playerAnimationEvent: movedWhileCasting
? nextAnimationEvent("ability-cancel", cast!.abilityId)
: state.playerAnimationEvent,
feedback: movedWhileCasting
? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId })
: state.feedback,
};
}),
beginGroundTarget: (abilityId) => {
const ability = abilityById(abilityId);
if (!ability || targetingRulesForAbility(ability).shape !== "ground") return false;
const state = get();
const targeting = targetingRulesForAbility(ability);
const maximumRange = targeting.groundRange ?? ability.range.max;
const player = combatSpatialActor(PLAYER_AURA_ENTITY_ID);
const yaw = player?.yaw ?? 0;
const distance = Math.min(10, Math.max(0, maximumRange));
const requested: CombatPosition = [
state.playerPosition[0] + Math.sin(yaw) * distance,
state.playerPosition[1],
state.playerPosition[2] + Math.cos(yaw) * distance,
];
const projected = projectCombatGroundPoint(requested, maximumRange, state.playerPosition);
set({
pendingGroundTarget: {
abilityId,
maximumRange,
radius: targeting.radius ?? ability.radius ?? 4,
position: projected ?? requested,
valid: projected !== null && (!targeting.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)),
},
feedback: nextFeedback("target", `Choose a location for ${ability.name}.`, { abilityId }),
});
return true;
},
moveGroundTarget: (moveX, moveForward, yaw, deltaSeconds) => set((state) => {
const pending = state.pendingGroundTarget;
if (!pending) return state;
const magnitude = Math.hypot(moveX, moveForward);
if (magnitude < 0.05 || !Number.isFinite(deltaSeconds)) return state;
const normalizedX = moveX / Math.max(1, magnitude);
const normalizedForward = moveForward / Math.max(1, magnitude);
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
const worldX = -normalizedX * Math.cos(safeYaw) + normalizedForward * Math.sin(safeYaw);
const worldZ = normalizedX * Math.sin(safeYaw) + normalizedForward * Math.cos(safeYaw);
const speed = 10;
const requested: CombatPosition = [
pending.position[0] + worldX * speed * clamp(deltaSeconds, 0, 0.1),
pending.position[1],
pending.position[2] + worldZ * speed * clamp(deltaSeconds, 0, 0.1),
];
const projected = projectCombatGroundPoint(requested, pending.maximumRange, state.playerPosition);
const ability = abilityById(pending.abilityId);
const targeting = ability ? targetingRulesForAbility(ability) : null;
const valid = Boolean(projected
&& Math.sqrt(distanceSquared(state.playerPosition, projected)) <= pending.maximumRange
&& (!targeting?.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)));
return {
pendingGroundTarget: {
...pending,
position: projected ?? pending.position,
valid,
},
};
}),
confirmGroundTarget: (position, now = combatNow()) => {
const pending = get().pendingGroundTarget;
if (!pending) return emptyCastResult("invalid-destination");
const destination = position ?? pending.position;
if (!pending.valid && position === undefined) return emptyCastResult("invalid-destination", pending.abilityId);
set({ pendingGroundTarget: null });
return get().castAbility(pending.abilityId, { origin: get().playerPosition, groundPosition: destination, now });
},
cancelGroundTarget: () => set({ pendingGroundTarget: null }),
synchronizeActors: () => {
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
actors = combatActorSnapshots(state);
return { actors };
});
usePartyStore.setState((party) => ({
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
}));
},
applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
level: state.level,
abilities: state.abilities,
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
level: member.level,
abilities: abilitiesForClass(member.classId),
})),
];
const selected = new Map<string, {
sourceId: string;
ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number;
}>();
for (const source of sources) {
for (const catalogAbility of source.abilities) {
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
if (!effects.length) continue;
const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency });
}
}
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) {
for (const recipientId of recipients) {
for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect,
aura: {
...effect.aura,
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`,
},
}, now);
}
}
}
get().synchronizeActors();
},
applyPrecombatPreparation: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
specialization: specializationById(state.specializationId)?.displayName ?? null,
role: state.gearRole,
level: state.level,
talentRanks: state.talentRanks,
abilities: state.abilities,
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
classId: member.classId,
secondaryClassId: member.secondaryClassId ?? null,
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
level: member.level,
talentRanks: member.talentRanks,
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
})),
];
for (const source of sources) {
const rotation = rotationProfileFor(source.classId, source.specializationId, source.specialization);
const priority = (ability: AbilityDefinition) => {
const index = rotation?.abilityPriority.indexOf(ability.name) ?? -1;
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
};
const abilities = source.abilities
.map((ability) => learnedAbilityForBuild(ability, source.level, source.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter(abilityIsPrecombatPreparation)
.filter((ability) => !rotation || priority(ability) < Number.MAX_SAFE_INTEGER)
.sort((left, right) => priority(left) - priority(right) || left.name.localeCompare(right.name));
for (const ability of abilities) {
const effects = precombatPreparationEffects(ability);
const auraEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura"
));
const current = get();
const maintained = current.auras.some((aura) => (
aura.targetId === source.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& auraEffects.some((effect) => effect.aura.id === aura.definition.id)
));
const blockedByExclusiveGroup = current.auras.some((aura) => (
aura.targetId === source.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& Boolean(aura.definition.exclusiveGroup)
&& auraEffects.some((effect) => effect.aura.exclusiveGroup === aura.definition.exclusiveGroup)
));
if (!maintained && !blockedByExclusiveGroup) {
for (const effect of auraEffects) get().applyActorAura(source.id, source.id, ability, effect, now);
}
const petEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "pet" }> => (
effect.kind === "pet"
));
if (!petEffects.length) continue;
const hasActivePet = get().summons.some((summon) => {
if (summon.ownerActorId !== source.id || summon.expiresAt <= now) return false;
const summonAbility = abilityById(summon.sourceAbilityId);
return summonAbility?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
});
if (hasActivePet) continue;
const position = actorPosition(get(), source.id) ?? get().playerPosition;
const petEffect = petEffects[0];
get().summonActor(source.id, ability, {
kind: "summon",
creatureId: petEffect.creatureId ?? 1,
coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now,
}, position, null, now);
}
}
get().synchronizeActors();
},
commitCompanionActors: (members) => {
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
const next = { ...state.actors };
for (const member of members) {
const existing = next[member.id] ?? companionActorFromMember(
member,
entityAbsorbAmount(state.auras, member.id),
);
next[member.id] = companionActorWithMemberRuntime(existing, member);
}
actors = Object.freeze(next);
return { actors };
});
usePartyStore.setState((party) => ({
members: party.members.map((member) => {
const runtime = members.find((candidate) => candidate.id === member.id) ?? member;
return memberFromCompanionActor(runtime, actors[member.id]);
}),
}));
},
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
const request: CastRequest = Array.isArray(requestOrOrigin)
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
const origin = request.origin;
const now = request.now ?? legacyNow;
const completingCast = request.completingCast ?? legacyCompletingCast;
const state = get();
const party = usePartyStore.getState();
const catalogAbility = abilityById(abilityId);
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
let ability = catalogAbility
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
: null;
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) =>
aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) {
const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage"
? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost);
ability = {
...ability,
cooldownMs: Math.max(0, ability.cooldownMs - 20_000),
cost: adjustedCosts[0] ?? ability.cost,
costs: adjustedCosts,
};
}
const innerFocusAura = activePlayerAuras.find((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
));
const consumesInnerFocus = Boolean(
innerFocusAura
&& ability?.name !== "Inner Focus"
&& ability?.effects.some((effect) => effect.kind === "heal"),
);
const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag));
const holyAuraFriendly = Boolean(
ability?.source === "runewaker"
&& ability.name === "Holy Aura"
&& party.selectedMemberId
&& hasRuneWakerState("rom-holy-salvation-candle")
);
const effectiveTarget = holyAuraFriendly
? "friendly"
: ability?.target === "any"
? completingCast
? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly"
: state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile"
: ability?.target;
const modifiers: readonly TalentModifier[] = consumesInnerFocus ? [
...state.talentModifiers,
{
kind: "critical-chance-percent",
amount: 25,
unit: "percent",
scope: "your healing spells",
sourceTalentId: "talent-348:active",
sourceTalentName: "Inner Focus",
rank: 1,
description: "The next direct or area heal costs no resource and gains 25 percentage points of critical chance.",
},
] : state.talentModifiers;
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const baseTargeting = ability ? targetingRulesForAbility(ability) : null;
const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot")
? { ...baseTargeting, maximumTargets: 5 }
: baseTargeting;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
? runeCostForAbility(ability)
: null;
let failure: CastFailureReason | undefined;
if (!ability) failure = "unknown-ability";
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
else if (talentNodeId && !learnedTalentRank) failure = "not-learned";
else if (ability.passive) failure = "passive";
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
else if (state.health <= 0) failure = "player-dead";
else if (
state.controlledUntil > now
&& enemyControlPreventsActions(state.controlMechanic)
) 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"))
) failure = "player-controlled";
else if (state.activeCast && !completingCast) failure = "already-casting";
else if (!completingCast && state.globalCooldownEndsAt > now) failure = "global-cooldown";
else if (!completingCast && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
else if (abilityRequiresOutOfCombat(ability)
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
else if (ability.name === "Enraged Regeneration"
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount
: (cost.resource === resourceProfileForClass(state.classId).type
? state.resource
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
const playerOrigin = currentOrigin(state, origin);
// `null` is a meaningful snapshotted target for friendly casts: it means
// the player. Do not fall through to a newly selected ally on completion.
let targetId = completingCast
? completingCast.targetId
: request.targetActorId !== undefined
? request.targetActorId
: effectiveTarget === "hostile"
? state.selectedTargetId
: effectiveTarget === "friendly"
? state.selectedFriendlyActorId ?? party.selectedMemberId
: null;
let targetPosition: CombatPosition | null = null;
if (!failure && ability && effectiveTarget === "hostile" && targeting?.shape !== "ground") {
let target = targetId ? state.mobs[targetId] : null;
if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) {
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers);
const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => {
const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin));
return distance >= minimumRange;
});
targetId = eligible ?? null;
target = targetId ? state.mobs[targetId] : null;
}
if (!targetId || !target) failure = "no-target";
else if (target.dead) failure = "target-dead";
else {
targetPosition = getMobPosition(targetId);
if (!targetPosition) failure = "target-unavailable";
else {
const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin));
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
}
}
} else if (!failure && ability && effectiveTarget === "friendly" && targetId) {
const partyTarget = party.members.find((member) => member.id === targetId);
const target = state.actors[targetId]
?? (partyTarget ? companionActorFromMember(partyTarget, entityAbsorbAmount(state.auras, targetId)) : null);
if (!target || !["companion", "pet", "totem", "summon"].includes(target.kind)) failure = "target-unavailable";
else if (!target.alive && !ability.effects.some((effect) => effect.kind === "resurrection")) {
failure = "target-dead";
}
else {
targetPosition = getPartyRuntimePosition(targetId)
?? state.summons.find((summon) => summon.id === targetId)?.position
?? null;
if (!targetPosition) failure = "target-unavailable";
else {
const distance = Math.sqrt(distanceSquared(playerOrigin, targetPosition));
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
}
}
}
if (!failure && ability && targeting?.requiresLineOfSight && targetPosition) {
if (!combatHasLineOfSight(playerOrigin, targetPosition)) failure = "line-of-sight";
}
if (!failure && ability?.name === "Kill Shot" && targetId) {
const target = state.mobs[targetId];
if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met";
}
if (!failure && ability?.name === "Power Word: Shield"
&& hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met";
if (!failure && ability?.source === "runewaker") {
const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined;
if (ability.dbcSpellId === 491613 && family === "living"
&& !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met";
if (ability.dbcSpellId === 490347 && family === "undead") failure = "condition-not-met";
if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
if (ability.dbcSpellId === 490151
&& (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met";
if (ability.dbcSpellId === 490347 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met";
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met";
}
}
const playerSpatial = combatSpatialActor(PLAYER_AGGRO_ID);
const targetSpatial = targetId ? combatSpatialActor(targetId) : null;
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresFacing && playerSpatial && targetPosition) {
if (!isPositionInFacingArc(playerSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) {
failure = "not-facing";
}
}
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresBehindTarget && targetSpatial) {
if (!isSourceBehindTarget(playerOrigin, targetSpatial)) failure = "must-be-behind";
}
let groundPosition: CombatPosition | null = completingCast?.groundPosition ?? null;
if (!failure && ability && targeting?.shape === "ground") {
const requestedGround = request.groundPosition ?? targetPosition ?? playerOrigin;
const maximumGroundRange = talentAdjustedRange(targeting.groundRange ?? ability.range.max, ability, modifiers);
const projected = projectCombatGroundPoint(requestedGround, maximumGroundRange, playerOrigin);
if (!projected || Math.sqrt(distanceSquared(playerOrigin, projected)) > maximumGroundRange) failure = "invalid-destination";
else if (targeting.requiresLineOfSight && !combatHasLineOfSight(playerOrigin, projected)) failure = "line-of-sight";
else groundPosition = [projected[0], projected[1], projected[2]];
}
const autoAttackTarget = targetId ? state.mobs[targetId] : null;
const startsAutoAttack = Boolean(
ability
&& effectiveTarget === "hostile"
&& abilityAttackKind(state.classId, ability) === "melee"
&& targetId
&& autoAttackTarget
&& !autoAttackTarget.dead
&& state.abilities.some((entry) => entry.id === ability.id)
&& !ability.passive
&& isAbilityUnlocked(ability, state.level)
&& state.health > 0,
);
const autoAttackUpdate = startsAutoAttack && targetId
? {
autoAttackTargetId: targetId,
nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now,
}
: {};
if (failure || !ability) {
const reason = failure ?? "unknown-ability";
set({
feedback: nextFeedback("error", castFailureMessage(reason, state.resourceName, ability?.unlockLevel), { abilityId }),
...autoAttackUpdate,
...(completingCast
? { activeCast: null, playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
: {}),
});
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
return emptyCastResult(reason, abilityId);
}
let affectedMobIds: string[] = [];
if (targeting?.shape === "chain" && effectiveTarget === "hostile" && targetId && targetPosition) {
let previous = targetPosition;
const ordered = [targetId, ...Object.keys(state.mobs).filter((id) => id !== targetId).sort()];
for (const id of ordered) {
if (affectedMobIds.length >= (targeting.maximumTargets ?? 3)) break;
const mob = state.mobs[id];
const position = getMobPosition(id);
if (!mob || mob.dead || !position) continue;
if (affectedMobIds.length > 0 && Math.sqrt(distanceSquared(previous, position)) > (targeting.jumpRange ?? 12)) continue;
if (!combatHasLineOfSight(previous, position)) continue;
affectedMobIds.push(id);
previous = position;
}
} else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) {
const directionX = targetPosition[0] - playerOrigin[0];
const directionZ = targetPosition[2] - playerOrigin[2];
const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ));
const reach = targetDistance + 4;
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => {
const mob = state.mobs[id];
const position = getMobPosition(id);
if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false;
const offsetX = position[0] - playerOrigin[0];
const offsetZ = position[2] - playerOrigin[2];
const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance;
const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance;
return forward >= 0 && forward <= reach && sideways <= 1.25;
});
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
const mob = state.mobs[id];
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position
&& combatHasLineOfSight(playerOrigin, position)
&& (!playerSpatial || isPositionInFacingArc(playerSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
});
} else if (ability.radius !== undefined || targeting?.shape === "ground") {
const center = groundPosition ?? (effectiveTarget === "hostile" ? targetPosition! : playerOrigin);
affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(targeting?.radius ?? ability.radius ?? 0, ability, modifiers)).filter((id) => {
const mob = state.mobs[id];
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(center, position));
});
} else if (effectiveTarget === "hostile" && targetId) {
affectedMobIds = [targetId];
}
if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) {
affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets);
}
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
const ordinaryCastTimeMs = talentAdjustedCastTime(
ability,
modifiers,
state.gearStats,
state.level,
state.classId,
);
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
const gcdMs = talentAdjustedGcd(
ability,
modifiers,
state.gearStats,
state.level,
state.classId,
);
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
const activeResourceType = resourceProfileForClass(state.classId).type;
const spentPools: Partial<Record<ResourceType, number>> = {
...state.resourcePools,
[activeResourceType]: state.resource,
};
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
let healthAfterCosts = state.health;
for (const cost of costsToSpend) {
if (cost.resource === "health") healthAfterCosts = Math.max(1, healthAfterCosts - cost.amount);
else {
spentPools[cost.resource] = Math.max(0, (spentPools[cost.resource] ?? 0) - cost.amount);
if (cost.amount > 0) lastResourceSpentAt[cost.resource] = now;
}
}
const spentRunes = runeCost && completingCast?.mode !== "channel"
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
: state.deathKnightRunes;
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
if (!completingCast && castTimeMs > 0) {
castSequence += 1;
const activeCast: ActiveCast = {
id: castSequence,
abilityId: ability.id,
targetId,
mode: ability.castMode === "channel" ? "channel" : "cast",
startedAt: now,
completesAt: now + castTimeMs,
originalDurationMs: castTimeMs,
pushbackHits: 0,
school: spellSchool,
canCastWhileMoving: canAbilityCastWhileMoving(ability)
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
groundPosition,
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
ticksCompleted: 0,
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / (ability.name === "Divine Hymn" ? 4 : 5) : 0,
};
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) {
channelCooldowns[ability.id] = now + cooldownMs;
}
set({
activeCast,
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("rom-last-prayer")
)) } : {}),
...autoAttackUpdate,
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
resource: activeCast.mode === "channel" ? activeResourceAfterCosts : state.resource,
resourcePools: activeCast.mode === "channel" ? spentPools : state.resourcePools,
lastResourceSpentAt: activeCast.mode === "channel" ? lastResourceSpentAt : state.lastResourceSpentAt,
deathKnightRunes: activeCast.mode === "channel" && spentRunes ? spentRunes : state.deathKnightRunes,
cooldowns: channelCooldowns,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "cast",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
abilityId: ability.id,
school: spellSchool,
rawAmount: 0,
effectiveAmount: 0,
overheal: 0,
absorbed: 0,
critical: false,
outcome: "started",
}),
globalCooldownEndsAt: now + gcdMs,
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
feedback: nextFeedback(
"success",
`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${ability.name}...`,
{ abilityId: ability.id, targetId: targetId ?? undefined },
),
});
emitPlayerAbilityPresentation(
ability,
"cast-start",
now,
playerOrigin,
targetId,
targetPosition,
);
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
}
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...combatActorSnapshots(state) },
cooldowns: { ...state.cooldowns },
health: healthAfterCosts,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: activeResourceAfterCosts,
maxResource: state.maxResource,
resourcePools: spentPools,
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt,
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: spentRunes ?? state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: modifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
if (consumesInnerFocus && innerFocusAura) {
work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId);
}
if (ability.name === "Enraged Regeneration") {
work.auras = work.auras.filter((aura) => !consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now));
}
const castThreatSource = playerThreatSource(state, ability.id);
if (targeting?.shape === "ground" && groundPosition) {
const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot");
const durationMs = periodic.reduce((maximum, effect) => (
Math.max(maximum, effect.ticks * effect.intervalMs)
), 0);
if (durationMs > 0) {
areaEffectSequence += 1;
work.areaEffects.push({
id: `area-effect-${areaEffectSequence}`,
sourceActorId: PLAYER_AGGRO_ID,
sourceAbilityId: ability.id,
position: groundPosition,
radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers),
createdAt: now,
expiresAt: now + durationMs,
tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)),
nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)),
});
}
}
const hasHostileEffect = (ability.target !== "any" || effectiveTarget === "hostile") && ability.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "finisher-damage"
|| effect.kind === "dot"
|| effect.kind === "taunt"
|| effect.kind === "status"
|| (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")
|| effect.kind === "interrupt"
|| effect.kind === "pull"
|| effect.kind === "execute"
|| effect.kind === "resource-drain"
|| effect.kind === "threat"
));
if (hasHostileEffect) {
work.auras = work.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("stealth")
));
}
if (hasHostileEffect) {
for (const id of affectedMobIds) engageMobGroupInWork(work, id, now, castThreatSource.actorId);
}
const damageSchool = abilityDamageSchool(state.classId, ability);
const attackKind = abilityAttackKind(state.classId, ability);
let playerPositionAfterEffects: CombatPosition = state.playerPosition;
let clearedPlayerControl = false;
const cooldownAdjustments: Array<Extract<AbilityEffect, { kind: "cooldown-reset" }>> = [];
const romMainHand = ability.source === "runewaker" && state.classId === "rom-rogue"
? resolvedEquipmentLoadout(state.equipment, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
})["main-hand"]
: null;
const castEffects = romEffectsForCast(ability, {
sealStacks: targetId && state.comboTargetId === targetId ? state.comboPoints : 0,
bleedCount: romTargetBleedCount(state.effects, PLAYER_AGGRO_ID, targetId),
charged: hasRuneWakerState("rom-charged"),
mainHandDagger: romMainHand?.weaponType === "dagger",
casterMaxHealth: state.maxHealth,
passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)
.flatMap((aura) => aura.definition.utilityTags ?? []),
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
romCreatureFamilyTargetTags(targetId && work.mobs[targetId]
? work.mobs[targetId].creatureFamily : "unknown"),
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
.flatMap((effect) => [
...(effect.tag ? [effect.tag] : []),
...(effect.tag?.includes("501690") ? ["rom-501690"] : []),
...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []),
]),
),
});
if (ability.dbcSpellId === 492920 && targetId
&& castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) {
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35"));
}
const effectsToResolve = completingCast?.mode === "channel"
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
: castEffects;
let clearedSingleTargetControls = false;
for (const effect of effectsToResolve) {
if (ability.name === "Penance" && effectiveTarget === "friendly"
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
if (ability.name === "Divine Hymn" && effect.kind === "apply-aura") continue;
if (
ability.target === "any"
&& effectiveTarget === "hostile"
&& ["heal", "hot", "shield", "resurrection"].includes(effect.kind)
) continue;
if (
effect.kind === "apply-aura"
&& ability.target === "any"
&& (
(effectiveTarget === "friendly" && effect.aura.disposition === "debuff")
|| (effectiveTarget === "hostile" && effect.aura.disposition === "buff" && effect.recipient !== "caster")
)
) continue;
if (effect.kind === "damage") {
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
effect,
work.progression.level,
work.gearStats,
state.classId,
ability,
) + (effect.bonusFlat ?? 0),
modifiers,
"damage-percent",
ability,
);
for (const [targetIndex, id] of affectedMobIds.entries()) {
const effectSchool = effect.school ?? damageSchool;
const sunderStacks = effect.bonusPerSunderStack
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
: 0;
const result = resolveAndDamageMobInWork(
state.classId,
work,
id,
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
now,
castThreatSource,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
ability,
false,
ability.name === "Shiv" ? { canCrit: false } : {},
);
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
if (ability.dbcSpellId === 490133) {
const bonus = romScoutRogueWhirlwindBonusEffect(
ability,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).flatMap((aura) => aura.definition.utilityTags ?? []),
work.effects.filter((active) => active.targetId === id && active.remainingTicks > 0)
.flatMap((active) => active.tag ? [active.tag] : []),
);
if (bonus) {
const bonusAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(bonus, work.progression.level, work.gearStats, state.classId, ability),
modifiers, "damage-percent", ability,
);
resolveAndDamageMobInWork(
state.classId, work, id, bonusAmount, now, castThreatSource,
"melee", "physical", `${castThreatSource.actorId}:${ability.id}:${id}:${now}:poison-bonus`, ability,
);
}
}
}
} else if (effect.kind === "finisher-damage") {
const comboPoints = work.comboPoints;
for (const id of affectedMobIds) {
const envenomDoses = effect.poison
? Math.min(
comboPoints,
effect.poison.maximumDoses,
matchingAuraStacks(
work.auras,
effect.poison.doseAuraTag,
castThreatSource.actorId,
id,
now,
),
)
: 0;
if (
effect.poison
&& envenomDoses > 0
&& !rogueEnvenomPreservesDeadlyPoison(
work.talentRanks,
`${castThreatSource.actorId}:${id}:${ability.id}:${now}:master-poisoner`,
)
) {
consumeRogueDeadlyPoisonInWork(
work,
castThreatSource.actorId,
id,
envenomDoses,
now,
);
}
const scaling = effect.poison
? {
coefficient: 0,
basePoints: effect.poison.basePointsPerDose * envenomDoses,
dieSides: 1,
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * envenomDoses,
}
: effect.baseCoefficient + effect.perComboCoefficient * comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
scaling,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
resolveAndDamageMobInWork(
state.classId,
work,
id,
amount,
now,
castThreatSource,
attackKind,
damageSchool,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:finisher`,
ability,
);
}
work.comboPoints = 0;
work.comboTargetId = null;
} else if (effect.kind === "heal") {
const partyMembers = usePartyStore.getState().members;
const friendlySummons = work.summons.filter((summon) => summon.health > 0);
const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0);
const selectedFriendly = effectiveTarget === "friendly" && targetId
? partyMembers.find((member) => member.id === targetId)
: null;
const selectedFriendlySummon = effectiveTarget === "friendly" && targetId
? friendlySummons.find((summon) => summon.id === targetId)
: null;
const selectedOnlineFriendly = effectiveTarget === "friendly" && targetId
? onlineFriendlies.find((actor) => actor.id === targetId)
: null;
const allLivingFriendlies = [
PLAYER_AURA_ENTITY_ID,
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
...friendlySummons.map((summon) => summon.id),
...onlineFriendlies.map((actor) => actor.id),
];
const friendlyPosition = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID
? playerOrigin
: getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null;
const healingCenter = groundPosition ?? (targetId ? friendlyPosition(targetId) : null) ?? playerOrigin;
let healingTargetIds: string[];
if (targeting?.shape === "chain") {
const ordered = [...allLivingFriendlies].sort((left, right) => {
const selectedId = selectedFriendly?.id ?? selectedFriendlySummon?.id ?? PLAYER_AURA_ENTITY_ID;
if (left === selectedId) return -1;
if (right === selectedId) return 1;
const leftMember = partyMembers.find((member) => member.id === left);
const rightMember = partyMembers.find((member) => member.id === right);
const leftSummon = friendlySummons.find((summon) => summon.id === left);
const rightSummon = friendlySummons.find((summon) => summon.id === right);
const leftOnline = onlineFriendlies.find((actor) => actor.id === left);
const rightOnline = onlineFriendlies.find((actor) => actor.id === right);
const leftRatio = leftMember ? leftMember.health / leftMember.maxHealth
: leftSummon ? leftSummon.health / leftSummon.maxHealth
: leftOnline ? leftOnline.health / leftOnline.maxHealth
: work.health / work.maxHealth;
const rightRatio = rightMember ? rightMember.health / rightMember.maxHealth
: rightSummon ? rightSummon.health / rightSummon.maxHealth
: rightOnline ? rightOnline.health / rightOnline.maxHealth
: work.health / work.maxHealth;
return leftRatio - rightRatio || left.localeCompare(right);
});
healingTargetIds = [];
for (const candidate of ordered) {
if (healingTargetIds.length >= (targeting.maximumTargets ?? 3)) break;
const position = friendlyPosition(candidate);
const previousPosition = healingTargetIds.length
? friendlyPosition(healingTargetIds.at(-1)!)
: playerOrigin;
if (!position || !previousPosition) continue;
if (Math.sqrt(distanceSquared(previousPosition, position)) > (targeting.jumpRange ?? 12)) continue;
if (!combatHasLineOfSight(previousPosition, position)) continue;
healingTargetIds.push(candidate);
}
} else if ((targeting?.shape === "circle" || targeting?.shape === "ground") && effectiveTarget !== "hostile") {
const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers);
healingTargetIds = allLivingFriendlies.filter((id) => {
const position = friendlyPosition(id);
return Boolean(position
&& Math.sqrt(distanceSquared(healingCenter, position)) <= radius
&& combatHasLineOfSight(healingCenter, position));
});
} else {
healingTargetIds = [selectedFriendly?.id ?? selectedFriendlySummon?.id ?? selectedOnlineFriendly?.id ?? PLAYER_AURA_ENTITY_ID];
}
const casterStats = applyAuraStats(
playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
);
const criticalChance = Math.max(0, (
casterStats.spellCritPercent
+ applyTalentModifierValue(
0,
modifiers,
"critical-chance-percent",
abilityTalentContext(ability),
)
) / 100);
let directHealing = 0;
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
for (const healingTargetId of [...new Set(healingTargetIds)]) {
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
const friendlyOnline = onlineFriendlies.find((actor) => actor.id === healingTargetId);
const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? friendlySummon?.maxHealth ?? friendlyOnline?.maxHealth ?? work.maxHealth;
const healingTargetHealth = friendlyTarget?.health ?? friendlySummon?.health ?? friendlyOnline?.health ?? work.health;
const unmodifiedAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
effect,
work.progression.level,
work.gearStats,
state.classId,
ability,
) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)),
modifiers,
"healing-percent",
ability,
);
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(
unmodifiedAmount,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
"done",
),
aurasForEntity(work.auras, healingTargetId),
"taken",
));
const resolution = resolveHealing(
amount,
healingTargetHealth,
healingTargetMaxHealth,
criticalChance,
stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal`),
);
const healed = friendlyTarget
? healCompanionActorInWork(work, healingTargetId, resolution.rawAmount)
: friendlySummon
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
: friendlyOnline
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
: healPlayerInWork(work, resolution.rawAmount);
if (friendlySummon && healed > 0) {
work.summons = work.summons.map((summon) => summon.id === healingTargetId
? { ...summon, health: summon.health + healed }
: summon);
const actor = work.actors[healingTargetId];
if (actor) work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
} else if (friendlyOnline && healed > 0) {
const actor = work.actors[healingTargetId] ?? friendlyOnline;
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
}
recordCombatEventInWork(work, {
occurredAt: now,
kind: "healing",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: healingTargetId,
abilityId: ability.id,
school: spellSchool,
rawAmount: resolution.rawAmount,
effectiveAmount: healed,
overheal: Math.max(0, resolution.rawAmount - healed),
absorbed: 0,
critical: resolution.critical,
});
directHealing += healed;
if (healed <= 0) continue;
evaluateProcsInWork(work, {
trigger: "heal",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: healingTargetId,
abilityId: ability.id,
amount: healed,
}, now);
}
work.totalHealing += directHealing;
const beacon = work.auras.find((aura) => (
aura.sourceId === PLAYER_AURA_ENTITY_ID
&& !healingTargetIds.includes(aura.targetId)
&& aura.definition.utilityTags?.includes("beacon-of-light")
));
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
work.totalHealing += healPlayerInWork(work, directHealing);
} else if (beacon) {
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
}
addHealingThreatInWork(work, castThreatSource, directHealing);
} else if (effect.kind === "resurrection") {
const restored = targetId
? reviveCompanionActorInWork(work, targetId, effect.percentMaxHealth)
: 0;
work.totalHealing += restored;
if (restored > 0 && targetId) recordCombatEventInWork(work, {
occurredAt: now,
kind: "resurrection",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: targetId,
abilityId: ability.id,
rawAmount: restored,
effectiveAmount: restored,
overheal: 0,
absorbed: 0,
critical: false,
});
addHealingThreatInWork(work, castThreatSource, restored);
} else if (effect.kind === "summon") {
summonSequence += 1;
if (/totem/i.test(ability.name)) {
work.summons = work.summons.filter((summon) => summon.exclusiveGroup !== "totem");
}
work.summons.push({
id: `player-summon-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.creatureId,
targetId,
expiresAt: now + effect.durationMs,
kind: /totem/i.test(ability.name) ? "totem" : "summon",
name: ability.name,
ownerActorId: PLAYER_AGGRO_ID,
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
position: groundPosition ?? playerOrigin,
nextActionAt: now + 1_000,
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}),
});
if (ability.target === "hostile") {
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
effect,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
for (const id of affectedMobIds) {
resolveAndDamageMobInWork(
state.classId,
work,
id,
amount,
now,
castThreatSource,
attackKind,
damageSchool,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:summon`,
ability,
);
}
}
} else if (effect.kind === "shield") {
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
effect,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"healing-percent",
ability,
);
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
const partyMembers = usePartyStore.getState().members;
const recipients = ability.radius !== undefined
? [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => {
const position = getPartyRuntimePosition(member.id);
return member.health > 0 && Boolean(position
&& Math.sqrt(distanceSquared(playerOrigin, position)) <= ability.radius!
&& combatHasLineOfSight(playerOrigin, position));
}).map((member) => member.id)]
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
if (recipientId === PLAYER_AURA_ENTITY_ID) {
work.shield = Math.max(work.shield, amount);
work.shieldExpiresAt = now + durationMs;
} else {
work.auras = [...applyCombatAura(work.auras, {
definition: {
id: `shield:${ability.id}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs,
stackBehavior: "replace",
absorbs: [{ id: `${ability.id}:absorb`, amount }],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).auras];
}
recordCombatEventInWork(work, {
occurredAt: now,
kind: "absorb",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: recipientId,
abilityId: ability.id,
school: spellSchool,
rawAmount: amount,
effectiveAmount: amount,
overheal: 0,
absorbed: amount,
critical: false,
});
}
} else if (effect.kind === "resource") {
const resource = effect.resource ?? activeResourceType;
const friendlyResourceTarget = effectiveTarget === "friendly" && targetId
? usePartyStore.getState().members.find((member) => member.id === targetId)
: null;
if (friendlyResourceTarget) {
const maximum = friendlyResourceTarget.resourceType === resource
? friendlyResourceTarget.maxResource
: 100;
const current = friendlyResourceTarget.resourcePools[resource] ?? 0;
const requested = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
const restored = Math.max(0, Math.min(requested, maximum - current));
const actor = ensureCompanionActorInWork(work, friendlyResourceTarget.id);
if (actor) {
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, current, maximum);
const next = Object.freeze({
...actor,
resources: Object.freeze({
...actor.resources,
[resource]: Object.freeze({ ...pool, current: current + restored }),
}),
});
work.actors[friendlyResourceTarget.id] = next;
mirrorCompanionActor(next);
}
recordCombatEventInWork(work, {
occurredAt: now,
kind: "resource",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: friendlyResourceTarget.id,
abilityId: ability.id,
rawAmount: requested,
effectiveAmount: restored,
overheal: Math.max(0, requested - restored),
absorbed: 0,
critical: false,
});
continue;
}
const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
const maximum = work.maxResourcePools[resource]
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
const rawAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
const amount = resolveAuraResourceValue(
resolveAuraResourceValue(rawAmount, targetAuras, resource, "generation"),
targetAuras,
"power",
"generation",
);
const resourceBefore = work.resourcePools[resource] ?? 0;
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
recordCombatEventInWork(work, {
occurredAt: now,
kind: "resource",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
abilityId: ability.id,
rawAmount: amount,
effectiveAmount: (work.resourcePools[resource] ?? 0) - resourceBefore,
overheal: Math.max(0, amount - ((work.resourcePools[resource] ?? 0) - resourceBefore)),
absorbed: 0,
critical: false,
});
} else if (effect.kind === "combo") {
const comboTargetId = targetId && work.mobs[targetId] ? targetId : null;
if (ability.dbcSpellId === 490151 && comboTargetId) {
work.effects = work.effects.filter((entry) => !(entry.kind === "dot"
&& entry.tag === "rom-holy-seal" && entry.sourceActorId === PLAYER_AGGRO_ID
&& entry.targetId === comboTargetId));
}
work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0,
state.classId === "rom-knight" ? 4 : 5);
work.comboTargetId = comboTargetId;
} else if (effect.kind === "dot") {
if (targeting?.shape === "ground") continue;
const selfDrainPercent = romSelfDrainPercent(ability.dbcSpellId);
if (selfDrainPercent > 0) {
work.effects = work.effects.filter((entry) => !(entry.kind === "dot"
&& entry.sourceAbilityId === ability.id && entry.targetId === PLAYER_AURA_ENTITY_ID));
work.effects.push({
...timedEffect("dot", ability.id, PLAYER_AGGRO_ID, PLAYER_AURA_ENTITY_ID,
effect, effect.ticks, effect.intervalMs, now, "rom-self-health-drain"),
fixedAmount: Math.max(1, Math.round(work.maxHealth * selfDrainPercent / 100)),
});
continue;
}
for (const id of affectedMobIds) {
work.effects = work.effects.filter((entry) => !(
entry.kind === "dot"
&& entry.targetId === id
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
));
work.effects.push(timedEffect(
"dot",
ability.id,
castThreatSource.actorId,
id,
effect,
ability.name === "Rupture"
? Math.max(4, Math.min(8, 3 + work.comboPoints))
: effect.ticks,
effect.intervalMs,
now,
effect.tag,
effect.school ?? damageSchool,
work.gearStats.spellPenetration,
));
}
} else if (effect.kind === "hot") {
if (targeting?.shape === "ground") continue;
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id),
[hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now);
for (const recipient of recipients) {
const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient;
work.effects = work.effects.filter((entry) => !(
entry.kind === "hot"
&& entry.sourceAbilityId === ability.id
&& entry.targetId === normalizedTarget
));
work.effects.push(timedEffect(
"hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now,
));
}
} else if (effect.kind === "taunt") {
const friendlyAttackers = effectiveTarget === "friendly" && targetId
? Object.values(work.mobs)
.filter((mob) => !mob.dead && mob.targetActorId === targetId)
.map((mob) => mob.id)
: [];
const tauntTargets = affectedMobIds.length ? affectedMobIds : friendlyAttackers;
const candidates = effect.maxTargets ? tauntTargets.slice(0, effect.maxTargets) : tauntTargets;
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
for (const id of candidates) {
const mob = work.mobs[id];
if (!mob || mob.dead) continue;
const previous = mob.tauntDiminishingReturns;
const applications = mob.boss && previous && previous.resetAt > now ? previous.applications : 0;
const multiplier = mob.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
const effectiveDurationMs = Math.round(durationMs * multiplier);
const tauntDiminishingReturns = mob.boss
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
: mob.tauntDiminishingReturns;
if (effectiveDurationMs <= 0) {
work.mobs[id] = { ...mob, tauntDiminishingReturns };
continue;
}
work.mobs[id] = {
...mob,
tauntDiminishingReturns,
threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId),
targetActorId: castThreatSource.actorId,
forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + effectiveDurationMs },
};
}
} else if (effect.kind === "status") {
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) {
for (const [mobId, mob] of Object.entries(work.mobs)) {
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
)) };
}
clearedSingleTargetControls = true;
}
for (const id of candidates) {
const mob = work.mobs[id];
if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue;
if (mob) work.mobs[id] = applyStatus(mob, {
...effect,
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
}, ability.id, now, PLAYER_AGGRO_ID);
}
} else if (effect.kind === "apply-aura") {
const sourceDurationMs = ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? (work.comboPoints + 1) * 1_000
: ability.name === "Rupture"
? Math.max(4, Math.min(8, 3 + work.comboPoints)) * 2_000
: holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
? Math.max(1_000, effect.aura.durationMs - 2_000)
: effect.aura.durationMs;
const durationMs = sourceDurationMs === null
? null
: ability.name === "Rupture" || ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? sourceDurationMs
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.scope === "party"
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)]
: holyAuraFriendly && ability.name === "Holy Aura" && targetId
? [targetId]
: effect.recipient === "caster"
? [PLAYER_AURA_ENTITY_ID]
: effectiveTarget === "hostile"
? affectedMobIds
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
});
work.auras = [...auraResult.auras];
if (ability.name === "Power Word: Shield") {
work.auras = [...applyCombatAura(work.auras, {
definition: WEAKENED_SOUL_AURA,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).auras];
}
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
if (grantedAbsorb > 0) {
recordCombatEventInWork(work, {
occurredAt: now,
kind: "absorb",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: recipientId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) {
if (
recipientId === PLAYER_AURA_ENTITY_ID
&& state.controlMechanic
&& auraDefinition.controlImmunities.includes(state.controlMechanic)
) clearedPlayerControl = true;
else if (recipientId !== PLAYER_AURA_ENTITY_ID) {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === recipientId);
if (member?.controlMechanic && auraDefinition.controlImmunities.includes(member.controlMechanic)) {
clearCompanionControlInWork(work, recipientId);
}
}
}
if (effect.control && work.mobs[recipientId]) {
const durationMs = auraDefinition.durationMs ?? 1_000;
const previousStatuses = work.mobs[recipientId].statuses;
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
kind: "status",
status: effect.control.kind as MobStatusKind,
durationMs,
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
}, `aura:${auraDefinition.id}`, now);
if (work.mobs[recipientId].characterDefender) {
const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`);
if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras];
else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura);
}
}
}
} else if (effect.kind === "dispel") {
const primaryRecipientId = effectiveTarget === "hostile"
? targetId
: targetId ?? PLAYER_AURA_ENTITY_ID;
const recipientIds = effect.scope === "party"
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)]
: primaryRecipientId ? [primaryRecipientId] : [];
for (const recipientId of recipientIds) {
const relationship = effect.relationship === "target"
? (work.mobs[recipientId] ? "hostile" : "friendly")
: effect.relationship;
const targeted = work.auras.filter((aura) => aura.targetId === recipientId);
const untouched = work.auras.filter((aura) => aura.targetId !== recipientId);
const result = dispelCombatAuras(targeted, {
relationship,
categories: effect.categories,
maxCount: effect.maxCount,
});
work.auras = [...untouched, ...result.auras];
if (result.removed.some((removed) => removed.definition.name.startsWith("Living Bomb"))) {
for (const timed of work.effects.filter((entry) => entry.targetId === recipientId
&& result.removed.some((removed) => removed.sourceId === entry.sourceActorId
&& abilityById(entry.sourceAbilityId)?.name === "Living Bomb"))) {
detonateLivingBombInWork(work, timed, now);
work.effects = work.effects.filter((entry) => entry.id !== timed.id);
work.auras = work.auras.filter((aura) => !(aura.targetId === recipientId
&& aura.sourceId === timed.sourceActorId && aura.definition.name.startsWith("Living Bomb")));
}
}
if (result.removed.length > 0 && work.mobs[recipientId]) {
const removedTokens = new Set(result.removed.map((removed) => `aura:${removed.definition.id}`));
work.mobs[recipientId] = {
...work.mobs[recipientId],
statuses: work.mobs[recipientId].statuses.filter((status) => !removedTokens.has(status.sourceAbilityId)),
auras: work.mobs[recipientId].auras.filter((legacy) => !result.removed.some((removed) => (
removed.definition.id === `enemy:${recipientId}:${legacy.id}`
))),
};
}
if (
recipientId === PLAYER_AURA_ENTITY_ID
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
) clearedPlayerControl = true;
if (
recipientId !== PLAYER_AURA_ENTITY_ID
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
) clearCompanionControlInWork(work, recipientId);
if (effect.mode === "steal") {
for (const removed of result.removed) {
work.auras = [...applyCombatAura(work.auras, {
definition: { ...removed.definition, disposition: "buff" },
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras];
}
}
}
} else if (effect.kind === "interrupt") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (mob) work.mobs[id] = applyStatus(mob, {
kind: "status",
status: "interrupt",
durationMs: effect.lockoutMs,
}, ability.id, now);
}
} else if (effect.kind === "pull") {
if (ability.target === "friendly" && targetId && effect.toward === "caster") {
updatePartyRuntimePosition(
targetId,
state.playerPosition[0],
state.playerPosition[1],
state.playerPosition[2],
);
}
for (const id of affectedMobIds) {
const currentPosition = getMobPosition(id);
if (!currentPosition) continue;
if (effect.toward === "caster") {
const dx = currentPosition[0] - state.playerPosition[0];
const dz = currentPosition[2] - state.playerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
setMobPosition(id, [
state.playerPosition[0] + dx / length * 1.5,
currentPosition[1],
state.playerPosition[2] + dz / length * 1.5,
]);
}
}
} else if (effect.kind === "execute") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
// DBC instant-kill effects include pet sacrifices such as Death Pact.
// They must never bypass a boss encounter's health and mechanics.
if (!mob || mob.dead || mob.boss) continue;
damageMobInWork(
state.classId,
work,
id,
Math.max(mob.health, mob.maxHealth * effect.coefficient),
now,
castThreatSource,
damageSchool,
);
}
} else if (effect.kind === "resource-drain") {
const resource = effect.resource ?? activeResourceType;
const maximum = work.maxResourcePools[resource]
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
if (ability.target === "hostile") {
let drainedFromPlayers = 0;
let hasPlayerTarget = false;
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (!mob?.characterResources) continue;
hasPlayerTarget = true;
const current = mob.characterResources[resource] ?? 0;
const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount;
const drained = Math.min(current, requested);
drainedFromPlayers += drained;
work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } };
if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool);
}
const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount;
if (returned > 0) {
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
}
} else {
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
}
} else if (effect.kind === "threat") {
const threatTargets = affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs);
for (const id of threatTargets) {
const mob = work.mobs[id];
if (!mob || mob.dead) continue;
const threatActorId = effect.recipient === "ability-target" && targetId && !work.mobs[targetId]
? targetId
: castThreatSource.actorId;
const currentThreat = mob.threatByActor[threatActorId] ?? 0;
const nextThreat = effect.mode === "redirect"
? 0
: effect.mode === "percent"
? currentThreat * Math.max(0, 1 + effect.amount / 100)
: Math.max(0, currentThreat + effect.amount);
work.mobs[id] = {
...mob,
threatByActor: {
...mob.threatByActor,
[threatActorId]: nextThreat,
...(effect.mode === "redirect" && targetId && !work.mobs[targetId]
? { [targetId]: (mob.threatByActor[targetId] ?? 0) + currentThreat }
: {}),
},
};
}
} else if (effect.kind === "movement") {
if (ability.dbcSpellId === 490344 && targetId) {
const target = getMobRuntimeTransform(targetId);
if (target) playerPositionAfterEffects = [
target.position[0] - Math.sin(target.yaw) * 1.5,
target.position[1],
target.position[2] - Math.cos(target.yaw) * 1.5,
];
} else if (ability.dbcSpellId === 490150 && targetPosition) {
const dx = targetPosition[0] - state.playerPosition[0];
const dz = targetPosition[2] - state.playerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
playerPositionAfterEffects = [
targetPosition[0] - dx / length * 1.5,
targetPosition[1],
targetPosition[2] - dz / length * 1.5,
];
} else if (effect.movement === "sanctuary") {
for (const [id, mob] of Object.entries(work.mobs)) {
const threatByActor = { ...mob.threatByActor, [castThreatSource.actorId]: 0 };
work.mobs[id] = { ...mob, threatByActor };
}
} else if (effect.movement === "knockback") {
for (const id of affectedMobIds) {
const position = getMobPosition(id);
if (!position) continue;
const dx = position[0] - state.playerPosition[0];
const dz = position[2] - state.playerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
setMobPosition(id, [position[0] + dx / length * 4, position[1], position[2] + dz / length * 4]);
}
} else if (effect.toward === "away") {
const referenceId = state.selectedTargetId
?? nearbyMobRuntimeIds(state.playerPosition, 40).find((id) => !work.mobs[id]?.dead)
?? null;
const reference = referenceId ? getMobPosition(referenceId) : null;
const dx = reference ? state.playerPosition[0] - reference[0] : 0;
const dz = reference ? state.playerPosition[2] - reference[2] : 1;
const length = Math.max(0.001, Math.hypot(dx, dz));
playerPositionAfterEffects = [
state.playerPosition[0] + dx / length * 8,
state.playerPosition[1],
state.playerPosition[2] + dz / length * 8,
];
} else if (targetPosition) {
playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]];
}
} else if (effect.kind === "dispel-mechanic") {
const mechanicStatus: Partial<Record<number, MobStatusKind>> = {
1: "charm",
3: "disarm",
5: "fear",
7: "root",
9: "silence",
10: "sleep",
11: "slow",
12: "stun",
};
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (!mob) continue;
const wanted = mechanicStatus[effect.mechanic];
let removed = 0;
work.mobs[id] = {
...mob,
statuses: mob.statuses.filter((status) => {
if (removed >= effect.maxCount || (wanted && status.kind !== wanted)) return true;
removed += 1;
return false;
}),
};
}
} else if (effect.kind === "pet") {
if (["summon", "call", "recall", "resurrect", "tame"].includes(effect.action)) {
summonSequence += 1;
const existingPet = work.summons.find((summon) => summon.id.startsWith("player-pet-"));
if (!existingPet) {
work.summons.push({
id: `player-pet-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.creatureId ?? 1,
targetId,
expiresAt: Number.MAX_SAFE_INTEGER,
kind: "pet",
name: ability.name,
ownerActorId: PLAYER_AGGRO_ID,
health: Math.max(1, Math.round(work.maxHealth * 0.5)),
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.5)),
position: playerOrigin,
nextActionAt: now + 1_000,
pulseIntervalMs: 2_000,
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
...initialSummonCombatRuntime(),
exclusiveGroup: "combat-pet",
});
}
} else if (effect.action === "dismiss") {
work.summons = work.summons.filter((summon) => !summon.id.startsWith("player-pet-"));
}
} else if (effect.kind === "totem") {
work.summons = work.summons.filter((summon) => !/totem/i.test(abilityById(summon.sourceAbilityId)?.name ?? ""));
} else if (effect.kind === "rune") {
work.deathKnightRunes = activateDeathKnightRunes(work.deathKnightRunes, effect.count);
} else if (effect.kind === "cooldown-reset") {
cooldownAdjustments.push(effect);
} else if (effect.kind === "trigger-spell") {
if (!effect.expanded && effect.spellId !== ability.dbcSpellId) {
executeTriggeredSpellInWork(
work,
effect.spellId,
PLAYER_AURA_ENTITY_ID,
targetId,
now,
0,
);
}
} else if (effect.kind === "utility") {
if (effect.action === "create-item") {
if (/water|mana|spellstone/i.test(ability.name)) {
const restored = Math.max(1, Math.round(work.maxResource * 0.25));
work.resource = clamp(work.resource + restored, 0, work.maxResource);
work.resourcePools[activeResourceType] = work.resource;
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
}
} else if (effect.action === "portal") {
summonSequence += 1;
work.summons.push({
id: `player-utility-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.miscValue,
targetId: null,
expiresAt: now + Math.max(30_000, effect.durationMs),
kind: "summon",
name: ability.name,
ownerActorId: PLAYER_AGGRO_ID,
health: 1,
maxHealth: 1,
position: playerOrigin,
nextActionAt: Number.MAX_SAFE_INTEGER,
pulseIntervalMs: 0,
attackDamage: 0,
...initialSummonCombatRuntime(),
});
} else if (effect.action === "class-script" && ability.name === "Divine Intervention") {
work.health = 1;
work.progression.health = 1;
} else if (effect.action === "class-script" && ability.id === "rom-rogue-rom-warden-elite-60") {
const revival = work.auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("rom-revival-charge")
));
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
}
}
}
const consumedRuneWakerTags = ability.id === "rom-mage-primary-55"
? ["rom-charged"]
: ability.id === "rom-mage-primary-56"
? ["rom-static-field-charge"]
: holyAuraFriendly && ability.name === "Holy Aura"
? ["rom-holy-salvation-candle"]
: [];
if (consumedRuneWakerTags.length) {
work.auras = work.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag))
));
}
if (hasHostileEffect && ability.name !== "Cold Blood") {
work.auras = work.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("next-offensive-critical")
));
}
evaluateProcsInWork(work, {
trigger: "cast",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: targetId ?? undefined,
abilityId: ability.id,
}, now);
recordCombatEventInWork(work, {
occurredAt: now,
kind: "cast",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
abilityId: ability.id,
school: spellSchool,
rawAmount: 0,
effectiveAmount: 0,
overheal: 0,
absorbed: 0,
critical: false,
outcome: "completed",
});
// Judgement of Light restores 2% health when the marked target is struck.
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
const healed = healPlayerInWork(work,
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
work.totalHealing += healed;
addHealingThreatInWork(work, castThreatSource, healed);
}
const cooldowns: Record<string, number> = { ...work.cooldowns };
if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs;
if (ability.cooldownGroup && completingCast?.mode !== "channel") {
for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) {
cooldowns[grouped.id] = now + Math.max(cooldownMs, talentAdjustedCooldown(grouped, modifiers));
}
}
for (const adjustment of cooldownAdjustments) {
const candidates = cooldownResetCandidates(state.abilities, ability.id, adjustment.abilityName);
for (const candidate of candidates) {
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
else {
const readyAt = cooldowns[candidate.id] ?? now;
const reduction = adjustment.amountPercent !== undefined
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, adjustment.amountPercent))
: adjustment.amountMs ?? 0;
cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
}
}
}
const burstReset = romFireLightningBurstReset(
ability,
Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).some((aura) =>
aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
);
if (burstReset.reset) {
cooldowns[ability.id] = 0;
work.auras = [...applyCombatAura(work.auras, {
definition: {
id: "rom-fire-lightning-burst-reset-guard",
name: "Fire Lightning Burst reset guard",
disposition: "buff",
durationMs: burstReset.guardDurationMs,
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras];
}
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
const levelMessage = work.progression.levelsGained > 0
? levelUpMessage(state.classId, state.level, work.progression.level)
: primaryCombatResult
? `${ability.name} ${combatResultDescription(primaryCombatResult)}.`
: `${ability.name}${work.totalDamage ? ` dealt ${work.totalDamage}` : ""}${work.totalHealing ? ` healed ${work.totalHealing}` : ""}.`;
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
for (const [actorId, actor] of Object.entries(work.actors)) {
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) delete work.actors[actorId];
}
for (const summon of work.summons) {
work.actors[summon.id] = combatActorFromSummon(summon, work.progression.level, work.auras);
}
set({
level: work.progression.level,
xp: work.progression.xp,
xpToNext: work.progression.xpToNext,
health: work.progression.levelsGained > 0 ? work.progression.health : work.health,
maxHealth: work.progression.maxHealth,
shield: work.shield,
shieldExpiresAt: work.shieldExpiresAt,
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
resourcePools: { ...work.resourcePools, [activeResourceType]: clamp(work.resource, 0, work.maxResource) },
maxResourcePools: work.maxResourcePools,
lastResourceSpentAt: work.lastResourceSpentAt,
deathKnightRunes: work.deathKnightRunes,
talentModifiers: work.talentModifiers,
comboPoints: work.comboPoints,
comboTargetId: work.comboTargetId,
mobs: work.mobs,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
effects: work.effects.length === state.effects.length
&& work.effects.every((effect, index) => effect === state.effects[index])
? state.effects
: work.effects,
summons: work.summons,
areaEffects: work.areaEffects,
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
cooldowns,
globalCooldownEndsAt: completingCast ? state.globalCooldownEndsAt : now + gcdMs,
activeCast: null,
...autoAttackUpdate,
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId,
playerPosition: playerPositionAfterEffects,
...(clearedPlayerControl ? { controlledUntil: 0, controlMechanic: null } : {}),
feedback: nextFeedback(
work.progression.levelsGained > 0 ? "level-up" : "success",
levelMessage,
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
),
});
emitPlayerAbilityResolutionPresentation(
ability,
now,
playerOrigin,
targetId,
targetPosition,
!completingCast,
);
const chimeraDamage = ability.name === "Chimera Shot" && targetId && work.totalDamage > 0
? get().triggerChimeraShot(PLAYER_AGGRO_ID, targetId, ability.id, now)
: 0;
return {
ok: true,
abilityId: ability.id,
affectedMobIds,
damage: work.totalDamage + chimeraDamage,
healing: work.totalHealing,
levelsGained: work.progression.levelsGained,
};
},
castSlot: (index, origin, now) => {
const ability = get().abilities[Math.trunc(index)];
if (!ability) {
set({ feedback: nextFeedback("error", "That action-bar slot is empty.") });
return emptyCastResult("unknown-ability");
}
return get().castAbility(ability.id, origin, now);
},
castActionBinding: (layer, control, origin, now) => {
const abilityId = get().actionBindings[actionBindingId(layer, control)];
if (!abilityId) {
set({ feedback: nextFeedback("error", "That controller binding is empty.") });
return emptyCastResult("unknown-ability");
}
const ability = abilityById(abilityId);
if (ability && targetingRulesForAbility(ability).shape === "ground") {
const pending = get().pendingGroundTarget;
if (pending?.abilityId !== abilityId) {
get().beginGroundTarget(abilityId);
return { ...emptyCastResult(), ok: true, abilityId };
}
return get().confirmGroundTarget(undefined, now);
}
return get().castAbility(abilityId, origin, now);
},
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
const state = get();
const threatSource = source ?? playerThreatSource(state);
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const result = rollContext
? resolveAndDamageMobInWork(
state.classId,
work,
id,
amount,
now,
threatSource,
rollContext.attackKind,
school,
rollContext.seed,
rollContext.abilityId ? abilityById(rollContext.abilityId) ?? undefined : undefined,
rollContext.periodic,
rollContext.rules,
rollContext.criticalChanceBonusPercent,
)
: damageMobInWork(
state.classId,
work,
id,
amount,
now,
threatSource,
school,
state.gearStats.spellPenetration,
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
cooldowns: work.cooldowns,
level: progression.level,
xp: progression.xp,
xpToNext: progression.xpToNext,
health: progression.health,
maxHealth: progression.maxHealth,
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
talentModifiers: work.talentModifiers,
lastCombatResult: result.combatResult ?? state.lastCombatResult,
selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId,
autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.comboPoints,
comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null : state.comboTargetId,
feedback: result.levelsGained > 0
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
: result.combatResult
? nextFeedback("damage", `${state.mobs[id]?.name ?? "Enemy"} ${combatResultDescription(result.combatResult)}.`, {
targetId: id,
amount: result.amount,
abilityId: threatSource.abilityId,
})
: nextFeedback("damage", result.killed ? `${state.mobs[id]?.name ?? "Enemy"} defeated.` : `${result.amount} damage.`, {
targetId: id,
amount: result.amount,
abilityId: threatSource.abilityId,
}),
});
return result;
},
consumeActorAuraTag: (actorId, tag) => {
set((state) => ({
auras: state.auras.filter((aura) => !(aura.targetId === actorId
&& aura.definition.utilityTags?.includes(tag))),
}));
},
consumeActorEnrage: (actorId, now = combatNow()) => {
const state = get();
if (!state.auras.some((aura) => consumableEnrage(aura, actorId, now))) return false;
set({ auras: state.auras.filter((aura) => !consumableEnrage(aura, actorId, now)) });
return true;
},
triggerChimeraShot: (sourceActorId, targetId, abilityId, now = combatNow()) => {
const state = get();
if (!state.mobs[targetId] || state.mobs[targetId].dead) return 0;
const stings = ["Serpent Sting", "Viper Sting", "Scorpid Sting"];
const stingEffect = state.effects.find((effect) => effect.kind === "dot"
&& effect.sourceActorId === sourceActorId && effect.targetId === targetId
&& abilityById(effect.sourceAbilityId)?.name === "Serpent Sting");
const stingAura = state.auras.find((aura) => aura.sourceId === sourceActorId
&& aura.targetId === targetId && stings.some((name) => aura.definition.name.startsWith(name))
&& (aura.expiresAt === null || aura.expiresAt > now));
if (!stingAura && !stingEffect) return 0;
const stingName = stingAura
? stings.find((name) => stingAura.definition.name.startsWith(name))!
: "Serpent Sting";
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const stingAbility = stingEffect
? abilityById(stingEffect.sourceAbilityId)
: abilitiesForClass(sourceMember?.classId ?? state.classId).find((candidate) => candidate.name === stingName) ?? null;
const activeSting = stingAbility
? abilityAtLevel(stingAbility, sourceMember?.level ?? state.level)
: null;
const dot = activeSting?.effects.find((effect) => effect.kind === "dot");
set({
auras: state.auras.map((aura) => aura === stingAura
? { ...aura, expiresAt: now + (aura.definition.durationMs ?? 15_000) }
: aura),
effects: stingEffect && dot
? state.effects.map((effect) => effect === stingEffect
? { ...effect, remainingTicks: dot.ticks, nextTickAt: now + dot.intervalMs }
: effect)
: state.effects,
});
if (stingName === "Serpent Sting" && stingEffect && dot) {
const tick = stingEffect.fixedAmount ?? equipmentAdjustedCombatAmount(
stingEffect,
sourceMember?.level ?? state.level,
sourceMember?.gearStats ?? state.gearStats,
sourceMember?.classId ?? state.classId,
activeSting ?? stingAbility!,
);
return get().damageMob(targetId, Math.max(1, Math.round(tick * dot.ticks * 0.4)), now,
threatSourceForActor(get(), sourceActorId, abilityId), "nature").amount;
}
if (stingName === "Viper Sting") {
const drain = activeSting?.effects.find((effect) => effect.kind === "resource-drain");
if (drain) {
const current = get();
const actor = current.actors[sourceActorId];
const maximum = sourceActorId === PLAYER_AURA_ENTITY_ID
? current.maxResourcePools.mana ?? current.maxResource
: actor?.resources.mana?.maximum ?? 0;
const amount = drain.percentage
? maximum * drain.amount / 100
: drain.amount;
if (sourceActorId === PLAYER_AURA_ENTITY_ID) {
const before = current.resourcePools.mana ?? 0;
const restored = Math.min(Math.max(0, amount * 0.6), Math.max(0, maximum - before));
if (restored > 0) {
set({
resourcePools: { ...current.resourcePools, mana: before + restored },
...(resourceProfileForClass(current.classId).type === "mana" ? { resource: before + restored } : {}),
actors: actor ? { ...current.actors, [sourceActorId]: {
...actor,
resources: { ...actor.resources, mana: { ...(actor.resources.mana
?? normalizeActorResourcePool("mana", before, maximum)), current: before + restored } },
} } : current.actors,
});
}
} else get().restoreActorResource(sourceActorId, "mana", Math.max(0, amount * 0.6), false,
sourceActorId, abilityId, now);
}
} else if (stingName === "Scorpid Sting" && !get().auras.some((aura) => aura.targetId === sourceActorId
&& aura.definition.utilityTags?.includes("chimera-scorpid-cooldown")
&& (aura.expiresAt === null || aura.expiresAt > now))) {
get().controlMob(targetId, abilityId, "disarm", 10_000, now);
const ability = abilityById(abilityId);
if (ability) get().applyActorAura(sourceActorId, sourceActorId, ability, {
kind: "apply-aura", recipient: "caster",
aura: {
id: `wow335:chimera-scorpid-cooldown:${sourceActorId}`, name: "Chimera Shot", disposition: "buff",
durationMs: 60_000, maxStacks: 1, stackBehavior: "refresh",
utilityTags: ["chimera-scorpid-cooldown"],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}, now);
}
return 0;
},
triggerRogueWeaponPoisons: (
sourceActorId,
targetId,
sourceAbilityId,
hitCount = 1,
now = combatNow(),
) => {
const state = get();
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const result = applyRogueWeaponPoisonsInWork(
work,
sourceActorId,
targetId,
sourceAbilityId,
hitCount,
now,
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
cooldowns: work.cooldowns,
level: progression.level,
xp: progression.xp,
xpToNext: progression.xpToNext,
health: progression.health,
maxHealth: progression.maxHealth,
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
selectedTargetId: work.mobs[targetId]?.dead && state.selectedTargetId === targetId
? null
: state.selectedTargetId,
autoAttackTargetId: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? null
: state.autoAttackTargetId,
nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? 0
: state.nextAutoAttackAt,
});
return result;
},
consumeRogueDeadlyPoison: (
sourceActorId,
targetId,
maximumDoses,
now = combatNow(),
) => {
const state = get();
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression: progressionFromState(state),
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const consumed = consumeRogueDeadlyPoisonInWork(
work,
sourceActorId,
targetId,
maximumDoses,
now,
);
if (consumed > 0) set({ auras: work.auras, effects: work.effects });
return consumed;
},
addHealingThreat: (source, effectiveHealing) => set((state) => {
const engagedIds = Object.values(state.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.map((mob) => mob.id);
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
* (source.threatScale ?? 1)
* resolveAuraStatValue(1, aurasForEntity(state.auras, source.actorId), "threat");
if (perMob <= 0) return state;
const mobs = { ...state.mobs };
for (const id of engagedIds) {
const mob = mobs[id];
mobs[id] = {
...mob,
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
};
}
return { mobs };
}),
applyActorThreatEffect: (actorId, effect, redirectTargetActorId = null, now = combatNow()) => set((state) => {
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (!mob.engaged || mob.dead) continue;
const currentThreat = mob.threatByActor[actorId] ?? 0;
const nextThreat = effect.mode === "redirect"
? 0
: effect.mode === "percent"
? currentThreat * Math.max(0, 1 + effect.amount / 100)
: Math.max(0, currentThreat + effect.amount);
const threatByActor = {
...mob.threatByActor,
[actorId]: nextThreat,
...(effect.mode === "redirect" && redirectTargetActorId
? { [redirectTargetActorId]: (mob.threatByActor[redirectTargetActorId] ?? 0) + currentThreat }
: {}),
};
const resolution = resolveAggroTarget(
threatByActor,
eligibleAggroActorIds(state, mob),
mob.targetActorId,
mob.forcedTarget,
now,
);
mobs[id] = {
...mob,
threatByActor,
targetActorId: resolution.targetActorId,
forcedTarget: resolution.forcedTarget,
};
changed = true;
}
return changed ? { mobs } : state;
}),
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
const previous = target.tauntDiminishingReturns;
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
const effectiveDurationMs = Math.round(durationMs * multiplier);
const nextDiminishing = target.boss
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
: target.tauntDiminishingReturns;
set((state) => {
const mobs: Record<string, MobCombatState> = { ...state.mobs };
if (effectiveDurationMs <= 0) {
mobs[id] = { ...mobs[id], tauntDiminishingReturns: nextDiminishing };
return { mobs };
}
const groupId = mobGroupId(id);
const groupWasEngaged = Object.entries(mobs).some(([mobId, mob]) => (
mobGroupId(mobId) === groupId && mob.engaged && !mob.dead
));
for (const [mobId, mob] of Object.entries(mobs)) {
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
const seeded = groupWasEngaged
? mob.threatByActor
: seedActorThreat(mob.threatByActor, source.actorId);
mobs[mobId] = {
...mob,
...(mobId === id ? { tauntDiminishingReturns: nextDiminishing } : {}),
threatByActor: mobId === id ? tauntThreat(seeded, source.actorId) : seeded,
targetActorId: mobId === id
? source.actorId
: (mob.targetActorId ?? (groupWasEngaged ? null : source.actorId)),
forcedTarget: mobId === id
? { actorId: source.actorId, endsAt: now + Math.max(1, effectiveDurationMs) }
: mob.forcedTarget,
engaged: true,
combatPhase: "chasing",
homePosition: groupWasEngaged
? (mob.homePosition ?? getMobPosition(mobId))
: (getMobPosition(mobId) ?? mob.homePosition),
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
? mob.abilityReadyAt
: initialEnemyAbilityReadyAt(mob, now),
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
};
}
return { mobs };
});
return effectiveDurationMs > 0;
},
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
let interrupted = false;
set((state) => {
const mob = state.mobs[id];
if (!mob?.activeCast?.interruptible) return state;
const next = applyStatus(mob, {
kind: "status",
status: "interrupt",
durationMs: Math.max(1, lockoutMs),
}, sourceAbilityId, now);
interrupted = next.activeCast === null;
return interrupted ? { mobs: { ...state.mobs, [id]: next } } : state;
});
return interrupted;
},
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow(), breakOnDamage = false, sourceActorId) => {
let controlled = false;
set((state) => {
const mob = state.mobs[id];
if (!mob || mob.dead) return state;
const exclusive = romSingleTargetControl(abilityById(sourceAbilityId));
const next = applyStatus(mob, {
kind: "status",
status,
durationMs: Math.max(1, durationMs),
breakOnDamage,
}, sourceAbilityId, now, sourceActorId);
controlled = next.statuses.some((entry) => (
entry.sourceAbilityId === sourceAbilityId
&& entry.kind === status
&& entry.endsAt > now
));
if (!controlled) return state;
const mobs = exclusive && sourceActorId ? Object.fromEntries(Object.entries(state.mobs).map(([mobId, candidate]) => [
mobId,
mobId === id ? next : { ...candidate, statuses: candidate.statuses.filter((entry) => !(
entry.sourceAbilityId === sourceAbilityId && entry.sourceActorId === sourceActorId
)) },
])) : { ...state.mobs, [id]: next };
return { mobs };
});
return controlled;
},
dispelActor: (id, categories, maxCount = 1, now = combatNow()) => {
let removedCount = 0;
let clearedCompanionActor: CombatActorState | null = null;
let removedBombs: TimedCombatEffect[] = [];
set((state) => {
const targeted = state.auras.filter((aura) => aura.targetId === id);
const untouched = state.auras.filter((aura) => aura.targetId !== id);
const result = dispelCombatAuras(targeted, {
relationship: "friendly",
categories,
maxCount: Math.max(1, Math.trunc(maxCount)),
});
removedCount = result.removed.length;
if (!removedCount) return state;
removedBombs = state.effects.filter((effect) => effect.targetId === id
&& abilityById(effect.sourceAbilityId)?.name === "Living Bomb"
&& result.removed.some((aura) => aura.sourceId === effect.sourceActorId
&& aura.definition.name.startsWith("Living Bomb")));
const effects = state.effects.filter((effect) => !removedBombs.includes(effect));
const auras = [...untouched, ...result.auras].filter((aura) => !removedBombs.some((bomb) => (
aura.targetId === id && aura.sourceId === bomb.sourceActorId
&& aura.definition.name.startsWith("Living Bomb")
)));
if (id === PLAYER_AURA_ENTITY_ID && result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
return { auras, effects, controlledUntil: 0, controlMechanic: null };
}
if (result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
if (member) {
const actor = companionActorFromMember(
member,
entityAbsorbAmount(state.auras, id),
state.actors[id],
);
clearedCompanionActor = { ...actor, controlledUntil: 0 };
return {
auras, effects,
actors: { ...state.actors, [id]: clearedCompanionActor },
};
}
}
return { auras, effects };
});
if (clearedCompanionActor) mirrorCompanionActor(clearedCompanionActor);
for (const bomb of removedBombs) detonateLivingBombFromStore(bomb, now);
return removedCount;
},
healActor: (id, amount, request) => {
const now = request.now ?? combatNow();
const state = get();
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
const targetAuras = aurasForEntity(state.auras, id);
const modified = request.amountAlreadyModified
? amount
: resolveAuraHealingValue(
resolveAuraHealingValue(amount, sourceAuras, "done"),
targetAuras,
"taken",
);
const partyMember = usePartyStore.getState().members.find((member) => member.id === id);
const summon = state.summons.find((candidate) => candidate.id === id);
const actor = partyMember
? companionActorFromMember(partyMember, entityAbsorbAmount(state.auras, id), state.actors[id])
: state.actors[id];
const currentHealth = id === PLAYER_AURA_ENTITY_ID
? state.health
: summon?.health ?? actor?.health ?? 0;
const maximumHealth = id === PLAYER_AURA_ENTITY_ID
? state.maxHealth
: summon?.maxHealth ?? actor?.maxHealth ?? 0;
if (currentHealth <= 0 || maximumHealth <= 0) return resolveHealing(0, currentHealth, maximumHealth);
const resolution = resolveHealing(
modified,
currentHealth,
maximumHealth,
request.criticalChance ?? 0,
stableCombatUnit(request.seed ?? `${request.sourceActorId}:${request.abilityId ?? "heal"}:${id}:${now}`),
request.criticalMultiplier ?? 1.5,
);
let healedCompanion: CombatActorState | null = null;
set((current) => {
const combatEvents = recordCombatEvent(current.combatEvents, {
occurredAt: now,
kind: "healing",
sourceActorId: request.sourceActorId,
targetActorId: id,
...(request.abilityId ? { abilityId: request.abilityId } : {}),
...(request.school ? { school: request.school } : {}),
rawAmount: resolution.rawAmount,
effectiveAmount: resolution.effectiveAmount,
overheal: resolution.overheal,
absorbed: 0,
critical: resolution.critical,
});
if (id === PLAYER_AURA_ENTITY_ID) {
const playerActor = current.actors[id];
return {
health: current.health + resolution.effectiveAmount,
actors: playerActor ? {
...current.actors,
[id]: { ...playerActor, health: current.health + resolution.effectiveAmount, alive: true },
} : current.actors,
combatEvents,
};
}
const activeSummon = current.summons.find((candidate) => candidate.id === id);
if (activeSummon) {
const nextHealth = activeSummon.health + resolution.effectiveAmount;
const summonActor = current.actors[id];
return {
summons: current.summons.map((candidate) => candidate.id === id
? { ...candidate, health: nextHealth }
: candidate),
actors: summonActor ? {
...current.actors,
[id]: { ...summonActor, health: nextHealth, alive: true },
} : current.actors,
combatEvents,
};
}
if (!partyMember || !actor) return { combatEvents };
healedCompanion = { ...actor, health: actor.health + resolution.effectiveAmount, alive: true };
return {
actors: { ...current.actors, [id]: healedCompanion },
combatEvents,
};
});
if (healedCompanion) mirrorCompanionActor(healedCompanion);
return resolution;
},
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
const state = get();
const fraction = clamp(percentMaxHealth, 0.01, 1);
if (id === PLAYER_AURA_ENTITY_ID) {
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
if (restored <= 0) return 0;
const playerActor = state.actors[id];
set({
health: restored,
controlledUntil: 0,
controlMechanic: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
actors: playerActor ? { ...state.actors, [id]: {
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
} } : state.actors,
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "resurrection",
sourceActorId,
targetActorId: id,
...(abilityId ? { abilityId } : {}),
rawAmount: restored,
effectiveAmount: restored,
overheal: 0,
absorbed: 0,
critical: false,
}),
});
return restored;
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
if (!member) {
const onlineActor = onlinePlayerRuntimeActor(id);
if (!onlineActor || onlineActor.health > 0) return 0;
const restored = Math.max(1, Math.round(onlineActor.maxHealth * fraction));
set({
actors: {
...state.actors,
[id]: { ...onlineActor, health: restored, alive: true, controlledUntil: 0, cast: null },
},
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "resurrection",
sourceActorId,
targetActorId: id,
...(abilityId ? { abilityId } : {}),
rawAmount: restored,
effectiveAmount: restored,
overheal: 0,
absorbed: 0,
critical: false,
}),
});
return restored;
}
const actor = companionActorFromMember(member, entityAbsorbAmount(state.auras, id), state.actors[id]);
if (actor.alive || actor.health > 0) return 0;
const restored = Math.max(1, Math.round(actor.maxHealth * fraction));
const nextActor: CombatActorState = {
...actor,
health: restored,
alive: true,
controlledUntil: 0,
cast: null,
};
set({
actors: { ...state.actors, [id]: nextActor },
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "resurrection",
sourceActorId,
targetActorId: id,
...(abilityId ? { abilityId } : {}),
rawAmount: restored,
effectiveAmount: restored,
overheal: 0,
absorbed: 0,
critical: false,
}),
});
mirrorCompanionActor(nextActor);
return restored;
},
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
const state = get();
const actor = state.actors[id];
if (!actor) return 0;
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, 0, defaultResourceMaximum(resource));
const requested = Math.max(0, percentage ? pool.maximum * amount / 100 : amount);
const restored = Math.min(requested, pool.maximum - pool.current);
if (restored <= 0) return 0;
const nextActor: CombatActorState = {
...actor,
resources: {
...actor.resources,
[resource]: { ...pool, current: pool.current + restored },
},
};
const patch: Partial<CombatState> = {
actors: { ...state.actors, [id]: nextActor },
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "resource",
sourceActorId,
targetActorId: id,
...(abilityId ? { abilityId } : {}),
rawAmount: requested,
effectiveAmount: restored,
overheal: requested - restored,
absorbed: 0,
critical: false,
}),
};
if (id === PLAYER_AURA_ENTITY_ID) {
patch.resourcePools = { ...state.resourcePools, [resource]: pool.current + restored };
if (resource === resourceProfileForClass(state.classId).type) patch.resource = pool.current + restored;
}
set(patch);
if (nextActor.kind === "companion") mirrorCompanionActor(nextActor);
return restored;
},
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
const state = get();
const absorb = Math.max(0, Math.round(amount));
if (absorb <= 0 || !state.actors[id]) return 0;
const result = applyCombatAura(state.auras, {
definition: {
id: `shield:${sourceActorId}:${ability.id}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs: Math.max(1, durationMs),
stackBehavior: "replace",
absorbs: [{ id: `${ability.id}:absorb`, amount: absorb }],
},
sourceId: sourceActorId,
targetId: id,
now,
});
set({
auras: result.auras,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "absorb",
sourceActorId,
targetActorId: id,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: absorb,
effectiveAmount: absorb,
overheal: 0,
absorbed: absorb,
critical: false,
}),
});
return absorb;
},
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false;
const durationMs = effect.aura.durationMs;
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const applied = applyCombatAura(state.auras, {
definition,
sourceId: sourceActorId,
targetId: targetActorId,
now,
});
const result = ability.name === "Power Word: Shield"
? { ...applied, auras: applyCombatAura(applied.auras, {
definition: WEAKENED_SOUL_AURA, sourceId: sourceActorId, targetId: targetActorId, now,
}).auras }
: applied;
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
let health = state.health;
let maxHealth = state.maxHealth;
const actors = { ...state.actors };
let companionActor: CombatActorState | null = null;
if (targetActorId === PLAYER_AURA_ENTITY_ID) {
const nextMaxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
result.auras,
);
const delta = nextMaxHealth - maxHealth;
maxHealth = nextMaxHealth;
health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth);
const actor = actors[targetActorId];
if (actor) actors[targetActorId] = { ...actor, health, maxHealth };
} else {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId);
const actor = member ? companionActorFromMember(
member,
entityAbsorbAmount(state.auras, targetActorId),
state.actors[targetActorId],
) : null;
if (member && actor) {
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, result.auras, targetActorId)
- auraMaximumHealthBonus(base, state.auras, targetActorId);
if (delta) {
const actorMaxHealth = Math.max(1, actor.maxHealth + delta);
const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth);
companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth });
actors[targetActorId] = companionActor;
}
}
}
let combatEvents = recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
});
if (grantedAbsorb > 0) {
combatEvents = recordCombatEvent(combatEvents, {
occurredAt: now,
kind: "absorb",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
set({
auras: result.auras,
health,
maxHealth,
actors,
combatEvents,
});
if (companionActor) mirrorCompanionActor(companionActor);
return true;
},
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId]) return false;
const selfDrainPercent = effect.kind === "dot" ? romSelfDrainPercent(ability.dbcSpellId) : 0;
if (effect.kind === "dot" && selfDrainPercent === 0
&& (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
if (selfDrainPercent > 0 && sourceActorId !== targetActorId) return false;
if (effect.kind === "hot") {
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
: state.summons.find((summon) => summon.id === targetActorId)?.health
?? state.actors[targetActorId]?.health ?? 0;
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
}
const sourceActor = state.actors[sourceActorId];
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const amount = selfDrainPercent > 0
? Math.max(1, Math.round((sourceMember?.maxHealth ?? sourceActor.maxHealth) * selfDrainPercent / 100))
: sourceMember
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
const next = state.effects.filter((entry) => !(
entry.kind === effect.kind
&& entry.sourceAbilityId === ability.id
&& entry.targetId === targetActorId
));
next.push({ ...timedEffect(
effect.kind,
ability.id,
sourceActorId,
targetActorId,
{ ...effect, coefficient: amount },
effect.ticks,
effect.intervalMs,
now,
selfDrainPercent > 0 ? "rom-self-health-drain" : effect.kind === "dot" ? effect.tag : undefined,
effect.kind === "dot" ? presentationSchoolForAbility(ability) : undefined,
sourceMember?.gearStats.spellPenetration,
), fixedAmount: amount });
set({ effects: next });
return true;
},
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
const state = get();
const owner = state.actors[ownerActorId];
if (!owner?.alive) return null;
summonSequence += 1;
const isTotem = /totem/i.test(ability.name);
const id = `${ownerActorId}:${isTotem ? "totem" : "summon"}-${summonSequence}`;
const maxHealth = Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3)));
const summon: CombatSummon = {
id,
sourceAbilityId: ability.id,
creatureId: effect.creatureId,
targetId,
expiresAt: now + effect.durationMs,
kind: isTotem ? "totem" : "summon",
name: ability.name,
ownerActorId,
health: maxHealth,
maxHealth,
position,
nextActionAt: now + 1_000,
pulseIntervalMs: isTotem ? 2_000 : 2_200,
attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))),
...initialSummonCombatRuntime(),
...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}),
};
const summons = isTotem
? [...state.summons.filter((candidate) => candidate.exclusiveGroup !== summon.exclusiveGroup), summon]
: [...state.summons, summon];
set({ summons });
get().synchronizeActors();
return id;
},
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
const state = get();
const sourceActor = state.actors[sourceActorId];
if (!sourceActor?.alive) return null;
const targeting = targetingRulesForAbility(ability);
if (targeting.shape !== "ground") return null;
const sourcePosition = actorPosition(state, sourceActorId) ?? state.playerPosition;
const projected = projectCombatGroundPoint(position, targeting.groundRange ?? ability.range.max, sourcePosition);
if (!projected || !combatHasLineOfSight(sourcePosition, projected)) return null;
const periodic = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "dot" | "hot" }> => (
effect.kind === "dot" || effect.kind === "hot"
));
if (!periodic.length) return null;
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const power = sourceMember
? sourceMember.level * 4 + sourceMember.gearStats.spellPower
+ sourceMember.gearStats.attackPower * 0.35 + sourceMember.gearStats.rangedAttackPower * 0.35
: sourceActor.level * 4;
const scaled = (effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>) => Math.max(
1,
Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * Math.max(8, power)),
);
areaEffectSequence += 1;
const id = `${sourceActorId}:area-${areaEffectSequence}`;
const interval = Math.max(100, Math.min(...periodic.map((effect) => effect.intervalMs)));
const duration = Math.max(...periodic.map((effect) => effect.intervalMs * effect.ticks));
const area: CombatAreaEffect = {
id,
sourceActorId,
sourceAbilityId: ability.id,
position: projected,
radius: targeting.radius ?? ability.radius ?? 8,
createdAt: now,
expiresAt: now + duration,
tickIntervalMs: interval,
nextTickAt: now + interval,
fixedDamagePerTick: periodic.filter((effect) => effect.kind === "dot").reduce((sum, effect) => sum + scaled(effect), 0),
fixedHealingPerTick: periodic.filter((effect) => effect.kind === "hot").reduce((sum, effect) => sum + scaled(effect), 0),
};
set({ areaEffects: [...state.areaEffects, area] });
return id;
},
damagePlayer: (amount, school = "physical", attackerLevel) => {
const state = get();
const now = combatNow();
const mitigated = damageAfterEquipmentMitigation(
talentAdjustedIncomingDamage(amount, state.talentModifiers),
state.gearStats,
state.level,
school,
attackerLevel ?? state.level,
);
const incoming = resolveAuraDamageValue(
mitigated,
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
{ direction: "taken", school, attackKind: "spell" },
);
const namedAbsorb = absorbAuraDamage(state.auras, {
targetId: PLAYER_AURA_ENTITY_ID,
amount: incoming,
school,
now,
});
const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage);
const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed);
const remodeledBody = namedAbsorb.auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("rom-remodeled-body")
));
const preventedDeath = Boolean(remodeledBody && healthDamage >= state.health);
const health = preventedDeath ? state.maxHealth : state.health - healthDamage;
const aurasAfterCheatDeath = preventedDeath
? namedAbsorb.auras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId)
: namedAbsorb.auras;
const rage = gainRomRage(
state.classId,
state.secondaryClassId,
healthDamage > 0 && (
hasRomWarriorRagePool(state.classId, state.secondaryClassId)
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && state.maxResourcePools.rage !== undefined)
)
? RAGE_PER_HIT_TAKEN
: 0,
state.resource,
state.maxResource,
{ ...state.resourcePools },
{ ...state.maxResourcePools },
);
const procResult = evaluateAuraProcs(aurasAfterCheatDeath, {
trigger: "damage-taken",
subjectId: PLAYER_AURA_ENTITY_ID,
amount: healthDamage,
school,
}, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`));
let nextAuras = procResult.auras;
if (
healthDamage > 0
&& state.abilities.some((ability) => ability.id === "rom-rogue-rom-warden-elite-60" && isAbilityUnlocked(ability, state.level))
) {
nextAuras = applyCombatAura(nextAuras, {
definition: {
id: "rom-state-revival-charge",
name: "Revival",
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
disposition: "buff",
durationMs: null,
maxStacks: 10,
stackBehavior: "add",
utilityTags: ["rom-revival-charge"],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras;
}
set({
auras: nextAuras,
shield: state.shield - absorbed,
health,
resource: rage.resource,
resourcePools: rage.resourcePools,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "damage",
sourceActorId: "external",
targetActorId: PLAYER_AURA_ENTITY_ID,
school,
rawAmount: incoming,
effectiveAmount: healthDamage,
overheal: 0,
absorbed: namedAbsorb.absorbed + absorbed,
critical: false,
outcome: healthDamage > 0 ? "hit" : "absorb",
}),
activeCast: health <= 0
? null
: healthDamage > 0
? applyWrathCastPushback(state.activeCast, now, `external:${school}:${state.health}`)
: state.activeCast,
autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId,
nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt,
playerAnimationEvent: healthDamage > 0
? nextAnimationEvent(health <= 0 ? "death" : "wound")
: state.playerAnimationEvent,
feedback: nextFeedback("damage", preventedDeath ? "Remodeled Body prevented defeat and restored all health." : health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }),
});
return healthDamage;
},
damageSummon: (id, amount) => {
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (requested <= 0) return 0;
let applied = 0;
set((state) => {
const summon = state.summons.find((candidate) => candidate.id === id);
if (!summon) return state;
const absorbed = absorbAuraDamage(state.auras, {
targetId: id,
amount: requested,
school: "physical",
now: combatNow(),
});
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
const health = summon.health - applied;
const summons = health > 0
? state.summons.map((candidate) => candidate.id === id ? { ...candidate, health } : candidate)
: state.summons.filter((candidate) => candidate.id !== id);
const actors = { ...state.actors };
if (health > 0) actors[id] = combatActorFromSummon({ ...summon, health }, state.level, absorbed.auras);
else delete actors[id];
return {
auras: absorbed.auras,
summons,
actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: combatNow(),
kind: "damage",
sourceActorId: "external",
targetActorId: id,
rawAmount: requested,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: applied > 0 ? "hit" : "absorb",
}),
};
});
return applied;
},
healPlayer: (amount) => {
const state = get();
// Ordinary healing and lingering HoTs must not raise a defeated player.
// Resurrection is an explicit combat action handled by revivePlayer.
if (state.health <= 0) return 0;
const modified = resolveAuraHealingValue(
amount,
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
"taken",
);
const healed = Math.min(Math.max(0, Math.round(modified)), state.maxHealth - state.health);
const procResult = evaluateAuraProcs(state.auras, {
trigger: "heal",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: PLAYER_AURA_ENTITY_ID,
amount: healed,
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
set({
auras: procResult.auras,
health: state.health + healed,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: combatNow(),
kind: "healing",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
rawAmount: Math.max(0, Math.round(modified)),
effectiveAmount: healed,
overheal: Math.max(0, Math.round(modified) - healed),
absorbed: 0,
critical: false,
}),
feedback: nextFeedback("heal", `${healed} health restored.`, { amount: healed }),
});
return healed;
},
revivePlayer: (percentMaxHealth = 0.35) => {
const state = get();
if (state.health > 0 || state.maxHealth <= 0) return 0;
const restored = Math.max(1, Math.round(
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
));
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
set({
health: restored,
controlledUntil: 0,
controlMechanic: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
} } : state.actors,
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
});
return restored;
},
respawnPlayerOnly: (position, now = combatNow()) => {
const state = get();
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
const auras = syncPassiveAuras(state.auras.filter((aura) => (
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
)), state.abilities, state.level, state.talentRanks, now);
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const patch: Partial<CombatState> = {
health: maxHealth,
maxHealth,
resource: initialResourceForMaximum(state.classId, state.maxResource),
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
type, type === resourceProfileForClass(state.classId).type
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
])),
controlledUntil: 0,
controlMechanic: null,
schoolLockedUntil: {},
shield: 0,
shieldExpiresAt: 0,
activeCast: null,
pendingGroundTarget: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
comboPoints: 0,
comboTargetId: null,
playerPosition: [...position],
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
auras,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
}),
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
};
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
return true;
},
cancelCast: (message = "Casting interrupted.") => {
const cast = get().activeCast;
if (!cast) return false;
set((state) => ({
activeCast: null,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: combatNow(),
kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
abilityId: cast.abilityId,
school: cast.school,
rawAmount: 0,
effectiveAmount: 0,
overheal: 0,
absorbed: 0,
critical: false,
outcome: "cancelled",
}),
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
}));
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = combatNow(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
: state;
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let engagedGroups = 0;
for (const id of Object.keys(mobs)) {
const mob = mobs[id];
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
const position = getMobPosition(id);
if (!position) continue;
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
if (!actorId) continue;
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
}
if (engagedGroups > 0) set({ mobs });
return engagedGroups;
},
resetMobForNavigation: (id, atHome, now = combatNow()) => {
const state = get();
const mob = state.mobs[id];
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
set({
mobs: { ...state.mobs, [id]: {
...mob,
engaged: false,
combatPhase: atHome ? "idle" : "returning",
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
health: mob.maxHealth,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextAttackAt: 0,
nextAttackIndex: 0,
threatByActor: {},
targetActorId: null,
forcedTarget: null,
} },
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
});
return true;
},
advanceMobCombat: (now = combatNow(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
: state;
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let namedAuras = [...state.auras];
const incomingHits: Array<{
readonly actorId: AggroActorId;
readonly amount: number;
readonly mob: MobCombatState;
readonly attack: EnemyAttackDefinition;
readonly attackRevision: number;
readonly instantCast: boolean;
}> = [];
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
const memberControls: Array<{
readonly memberId: string;
readonly mechanic: EnemyControlMechanic;
readonly endsAt: number;
}> = [];
let changed = false;
for (const [id, mob] of Object.entries(state.mobs)) {
if (!mob.engaged || mob.dead) continue;
const position = getMobPosition(id);
if (!position) continue;
const home = mob.homePosition ?? position;
const eligible = eligibleAggroActorIds(runtimeState, { ...mob, homePosition: home });
const threatByActor = pruneThreatTable(mob.threatByActor, eligible);
const resolution = resolveAggroTarget(
threatByActor,
eligible,
mob.targetActorId,
mob.forcedTarget,
now,
);
const targetPosition = resolution.targetActorId
? actorPosition(runtimeState, resolution.targetActorId)
: null;
if (!resolution.targetActorId || !targetPosition) {
mobs[id] = {
...mob,
engaged: false,
combatPhase: "returning",
nextAttackAt: 0,
health: mob.maxHealth,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
threatByActor: {},
targetActorId: null,
forcedTarget: null,
};
changed = true;
continue;
}
const activeStatuses = mob.statuses.filter((status) => status.endsAt > now);
const cannotAttack = activeStatuses.some((status) => [
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
].includes(status.kind));
const silenced = activeStatuses.some((status) => status.kind === "silence");
const disarmed = activeStatuses.some((status) => status.kind === "disarm");
const rooted = activeStatuses.some((status) => status.kind === "root");
const dx = position[0] - targetPosition[0];
const dy = position[1] - targetPosition[1];
const dz = position[2] - targetPosition[2];
const distanceSquared = dx * dx + dy * dy + dz * dz;
const targetVisible = combatHasLineOfSight(position, targetPosition);
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
? { ...mob.abilityReadyAt }
: { ...initialEnemyAbilityReadyAt(mob, now) };
const abilityCastCounts = { ...mob.abilityCastCounts };
const schoolLockedUntil = Object.fromEntries(
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
) as Partial<Record<EnemyDamageSchool, number>>;
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
? null
: mob.activeCast;
if (enemyActiveCast) {
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
const pendingPosition = enemyActiveCast.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
position[2] - pendingPosition[2]) > pendingAttack.range)) {
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
enemyActiveCast.targetActorId, pendingPosition);
abilityReadyAt[pendingAttack.id] = now + 250;
enemyActiveCast = null;
}
}
let completingEnemyCast = false;
let selectedAttackIndex = -1;
let selectedAttack: EnemyAttackDefinition | null = null;
if (enemyActiveCast && now >= enemyActiveCast.completesAt) {
selectedAttackIndex = mob.attacks.findIndex((attack) => attack.id === enemyActiveCast!.attackId);
selectedAttack = selectedAttackIndex >= 0 ? mob.attacks[selectedAttackIndex] : null;
completingEnemyCast = selectedAttack !== null;
if (!selectedAttack) enemyActiveCast = null;
} else if (!enemyActiveCast && !cannotAttack) {
for (let offset = 0; offset < mob.attacks.length; offset += 1) {
const index = (mob.nextAttackIndex + offset) % mob.attacks.length;
const attack = mob.attacks[index];
const attackKind = enemyAttackKind(attack);
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|| (targetVisible && distanceSquared <= attack.range * attack.range);
if (
inAbilityRange
&& !(silenced && attackKind === "spell")
&& !(disarmed && attackKind === "melee")
&& now >= (abilityReadyAt[attack.id] ?? 0)
&& now >= (schoolLockedUntil[attack.school] ?? 0)
&& (!attack.maximumCasts || (abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts)
&& enemyAbilityConditionMet(mob, mobs, attack)
) {
selectedAttackIndex = index;
selectedAttack = attack;
break;
}
}
}
// A self buff/heal must not pin an otherwise blocked attacker in place.
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
const inRange = targetVisible && (hostileAttacks.length
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
: distanceSquared <= mob.attackRange * mob.attackRange);
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
const castTargetPosition = enemyActiveCast?.targetActorId
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
position[2] - castTargetPosition[2]) > selectedAttack.range) {
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
abilityReadyAt[selectedAttack.id] = now + 250;
enemyActiveCast = null;
selectedAttack = null;
}
}
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
let attackRevision = mob.attackRevision;
let attackAnimation = mob.attackAnimation;
let nextAttackIndex = mob.nextAttackIndex;
let lastAttackId = mob.lastAttackId;
let activeAuras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
const startingEnemyCast = Boolean(
selectedAttack
&& !completingEnemyCast
&& (selectedAttack.castTimeMs ?? 0) > 0,
);
if (selectedAttack && startingEnemyCast) {
const attack = selectedAttack;
const sourceTransform = getMobRuntimeTransform(id);
enemyActiveCast = {
attackId: attack.id,
name: attack.name,
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
school: attack.school,
interruptible: attack.interruptible !== false,
targetActorId: resolution.targetActorId,
sourcePosition: [...position],
targetPosition: [...targetPosition],
sourceYaw: sourceTransform?.yaw
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
startedAt: now,
completesAt: now + (attack.castTimeMs ?? 0),
};
attackAnimation = "cast";
lastAttackId = attack.id;
nextAttackAt = enemyActiveCast.completesAt;
emitEnemyAttackPresentation(
mob,
attack,
"cast-start",
now,
position,
resolution.targetActorId,
targetPosition,
);
} else if (selectedAttack && !cannotAttack) {
const attack = selectedAttack!;
const resolvedEnemyCast = enemyActiveCast;
enemyActiveCast = null;
const effect = attack.effect ?? {
kind: "damage" as const,
multiplier: attack.damageMultiplier,
};
const eligibleActors = [...eligible].sort();
const randomActor = eligibleActors.length
? eligibleActors[Math.floor(
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
)]
: resolution.targetActorId;
const telegraphPlacement = resolvedEnemyCast
? {
sourcePosition: resolvedEnemyCast.sourcePosition,
targetPosition: resolvedEnemyCast.targetPosition,
sourceYaw: resolvedEnemyCast.sourceYaw,
}
: {
sourcePosition: position,
targetPosition,
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
};
const unvalidatedTargets = attack.target === "nearby-party"
? eligibleActors.filter((actorId) => {
const candidate = actorPosition(runtimeState, actorId);
if (!candidate) return false;
if (attack.telegraph) {
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
}
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
const targetDx = candidate[0] - position[0];
const targetDy = candidate[1] - position[1];
const targetDz = candidate[2] - position[2];
return targetDx * targetDx + targetDy * targetDy + targetDz * targetDz
<= radius * radius;
})
: attack.target === "random-party"
? [randomActor]
: attack.target === "primary"
? [resolution.targetActorId]
: [];
const enemySpatial = combatSpatialActor(id);
const targets = unvalidatedTargets.filter((actorId) => {
const candidate = actorPosition(runtimeState, actorId);
if (!candidate || !combatHasLineOfSight(position, candidate)) return false;
return attack.target === "nearby-party"
|| !enemySpatial
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
});
const presentationTargetId = attack.target === "self"
? id
: targets[0] ?? resolution.targetActorId;
const presentationTargetPosition = attack.target === "self"
? position
: actorPosition(runtimeState, presentationTargetId);
const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined;
emitEnemyAttackPresentation(
mob,
attack,
"release",
now,
position,
presentationTargetId,
presentationTargetPosition,
instantSourceCast,
);
const effects = effect.kind === "composite" ? effect.effects : [effect];
let hasDamageResolution = false;
for (const effect of effects) {
if (effect.kind === "damage") {
hasDamageResolution = true;
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
formula: attack.runewakerFormula,
baselineDamage: mob.attackDamage,
}).amount
: baselineDamage;
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
} else if (effect.kind === "leech") {
hasDamageResolution = true;
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier));
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
if (targets.length) {
enemyHealing.push({
targetId: id,
amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)),
});
}
} else if (effect.kind === "heal") {
const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health"
? attack.condition.amount
: 1;
const friendlyRange = attack.condition?.kind === "friendly-missing-health"
? attack.condition.range
: attack.range;
const friendly = Object.values(mobs)
.filter((candidate) => {
if (candidate.dead || candidate.health > candidate.maxHealth - requiredMissingHealth) {
return false;
}
const candidatePosition = getMobPosition(candidate.id);
if (!candidatePosition) return false;
const friendlyDx = candidatePosition[0] - position[0];
const friendlyDy = candidatePosition[1] - position[1];
const friendlyDz = candidatePosition[2] - position[2];
return friendlyDx * friendlyDx + friendlyDy * friendlyDy + friendlyDz * friendlyDz
<= friendlyRange * friendlyRange
&& combatHasLineOfSight(position, candidatePosition);
})
.sort((left, right) => (
(left.health / Math.max(1, left.maxHealth))
- (right.health / Math.max(1, right.maxHealth))
|| left.id.localeCompare(right.id)
))[0];
if (friendly) {
enemyHealing.push({
targetId: friendly.id,
amount: Math.max(1, Math.round(mob.attackDamage * effect.multiplier)),
});
}
} else if (effect.kind === "aura") {
const endsAt = effect.durationMs > 0 ? now + effect.durationMs : 0;
activeAuras = [
...activeAuras.filter((aura) => aura.id !== effect.aura),
{
id: effect.aura,
sourceAbilityId: attack.id,
endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.lifeStealPercent === undefined
? {}
: { lifeStealPercent: effect.lifeStealPercent }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
},
];
namedAuras = [...applyCombatAura(namedAuras, {
definition: {
id: `enemy:${id}:${effect.aura}`,
name: `${attack.name}: ${effect.aura}`,
disposition: "buff",
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
dispelCategory: effect.aura === "enrage" ? "enrage" : "magic",
...(effect.damageTakenMultiplier === undefined
? {}
: {
modifiers: [{
kind: "damage" as const,
direction: "taken" as const,
operation: "percent" as const,
value: effect.damageTakenMultiplier - 1,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
...(effect.absorbMultiplier === undefined
? {}
: {
absorbs: [{
id: `${attack.id}:absorb`,
amount: Math.max(1, Math.round(mob.attackDamage * effect.absorbMultiplier)),
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
...(effect.reflectSpellChance === undefined
? {}
: { reflectSpellChance: effect.reflectSpellChance }),
},
sourceId: id,
targetId: id,
now,
}).auras];
} else if (effect.kind === "target-aura") {
for (const actorId of targets) {
namedAuras = [...applyCombatAura(namedAuras, {
definition: {
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
name: `${attack.name}: ${effect.aura}`,
disposition: "debuff",
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
dispelCategory: "magic",
...(effect.damageTakenMultiplier === undefined
? {}
: {
modifiers: [{
kind: "damage" as const,
direction: "taken" as const,
operation: "percent" as const,
value: effect.damageTakenMultiplier - 1,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}],
}),
},
sourceId: id,
targetId: actorId,
now,
}).auras];
}
} else if (effect.kind === "control") {
if ((effect.damageMultiplier ?? 0) > 0) {
hasDamageResolution = true;
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
formula: attack.runewakerFormula,
baselineDamage: mob.attackDamage,
}).amount
: baselineDamage;
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
instantCast: instantSourceCast,
});
}
}
for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
if (actorId === PLAYER_AGGRO_ID) playerControl = control;
else memberControls.push({ memberId: actorId, ...control });
namedAuras = [...applyCombatAura(namedAuras, {
definition: {
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
name: `${attack.name}: ${effect.mechanic}`,
description: effect.mechanic === "slow"
? `${attack.name} reduces movement speed until the effect ends.`
: effect.mechanic === "root"
? `${attack.name} prevents movement until the effect ends.`
: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
disposition: "debuff",
durationMs: effect.durationMs,
dispelCategory: "magic",
},
sourceId: id,
targetId: actorId,
now,
}).auras];
}
} else if (effect.kind === "transform") {
activeAuras = [
...activeAuras.filter((aura) => !aura.id.startsWith("form:")),
{
id: `form:${effect.form}`,
sourceAbilityId: attack.id,
endsAt: 0,
},
];
} else if (effect.kind === "pull") {
const pulledPosition: CombatPosition = [position[0], position[1], position[2]];
for (const actorId of targets) {
if (actorId === PLAYER_AGGRO_ID) {
set({ playerPosition: pulledPosition });
} else {
updatePartyRuntimePosition(
actorId,
pulledPosition[0],
pulledPosition[1],
pulledPosition[2],
);
}
}
} else if (effect.kind === "call-for-help") {
for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) {
const helper = mobs[helperId];
if (!helper || helper.dead) continue;
mobs[helperId] = {
...helper,
engaged: true,
combatPhase: "chasing",
threatByActor: seedActorThreat(helper.threatByActor, resolution.targetActorId),
targetActorId: helper.targetActorId ?? resolution.targetActorId,
homePosition: helper.homePosition ?? getMobPosition(helperId),
abilityReadyAt: Object.keys(helper.abilityReadyAt).length
? helper.abilityReadyAt
: initialEnemyAbilityReadyAt(helper, now),
};
}
} else if (effect.kind === "summon") {
const castNumber = (abilityCastCounts[attack.id] ?? 0) + 1;
for (let summonIndex = 0; summonIndex < effect.count; summonIndex += 1) {
const summonNamespace = effect.entityId?.trim() || `${id}:${attack.id}:summon`;
const summonId = `${summonNamespace}:${castNumber}:${summonIndex + 1}`;
if (mobs[summonId]) continue;
const angle = summonIndex * 2.399963229728653;
const summonPosition: CombatPosition = [
position[0] + Math.cos(angle) * 2.2,
position[1],
position[2] + Math.sin(angle) * 2.2,
];
setMobPosition(summonId, summonPosition);
mobs[summonId] = summonedMobCombatState(
summonId,
mob,
effect.creatureEntry,
resolution.targetActorId,
summonPosition,
effect.spawnGroupId,
);
}
} else if (effect.kind === "encounter-event") {
dispatchDungeonEncounterSignal(effect.eventId, now);
}
}
if (hasDamageResolution && targets.length) {
const lifeStealPercent = activeAuras.reduce(
(maximum, aura) => Math.max(maximum, aura.lifeStealPercent ?? 0),
0,
);
if (lifeStealPercent > 0) {
enemyHealing.push({
targetId: id,
amount: Math.max(1, Math.round(mob.attackDamage * lifeStealPercent)),
});
}
}
if (!hasDamageResolution) {
const auraResolution = effects.some((entry) => (
entry.kind === "aura"
|| entry.kind === "target-aura"
|| entry.kind === "control"
|| entry.kind === "transform"
));
emitEnemyAttackPresentation(
mob,
attack,
auraResolution ? "aura-start" : "impact",
now,
position,
presentationTargetId,
presentationTargetPosition,
instantSourceCast,
);
}
attackRevision += 1;
abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1;
attackAnimation = attack.animation;
nextAttackIndex = (selectedAttackIndex + 1) % mob.attacks.length;
lastAttackId = attack.id;
const slowAttack = activeStatuses.find((status) => status.kind === "attack-speed")?.magnitude ?? 0;
const cooldownRange = attack.repeatCooldownMs
?? [attack.cooldownMs, attack.cooldownMs] as const;
abilityReadyAt[attack.id] = attack.maximumCasts
&& abilityCastCounts[attack.id] >= attack.maximumCasts
? Number.POSITIVE_INFINITY
: now + Math.round(
deterministicEnemyCooldown(id, attack.id, attackRevision, cooldownRange) * (1 + slowAttack),
);
nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
}
if (enemyActiveCast) nextAttackAt = enemyActiveCast.completesAt;
if (
phase !== mob.combatPhase
|| nextAttackAt !== mob.nextAttackAt
|| attackRevision !== mob.attackRevision
|| attackAnimation !== mob.attackAnimation
|| nextAttackIndex !== mob.nextAttackIndex
|| lastAttackId !== mob.lastAttackId
|| activeStatuses.length !== mob.statuses.length
|| activeAuras.length !== mob.auras.length
|| Object.entries(abilityReadyAt).some(([abilityId, readyAt]) => (
mob.abilityReadyAt[abilityId] !== readyAt
))
|| Object.entries(abilityCastCounts).some(([abilityId, count]) => (
mob.abilityCastCounts[abilityId] !== count
))
|| enemyActiveCast !== mob.activeCast
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|| Object.entries(schoolLockedUntil).some(([school, lockedUntil]) => (
mob.schoolLockedUntil[school as EnemyDamageSchool] !== lockedUntil
))
|| threatByActor !== mob.threatByActor
|| resolution.targetActorId !== mob.targetActorId
|| resolution.forcedTarget !== mob.forcedTarget
) {
mobs[id] = {
...mob,
combatPhase: phase,
nextAttackAt,
abilityReadyAt,
abilityCastCounts,
activeCast: enemyActiveCast,
schoolLockedUntil,
attackRevision,
attackAnimation,
nextAttackIndex,
lastAttackId,
statuses: activeStatuses,
auras: activeAuras,
threatByActor,
targetActorId: resolution.targetActorId,
forcedTarget: resolution.forcedTarget,
homePosition: home,
};
changed = true;
}
}
for (const healing of enemyHealing) {
const target = mobs[healing.targetId];
if (!target || target.dead) continue;
mobs[healing.targetId] = {
...target,
health: Math.min(target.maxHealth, target.health + healing.amount),
};
changed = true;
}
if (changed || playerControl) {
set({
mobs,
auras: namedAuras,
...(playerControl
? {
controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt),
controlMechanic: playerControl.mechanic,
schoolLockedUntil: state.activeCast && playerControl.mechanic === "silence"
? {
...state.schoolLockedUntil,
[state.activeCast.school]: Math.max(state.schoolLockedUntil[state.activeCast.school] ?? 0, playerControl.endsAt),
}
: state.schoolLockedUntil,
activeCast: null,
}
: {}),
});
}
let appliedDamage = 0;
for (const hit of incomingHits) {
const impactOrigin = getMobPosition(hit.mob.id);
const impactTarget = actorPosition(get(), hit.actorId);
if (impactOrigin && impactTarget) {
emitEnemyAttackPresentation(
hit.mob,
hit.attack,
"impact",
now,
impactOrigin,
hit.actorId,
impactTarget,
hit.instantCast,
);
}
const defendingAuras = aurasForEntity(get().auras, hit.actorId);
const reflectChance = enemyAttackKind(hit.attack) === "spell"
? Math.min(1, defendingAuras.reduce((chance, aura) => Math.max(chance, aura.definition.reflectSpellChance ?? 0), 0))
: 0;
if (reflectChance > 0 && stableCombatUnit(`${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:reflect`) < reflectChance) {
get().damageMob(
hit.mob.id,
hit.amount,
now,
playerThreatSource(get(), `reflect:${hit.attack.id}`),
hit.attack.school,
);
continue;
}
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
const commitMeleeAutoProc = (landed: boolean): void => {
const current = get();
const currentMob = current.mobs[hit.mob.id];
if (!currentMob) return;
const auras = advanceEnemyMeleeAutoProcAuras(
currentMob,
hit.attack,
now,
hit.attackRevision,
landed,
meleeCriticalCharge?.id,
);
if (auras === currentMob.auras) return;
set({
mobs: {
...current.mobs,
[currentMob.id]: { ...currentMob, auras },
},
});
};
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
playerCharacterStats(
current.classId,
current.level,
current.gearStats,
current.raceId,
current.secondaryClassId,
current.talentModifiers,
),
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
);
const combatResult = resolveEnemyDamageResult(
hit.mob,
hit.attack,
defender,
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
);
commitMeleeAutoProc(combatResult.landed);
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
const namedAbsorb = absorbAuraDamage(current.auras, {
targetId: PLAYER_AURA_ENTITY_ID,
amount: auraAdjustedDamage,
school: hit.attack.school,
now,
});
const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage);
const healthDamage = Math.min(current.health, namedAbsorb.remainingDamage - absorbed);
const health = current.health - healthDamage;
const rage = gainRomRage(
current.classId,
current.secondaryClassId,
healthDamage > 0 && (
hasRomWarriorRagePool(current.classId, current.secondaryClassId)
|| (!hasRomRagePool(current.classId, current.secondaryClassId) && current.maxResourcePools.rage !== undefined)
)
? RAGE_PER_HIT_TAKEN
: 0,
current.resource,
current.maxResource,
{ ...current.resourcePools },
{ ...current.maxResourcePools },
);
const avoidTrigger = combatResult.dodged
? "dodge"
: combatResult.parried
? "parry"
: combatResult.blocked
? "block"
: null;
const procEvent: ProcEvent | null = avoidTrigger
? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id }
: healthDamage > 0
? {
trigger: "damage-taken",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: hit.mob.id,
abilityId: hit.attack.id,
amount: healthDamage,
school: hit.attack.school,
attackKind: enemyAttackKind(hit.attack),
}
: null;
const procProgression = progressionFromState(current);
procProgression.health = health;
const procWork: MutableCastWork = {
classId: current.classId,
abilities: current.abilities,
mobs: { ...get().mobs },
effects: [...current.effects],
auras: [...namedAbsorb.auras],
summons: [...current.summons],
areaEffects: [...current.areaEffects],
combatEvents: [...current.combatEvents],
actors: { ...current.actors },
cooldowns: { ...current.cooldowns },
health,
maxHealth: current.maxHealth,
shield: current.shield - absorbed,
shieldExpiresAt: current.shieldExpiresAt,
resource: rage.resource,
maxResource: current.maxResource,
resourcePools: { ...rage.resourcePools },
maxResourcePools: { ...current.maxResourcePools },
lastResourceSpentAt: { ...current.lastResourceSpentAt },
comboPoints: current.comboPoints,
comboTargetId: current.comboTargetId,
deathKnightRunes: current.deathKnightRunes,
progression: procProgression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: current.talentRanks,
talentModifiers: current.talentModifiers,
gearStats: current.gearStats,
raceId: current.raceId,
secondaryClassId: current.secondaryClassId,
};
if (healthDamage > 0) {
procWork.auras = procWork.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("stealth")
));
}
procWork.auras = consumeInnerFireCharges(
procWork.auras, PLAYER_AURA_ENTITY_ID, combatResult.landed, enemyAttackKind(hit.attack),
);
if (procEvent) evaluateProcsInWork(procWork, procEvent, now);
recordCombatEventInWork(procWork, {
occurredAt: now,
kind: "damage",
sourceActorId: hit.mob.id,
targetActorId: PLAYER_AURA_ENTITY_ID,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: combatResult.rawDamage,
effectiveAmount: healthDamage,
overheal: 0,
absorbed: namedAbsorb.absorbed + absorbed,
critical: combatResult.critical,
outcome: combatResult.outcome,
});
set({
auras: procWork.auras,
mobs: procWork.mobs,
shield: procWork.shield,
health: procWork.health,
resource: procWork.resource,
resourcePools: procWork.resourcePools,
cooldowns: procWork.cooldowns,
combatEvents: procWork.combatEvents,
actors: procWork.actors,
lastCombatResult: combatResult,
activeCast: procWork.health <= 0
? null
: healthDamage > 0
? applyWrathCastPushback(current.activeCast, now, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`)
: current.activeCast,
autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId,
nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt,
playerAnimationEvent: healthDamage > 0
? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound")
: current.playerAnimationEvent,
feedback: nextFeedback(
"damage",
procWork.health <= 0
? "You have been defeated."
: `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)}.`,
{ amount: healthDamage, abilityId: hit.attack.id },
),
});
appliedDamage += healthDamage;
} else {
const partyState = usePartyStore.getState();
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
if (summon) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
incomingAmount,
aurasForEntity(current.auras, summon.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
const absorbed = absorbAuraDamage(current.auras, {
targetId: summon.id,
amount: adjusted,
school: hit.attack.school,
now,
});
const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
set({
auras: absorbed.auras,
summons: current.summons.flatMap((candidate) => candidate.id !== summon.id
? [candidate]
: candidate.health - applied > 0
? [{ ...candidate, health: candidate.health - applied }]
: []),
combatEvents: recordCombatEvent(current.combatEvents, {
occurredAt: now,
kind: "damage",
sourceActorId: hit.mob.id,
targetActorId: summon.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: incomingAmount,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical,
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += applied;
continue;
}
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
incomingAmount,
aurasForEntity(current.auras, onlineActor.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
const absorbed = absorbAuraDamage(current.auras, {
targetId: onlineActor.id,
amount: adjusted,
school: hit.attack.school,
now,
});
const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage)));
const nextActor: CombatActorState = {
...onlineActor,
health: onlineActor.health - damaged,
alive: onlineActor.health - damaged > 0,
cast: onlineActor.health - damaged > 0 ? onlineActor.cast : null,
};
set({
actors: { ...current.actors, [nextActor.id]: nextActor },
auras: absorbed.auras,
combatEvents: recordCombatEvent(current.combatEvents, {
occurredAt: now,
kind: "damage",
sourceActorId: hit.mob.id,
targetActorId: onlineActor.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: incomingAmount,
effectiveAmount: damaged,
overheal: 0,
absorbed: absorbed.absorbed,
critical,
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += damaged;
continue;
}
const storedMember = partyState.members.find((candidate) => candidate.id === hit.actorId);
if (!storedMember) continue;
const currentBeforeHit = get();
const authoritativeActor = companionActorFromMember(
storedMember,
entityAbsorbAmount(currentBeforeHit.auras, storedMember.id),
currentBeforeHit.actors[storedMember.id],
);
const member = memberFromCompanionActor(storedMember, authoritativeActor);
if (member.health <= 0) continue;
const defender = applyAuraStats(
deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
),
aurasForEntity(get().auras, member.id),
);
const combatResult = resolveEnemyDamageResult(
hit.mob,
hit.attack,
defender,
hit.amount,
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
aurasForEntity(get().auras, member.id),
);
commitMeleeAutoProc(combatResult.landed);
const currentAuras = get().auras;
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
aurasForEntity(currentAuras, hit.actorId),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
const namedAbsorb = absorbAuraDamage(currentAuras, {
targetId: hit.actorId,
amount: auraAdjustedDamage,
school: hit.attack.school,
now,
});
const redirectAura = aurasForEntity(namedAbsorb.auras, hit.actorId)
.find((aura) => (aura.definition.redirectDamagePercent ?? 0) > 0);
const redirectFraction = Math.min(1, Math.max(0, redirectAura?.definition.redirectDamagePercent ?? 0));
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
: 0;
const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")
? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId)
: namedAbsorb.auras;
const damaged = Math.min(
authoritativeActor.health,
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
);
let nextCast = authoritativeActor.cast;
if (damaged > 0 && nextCast && nextCast.pushbackHits < 2) {
const castingAbility = abilityById(nextCast.abilityId);
const resistance = castingAbility ? abilityPushbackResistance(castingAbility) : 0;
if (stableCombatUnit(`${member.id}:${hit.attack.id}:${hit.attackRevision}:pushback`) >= resistance) {
nextCast = {
...nextCast,
pushbackHits: nextCast.pushbackHits + 1,
completesAt: nextCast.mode === "channel"
? Math.max(now, nextCast.completesAt - nextCast.originalDurationMs * 0.25)
: nextCast.completesAt + 500,
};
}
}
const ragePool = authoritativeActor.resources.rage;
const nextActor: CombatActorState = {
...authoritativeActor,
health: authoritativeActor.health - damaged,
alive: authoritativeActor.health - damaged > 0,
cast: authoritativeActor.health - damaged > 0 ? nextCast : null,
resources: damaged > 0 && ragePool
? {
...authoritativeActor.resources,
rage: {
...ragePool,
current: Math.min(ragePool.maximum, ragePool.current + RAGE_PER_HIT_TAKEN),
},
}
: authoritativeActor.resources,
};
mirrorCompanionActor(nextActor);
if (redirected > 0) {
const player = get();
const transferred = Math.min(player.health, redirected);
set({ health: player.health - transferred, activeCast: player.health - transferred <= 0 ? null : player.activeCast });
appliedDamage += transferred;
}
appliedDamage += damaged;
set({
actors: { ...get().actors, [nextActor.id]: nextActor },
auras: consumeInnerFireCharges(
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
),
combatEvents: recordCombatEvent(get().combatEvents, {
occurredAt: now,
kind: "damage",
sourceActorId: hit.mob.id,
targetActorId: member.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: combatResult.rawDamage,
effectiveAmount: damaged,
overheal: 0,
absorbed: namedAbsorb.absorbed,
critical: combatResult.critical,
outcome: combatResult.outcome,
}),
lastCombatResult: combatResult,
feedback: nextFeedback(
"damage",
`${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)} against ${member.name}.`,
{ amount: damaged, abilityId: hit.attack.id },
),
});
}
}
for (const control of memberControls) {
const current = get();
const summon = current.summons.find((candidate) => candidate.id === control.memberId);
if (summon) {
const nextSummon = {
...summon,
controlledUntil: Math.max(summon.controlledUntil, control.endsAt),
cast: null,
schoolLockedUntil: control.mechanic === "silence" && summon.cast
? {
...summon.schoolLockedUntil,
[summon.cast.school]: Math.max(summon.schoolLockedUntil[summon.cast.school] ?? 0, control.endsAt),
}
: summon.schoolLockedUntil,
};
set({
summons: current.summons.map((candidate) => candidate.id === summon.id ? nextSummon : candidate),
actors: {
...current.actors,
[summon.id]: combatActorFromSummon(nextSummon, current.level, current.auras),
},
});
continue;
}
const partyState = usePartyStore.getState();
const storedMember = partyState.members.find((member) => member.id === control.memberId);
if (!storedMember) {
const onlineActor = onlinePlayerRuntimeActor(control.memberId);
if (onlineActor) {
set({
actors: {
...current.actors,
[onlineActor.id]: {
...onlineActor,
controlledUntil: Math.max(onlineActor.controlledUntil, control.endsAt),
cast: null,
},
},
});
}
continue;
}
const actor = companionActorFromMember(
storedMember,
entityAbsorbAmount(current.auras, storedMember.id),
current.actors[storedMember.id],
);
const nextActor: CombatActorState = {
...actor,
controlledUntil: Math.max(actor.controlledUntil, control.endsAt),
cast: null,
schoolLockedUntil: control.mechanic === "silence" && actor.cast
? {
...actor.schoolLockedUntil,
[actor.cast.school]: Math.max(actor.schoolLockedUntil[actor.cast.school] ?? 0, control.endsAt),
}
: actor.schoolLockedUntil,
};
set({ actors: { ...current.actors, [nextActor.id]: nextActor } });
mirrorCompanionActor(nextActor);
partyState.updateMemberCombat(control.memberId, {
controlMechanic: control.mechanic,
});
}
return appliedDamage;
},
tick: (deltaSeconds, now = combatNow()) => {
const partySelfDrainTicks: Array<{ memberId: string; amount: number; sourceAbilityId: string }> = [];
const partyHotTicks: Array<{
memberId: string;
amount: number;
sourceActorId: AggroActorId;
sourceAbilityId: string;
}> = [];
const shadowfiendManaRestores: Array<{ ownerActorId: string; summonId: string; abilityId: string }> = [];
set((state) => {
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
const resourceProfile = resourceProfileForClass(state.classId);
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
const auraExpiration = expireAuras(state.auras, now);
const activeAuras = [...auraExpiration.active];
const baseRegeneration = applyTalentModifierValue(
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
? 0
: resourceProfile.regenerationPerSecond
+ state.gearStats.mp5 / 5
+ state.gearStats.spirit * 0.02,
state.talentModifiers,
"resource-regeneration-percent",
);
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
const regeneration = resolveAuraResourceValue(
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
playerAuras,
"power",
"regeneration",
);
const activePool = advanceCombatResourcePool(normalizeActorResourcePool(
resourceProfile.type,
state.resource,
state.maxResource,
regeneration,
state.lastResourceSpentAt[resourceProfile.type] ?? 0,
), {
inCombat,
actorLevel: state.level,
now,
deltaSeconds: delta,
manaSpiritRegenerationPerSecond: state.gearStats.spirit * 0.02,
});
let resource = activePool.current;
const resourcePools: Partial<Record<ResourceType, number>> = { ...state.resourcePools };
const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId);
const passiveRates: Partial<Record<ResourceType, number>> = {
mana: 4,
rage: 0,
energy: 10,
focus: 6,
"runic-power": 0,
};
for (const [type, maximum] of Object.entries(state.maxResourcePools) as [ResourceType, number][]) {
if (type === resourceProfile.type) continue;
resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
type,
resourcePools[type] ?? 0,
maximum,
passiveRates[type] ?? 0,
state.lastResourceSpentAt[type] ?? 0,
), {
inCombat,
actorLevel: state.level,
now,
deltaSeconds: delta,
manaSpiritRegenerationPerSecond: 0,
}).current;
}
resourcePools[resourceProfile.type] = resource;
let shield = state.shield;
let shieldExpiresAt = state.shieldExpiresAt;
if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) {
shield = 0;
shieldExpiresAt = 0;
}
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [],
auras: activeAuras,
summons: state.summons.filter((summon) => summon.expiresAt > now),
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health <= 0 ? 0 : Math.min(
state.maxHealth,
state.health + resolveAuraStatValue(
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
playerAuras,
"health-regeneration",
) * delta,
),
maxHealth: state.maxHealth,
shield,
shieldExpiresAt,
resource,
maxResource: state.maxResource,
resourcePools,
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) {
applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras);
}
let autoAttackTargetId = state.autoAttackTargetId;
let nextAutoAttackAt = state.nextAutoAttackAt;
let autoAttackEvent: CharacterCombatAnimationEvent | null = null;
const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null;
if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) {
autoAttackTargetId = null;
nextAutoAttackAt = 0;
} else {
const autoAttackTargetPosition = getMobPosition(autoAttackTargetId!);
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
const canAutoAttack = (
!state.activeCast
&& (
state.controlledUntil <= now
|| !enemyControlPreventsActions(state.controlMechanic)
)
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
&& Boolean(autoAttackTargetPosition)
&& combatHasLineOfSight(state.playerPosition, autoAttackTargetPosition ?? state.playerPosition)
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
);
if (canAutoAttack) {
const profile = playerAutoAttackProfile(state);
if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) {
const rage = gainRomRage(
state.classId,
state.secondaryClassId,
ROM_CHAMPION_RAGE_PER_SECOND * profile.hasteMultiplier * delta,
work.resource,
work.maxResource,
work.resourcePools,
work.maxResourcePools,
);
work.resource = rage.resource;
work.resourcePools = rage.resourcePools;
}
if (now >= nextAutoAttackAt) {
const offHandId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.["off-hand"];
const isDualWielding = Boolean(
offHandId && state.inventory.some((item) => item.instanceId === offHandId && item.weaponType),
);
const autoAttackSchool = state.gearStats.meleeWeapon?.damageSchool ?? "physical";
const result = resolveAndDamageMobInWork(
state.classId,
work,
autoAttackTargetId!,
profile.damage,
now,
playerThreatSource(state),
"melee",
autoAttackSchool,
`${PLAYER_AGGRO_ID}:auto:${autoAttackTargetId}:${now}`,
undefined,
false,
{ isAutoAttack: true, isDualWielding },
);
if (result.amount > 0 && (
hasNativeRomWarriorRage
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && work.maxResourcePools.rage !== undefined)
)) {
const rage = gainRomRage(
state.classId,
state.secondaryClassId,
RAGE_PER_AUTO_ATTACK,
work.resource,
work.maxResource,
work.resourcePools,
work.maxResourcePools,
);
work.resource = rage.resource;
work.resourcePools = rage.resourcePools;
}
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
if (result.amount > 0) {
const targetPosition = getMobPosition(autoAttackTargetId!);
emitCombatPresentation({
occurredAt: now,
phase: "release",
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: autoAttackTargetId!,
abilityId: "player-auto-attack",
name: "Auto Attack",
source: "fallback",
school: autoAttackSchool,
delivery: "melee",
origin: state.playerPosition,
targetPosition: targetPosition ?? state.playerPosition,
});
}
nextAutoAttackAt = now + profile.intervalMs;
if (result.killed) {
autoAttackTargetId = null;
nextAutoAttackAt = 0;
}
}
}
}
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
if (effect.kind === "dot") {
if (effect.tag === "rom-self-health-drain") {
const casterBuffActive = work.auras.some((aura) => aura.targetId === effect.targetId
&& aura.sourceId === effect.sourceActorId
&& aura.definition.utilityTags?.includes("rom-self-health-drain"));
if (!casterBuffActive) return false;
if (effect.targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
return Boolean(effect.targetId && work.actors[effect.targetId]?.alive);
}
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
return Boolean(mob && !mob.dead && mob.health > 0);
}
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
const summon = work.summons.find((candidate) => candidate.id === targetId);
if (summon) return summon.health > 0;
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
return Boolean(actor?.alive && actor.health > 0);
};
for (const effect of state.effects) {
// Check before due ticks/procs, not only after applying their health changes.
if (!timedEffectTargetAlive(effect)) continue;
let remainingTicks = effect.remainingTicks;
let nextTickAt = effect.nextTickAt;
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
const ability = abilityById(effect.sourceAbilityId);
const baseAmount = effect.fixedAmount ?? (ability
? equipmentAdjustedCombatAmount(
effect,
progression.level,
state.gearStats,
state.classId,
ability,
)
: Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level))));
const amount = ability && effect.fixedAmount === undefined
? talentAdjustedAmount(
baseAmount,
state.talentModifiers,
effect.kind === "dot" ? "damage-percent" : "healing-percent",
ability,
true,
)
: baseAmount;
if (effect.kind === "dot" && effect.tag === "rom-self-health-drain" && effect.targetId) {
if (effect.targetId === PLAYER_AURA_ENTITY_ID) {
const drained = Math.min(work.health, amount);
work.health -= drained;
work.progression.health = work.health;
work.totalDamage += drained;
recordCombatEventInWork(work, {
occurredAt: nextTickAt, kind: "damage", sourceActorId: effect.sourceActorId,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: effect.sourceAbilityId,
rawAmount: drained, effectiveAmount: drained, overheal: 0, absorbed: 0, critical: false,
});
} else {
partySelfDrainTicks.push({ memberId: effect.targetId, amount, sourceAbilityId: effect.sourceAbilityId });
}
} else if (effect.kind === "dot" && effect.targetId) {
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
const result = resolveAndDamageMobInWork(
state.classId,
work,
effect.targetId,
amount,
nextTickAt,
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
dotAttackKind,
effect.damageSchool ?? "physical",
`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}`,
ability ?? undefined,
true,
{
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
);
grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt);
} else if (effect.kind === "hot") {
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
const modifiedHealing = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(
amount,
aurasForEntity(work.auras, effect.sourceActorId),
"done",
),
aurasForEntity(work.auras, hotTargetId),
"taken",
));
if (effect.targetId) {
const target = ensureCompanionActorInWork(work, hotTargetId)
?? work.actors[hotTargetId]
?? (work.summons.some((summon) => summon.id === hotTargetId)
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
: null);
if (target?.alive) partyHotTicks.push({
memberId: target.id,
amount: modifiedHealing,
sourceActorId: effect.sourceActorId,
sourceAbilityId: effect.sourceAbilityId,
});
} else {
const healed = healPlayerInWork(work, modifiedHealing);
work.totalHealing += healed;
addHealingThreatInWork(
work,
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
healed,
);
}
}
evaluateProcsInWork(work, {
trigger: "periodic",
subjectId: effect.sourceActorId,
otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID,
abilityId: effect.sourceAbilityId,
amount,
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
}, nextTickAt);
if (ability) {
const sourceActorId = effect.sourceActorId;
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
const origin = sourceActorId === PLAYER_AGGRO_ID
? state.playerPosition
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
const targetPosition = effect.targetId
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
: state.playerPosition;
emitCombatPresentation({
occurredAt: nextTickAt,
phase: "tick",
actorGroup,
sourceActorId,
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
origin,
targetPosition: targetPosition ?? origin,
});
}
remainingTicks -= 1;
nextTickAt += effect.intervalMs;
}
if (effect.kind === "dot" && remainingTicks === 0 && abilityById(effect.sourceAbilityId)?.name === "Living Bomb") {
detonateLivingBombInWork(work, effect, nextTickAt - effect.intervalMs);
}
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
work.effects.push(
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
? effect
: { ...effect, remainingTicks, nextTickAt },
);
}
}
work.areaEffects = work.areaEffects.map((area) => {
let nextTickAt = area.nextTickAt;
const catalogAbility = abilityById(area.sourceAbilityId);
const ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level) : null;
while (ability && nextTickAt <= now && nextTickAt < area.expiresAt) {
const resolvedFixedKinds = new Set<"dot" | "hot">();
for (const periodic of ability.effects) {
if (periodic.kind !== "dot" && periodic.kind !== "hot") continue;
const fixedAmount = periodic.kind === "dot" ? area.fixedDamagePerTick : area.fixedHealingPerTick;
if (fixedAmount !== undefined && resolvedFixedKinds.has(periodic.kind)) continue;
if (fixedAmount !== undefined) resolvedFixedKinds.add(periodic.kind);
const amount = fixedAmount ?? talentAdjustedAmount(
equipmentAdjustedCombatAmount(periodic, state.level, state.gearStats, state.classId, ability),
state.talentModifiers,
periodic.kind === "dot" ? "damage-percent" : "healing-percent",
ability,
true,
);
if (periodic.kind === "dot") {
for (const id of nearbyMobRuntimeIds(area.position, area.radius)) {
const position = getMobPosition(id);
if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue;
resolveAndDamageMobInWork(
state.classId,
work,
id,
amount,
nextTickAt,
threatSourceForActor(state, area.sourceActorId, ability.id, true),
abilityAttackKind(state.classId, ability),
abilityDamageSchool(state.classId, ability),
`${area.id}:${id}:${nextTickAt}`,
ability,
true,
{ canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
);
}
} else {
const partyMembers = usePartyStore.getState().members;
for (const friendlyId of [PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id)]) {
const position = friendlyId === PLAYER_AURA_ENTITY_ID
? state.playerPosition
: getPartyRuntimePosition(friendlyId);
if (!position
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|| !combatHasLineOfSight(area.position, position)) continue;
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
if (member ? member.health <= 0 : work.health <= 0) continue;
const healed = member
? healCompanionActorInWork(work, friendlyId, amount)
: healPlayerInWork(work, amount);
work.totalHealing += healed;
recordCombatEventInWork(work, {
occurredAt: nextTickAt,
kind: "healing",
sourceActorId: area.sourceActorId,
targetActorId: friendlyId,
abilityId: ability.id,
rawAmount: amount,
effectiveAmount: healed,
overheal: Math.max(0, amount - healed),
absorbed: 0,
critical: false,
});
}
}
}
nextTickAt += area.tickIntervalMs;
}
return nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt };
});
work.summons = work.summons.map((summon) => {
if (
summon.health <= 0
|| summon.controlledUntil > now
|| summon.cast
|| summon.attackDamage <= 0
) return summon;
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
const summonAbility = abilityById(summon.sourceAbilityId);
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
if (!readyToAct) return summon;
const partyMembers = usePartyStore.getState().members;
const injured = [
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
...partyMembers.map((member) => ({
id: member.id,
health: member.health,
maxHealth: member.maxHealth,
position: getPartyRuntimePosition(member.id),
})),
].filter((candidate) => candidate.health > 0
&& candidate.health < candidate.maxHealth
&& candidate.position
&& Math.sqrt(distanceSquared(summon.position, candidate.position)) <= 30
&& combatHasLineOfSight(summon.position, candidate.position))
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
const target = injured[0];
if (target) {
const healed = target.id === PLAYER_AURA_ENTITY_ID
? healPlayerInWork(work, summon.attackDamage)
: healCompanionActorInWork(work, target.id, summon.attackDamage);
work.totalHealing += healed;
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
recordCombatEventInWork(work, {
occurredAt: now,
kind: "healing",
sourceActorId: summon.id,
targetActorId: target.id,
abilityId: summon.sourceAbilityId,
rawAmount: summon.attackDamage,
effectiveAmount: healed,
overheal: summon.attackDamage - healed,
absorbed: 0,
critical: false,
});
}
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
}
const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
? summon.targetId
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
? state.selectedTargetId
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
if (!targetId) return readyToAct
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
: summon;
const targetPosition = getMobPosition(targetId);
let position = summon.position;
if (targetPosition) {
const dx = targetPosition[0] - position[0];
const dz = targetPosition[2] - position[2];
const length = Math.hypot(dx, dz);
if (summon.kind !== "totem" && length > 4) {
const step = Math.min(length - 3, 4 * delta);
const requested: CombatPosition = [position[0] + dx / length * step, position[1], position[2] + dz / length * step];
position = projectCombatGroundPoint(requested, step, position) ?? position;
}
if (
readyToAct
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
&& combatHasLineOfSight(position, targetPosition)
) {
const result = damageMobInWork(
state.classId,
work,
targetId,
summon.attackDamage,
now,
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
"physical",
);
if (summonAbility?.name === "Shadowfiend" && result.amount > 0) {
shadowfiendManaRestores.push({
ownerActorId: summon.ownerActorId, summonId: summon.id, abilityId: summon.sourceAbilityId,
});
}
}
}
return {
...summon,
targetId,
position,
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
};
});
for (const [id, mob] of Object.entries(work.mobs)) {
const statuses = mob.statuses.filter((status) => status.endsAt > now);
const auras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
const schoolLockedUntil = Object.fromEntries(
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
) as Partial<Record<EnemyDamageSchool, number>>;
const corpseExpired = mob.dead
&& !mob.despawned
&& mob.corpseDespawnAt !== null
&& now >= mob.corpseDespawnAt;
if (
statuses.length !== mob.statuses.length
|| auras.length !== mob.auras.length
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|| corpseExpired
) {
work.mobs[id] = {
...mob,
statuses,
auras,
schoolLockedUntil,
activeCast: mob.dead ? null : mob.activeCast,
despawned: mob.despawned || corpseExpired,
};
}
}
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
for (const [actorId, actor] of Object.entries(work.actors)) {
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) {
delete work.actors[actorId];
}
}
for (const summon of work.summons) {
work.actors[summon.id] = combatActorFromSummon(summon, progression.level, work.auras);
}
const levelled = progression.levelsGained > 0;
if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) {
autoAttackTargetId = null;
nextAutoAttackAt = 0;
}
return {
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) },
maxResourcePools: work.maxResourcePools,
lastResourceSpentAt: work.lastResourceSpentAt,
deathKnightRunes: work.deathKnightRunes,
talentModifiers: work.talentModifiers,
shield: work.shield,
shieldExpiresAt: work.shieldExpiresAt,
health: progression.levelsGained > 0 ? progression.health : work.health,
maxHealth: progression.maxHealth,
controlledUntil: state.controlledUntil > now ? state.controlledUntil : 0,
controlMechanic: state.controlledUntil > now ? state.controlMechanic : null,
level: progression.level,
xp: progression.xp,
xpToNext: progression.xpToNext,
mobs: work.mobs,
auras: work.auras,
effects: work.effects.length === state.effects.length
&& work.effects.every((effect, index) => effect === state.effects[index])
? state.effects
: work.effects,
summons: work.summons,
areaEffects: work.areaEffects,
combatEvents: work.combatEvents,
actors: work.actors,
cooldowns: work.cooldowns,
schoolLockedUntil: Object.fromEntries(
Object.entries(state.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
),
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId,
selectedFriendlyActorId: state.selectedFriendlyActorId && !work.actors[state.selectedFriendlyActorId]
? null
: state.selectedFriendlyActorId,
autoAttackTargetId,
nextAutoAttackAt,
playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent,
comboPoints: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? 0 : work.comboPoints,
comboTargetId: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? null : work.comboTargetId,
feedback: levelled
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
: work.combatResults.length > 0
&& work.combatResults.at(-1)?.outcome !== "hit"
? nextFeedback("damage", `Attack ${combatResultDescription(work.combatResults.at(-1)!)}.`, {
amount: work.combatResults.at(-1)!.finalDamage,
})
: state.feedback,
};
});
for (const restore of shadowfiendManaRestores) {
get().restoreActorResource(
restore.ownerActorId, "mana", 1, true, restore.summonId, restore.abilityId, now,
);
}
for (const tick of partySelfDrainTicks) {
const damaged = usePartyStore.getState().damageMemberResolved(tick.memberId, tick.amount);
if (damaged > 0) {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === tick.memberId);
set((state) => ({
actors: member && state.actors[tick.memberId] ? {
...state.actors,
[tick.memberId]: companionActorWithMemberRuntime(state.actors[tick.memberId], member),
} : state.actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, kind: "damage", sourceActorId: tick.memberId,
targetActorId: tick.memberId, abilityId: tick.sourceAbilityId,
rawAmount: damaged, effectiveAmount: damaged, overheal: 0, absorbed: 0, critical: false,
}),
}));
}
}
for (const tick of partyHotTicks) {
const healed = get().healActor(tick.memberId, tick.amount, {
sourceActorId: tick.sourceActorId,
abilityId: tick.sourceAbilityId,
amountAlreadyModified: true,
now,
}).effectiveAmount;
if (healed > 0) {
get().addHealingThreat(
threatSourceForActor(get(), tick.sourceActorId, tick.sourceAbilityId, true),
healed,
);
}
}
const cast = get().activeCast;
if (cast?.mode === "channel") {
const catalogAbility = abilityById(cast.abilityId);
const ability = catalogAbility
? learnedAbilityForBuild(catalogAbility, get().level, get().talentRanks)
: null;
let ticksCompleted = cast.ticksCompleted;
let nextTickAt = cast.nextTickAt;
while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now) {
const target = cast.targetId ? get().mobs[cast.targetId] : null;
const friendlyTarget = cast.targetId
? usePartyStore.getState().members.find((member) => member.id === cast.targetId)
: null;
const targetPosition = cast.targetId
? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId)
: get().playerPosition;
const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target));
const distance = cast.targetId
? channelHostile
? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin))
: Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin))
: 0;
const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0;
const maximumRange = ability ? talentAdjustedRange(ability.range.max, ability, get().talentModifiers) : 0;
const targetUnavailable = channelHostile
? !target || target.dead || !targetPosition
: (ability.target === "friendly" || ability.target === "any") && cast.targetId
? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition
: false;
if (targetUnavailable || distance < minimumRange || (cast.targetId !== null && distance > maximumRange)) {
get().cancelCast((target?.dead || friendlyTarget?.health === 0)
? "Channel ended: the target was defeated."
: "Channel interrupted: target unavailable.");
return;
}
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
const damageAmount = channelEffects.reduce(
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * multiplier
: 0),
0,
);
const healingAmount = channelEffects.reduce(
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * multiplier
: 0),
0,
);
if (damageAmount > 0) {
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
const channelTargets = ability.radius === undefined
? (cast.targetId && channelHostile ? [cast.targetId] : [])
: nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(ability.radius, ability, get().talentModifiers))
.filter((id) => !get().mobs[id]?.dead);
for (const channelTargetId of channelTargets) get().damageMob(
channelTargetId,
talentAdjustedAmount(
damageAmount,
get().talentModifiers,
"damage-percent",
ability,
),
nextTickAt,
playerThreatSource(get(), ability.id, true),
abilityDamageSchool(get().classId, ability),
{
attackKind: abilityAttackKind(get().classId, ability),
seed: `${PLAYER_AGGRO_ID}:${ability.id}:${channelTargetId}:${nextTickAt}:channel:${ticksCompleted}`,
abilityId: ability.id,
periodic: true,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
},
);
}
if (healingAmount > 0) {
const perTargetHealing = talentAdjustedAmount(
healingAmount,
get().talentModifiers,
"healing-percent",
ability,
);
let healed = 0;
if (!channelHostile && (ability.target === "friendly" || ability.target === "any") && cast.targetId) {
const targetMember = usePartyStore.getState().members.find((member) => member.id === cast.targetId);
if (targetMember) {
const current = get();
const actor = companionActorFromMember(
targetMember,
entityAbsorbAmount(current.auras, targetMember.id),
current.actors[targetMember.id],
);
const effective = actor.alive
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
: 0;
if (effective > 0) {
const nextActor = { ...actor, health: actor.health + effective };
set({ actors: { ...current.actors, [actor.id]: nextActor } });
mirrorCompanionActor(nextActor);
healed += effective;
}
}
} else if (ability.name === "Divine Hymn") {
const origin = cast.origin;
const candidates = [
{ id: PLAYER_AURA_ENTITY_ID, health: get().health, maxHealth: get().maxHealth },
...usePartyStore.getState().members.flatMap((member) => {
const position = getPartyRuntimePosition(member.id);
const current = get();
const actor = companionActorFromMember(member,
entityAbsorbAmount(current.auras, member.id), current.actors[member.id]);
return position && actor?.alive && actor.health > 0
&& Math.sqrt(distanceSquared(origin, position)) <= ability.radius!
&& combatHasLineOfSight(origin, position)
? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }]
: [];
}),
].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth
|| left.id.localeCompare(right.id))
.slice(0, 3);
for (const candidate of candidates) {
if (candidate.id === PLAYER_AURA_ENTITY_ID) healed += get().healPlayer(perTargetHealing);
else {
const current = get();
const member = usePartyStore.getState().members.find((entry) => entry.id === candidate.id);
const actor = member ? companionActorFromMember(member,
entityAbsorbAmount(current.auras, member.id), current.actors[member.id]) : null;
if (!actor?.alive) continue;
const effective = Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)));
if (effective > 0) {
const nextActor = { ...actor, health: actor.health + effective };
set({ actors: { ...current.actors, [actor.id]: nextActor } });
mirrorCompanionActor(nextActor);
healed += effective;
}
}
get().applyActorAura(PLAYER_AURA_ENTITY_ID, candidate.id, ability, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "wow335:priest:divine-hymn-healing", name: "Divine Hymn", disposition: "buff",
durationMs: 8_000, maxStacks: 1, stackBehavior: "refresh",
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03 }],
},
sourceEffectType: 6, sourceAuraType: 118, miscValue: 0, triggerSpellId: 0,
}, nextTickAt);
}
} else {
healed += get().healPlayer(perTargetHealing);
if (ability.radius !== undefined) {
for (const member of usePartyStore.getState().members) {
const current = get();
const actor = companionActorFromMember(
member,
entityAbsorbAmount(current.auras, member.id),
current.actors[member.id],
);
const effective = actor.alive
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
: 0;
if (effective <= 0) continue;
const nextActor = { ...actor, health: actor.health + effective };
set({ actors: { ...current.actors, [actor.id]: nextActor } });
mirrorCompanionActor(nextActor);
healed += effective;
}
}
}
if (healed > 0) get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
}
const drainedResource = ability.effects.reduce((total, effect) => (
effect.kind === "resource-drain" && !effect.burn ? total + effect.amount : total
), 0) / cast.totalTicks;
if (drainedResource > 0) {
const activeResource = resourceProfileForClass(get().classId).type;
const maximum = get().maxResourcePools[activeResource] ?? get().maxResource;
const amount = ability.effects.some((effect) => effect.kind === "resource-drain" && effect.percentage)
? maximum * drainedResource / 100
: drainedResource;
const pools = { ...get().resourcePools };
pools[activeResource] = clamp((pools[activeResource] ?? get().resource) + amount, 0, maximum);
set({ resource: pools[activeResource]!, resourcePools: pools });
}
emitPlayerAbilityPresentation(
ability,
"tick",
nextTickAt,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
ticksCompleted += 1;
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
}
if (get().activeCast?.id !== cast.id) return;
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
if (ability) get().castAbility(cast.abilityId, cast.origin, now, cast);
else set({ activeCast: null });
} else if (ticksCompleted !== cast.ticksCompleted) {
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
}
return;
}
if (cast && now >= cast.completesAt) {
set({ activeCast: null });
get().castAbility(cast.abilityId, cast.origin, now, cast);
}
},
resetEncounter: (options = {}) => set((state) => {
resetCombatPresentationSession();
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
const mobs = Object.fromEntries(Object.entries(state.mobs)
.filter(([, mob]) => !mob.summonedBy)
.map(([id, mob]) => {
const preserveDefeated = mob.dead && (
options.preserveDefeated === true || preserveDefeatedIds.has(id)
);
return [id, {
...mob,
health: preserveDefeated ? 0 : mob.maxHealth,
dead: preserveDefeated,
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
bossCardDrop: preserveDefeated ? mob.bossCardDrop : undefined,
looted: preserveDefeated ? mob.looted : false,
despawned: preserveDefeated ? mob.despawned : false,
diedAt: preserveDefeated ? mob.diedAt : null,
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}] as const;
}));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
return {
health: maxHealth,
maxHealth,
controlledUntil: 0,
controlMechanic: null,
shield: 0,
shieldExpiresAt: 0,
resource: initialResourceForMaximum(state.classId, state.maxResource),
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
type,
type === resourceProfileForClass(state.classId).type
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource)
: 0,
])),
lastResourceSpentAt: {},
comboPoints: 0,
comboTargetId: null,
deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [],
cooldowns: {},
globalCooldownEndsAt: 0,
activeCast: null,
schoolLockedUntil: {},
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
autoAttackTargetId: null,
nextAutoAttackAt: 0,
mobs,
selectedTargetId: null,
selectedFriendlyActorId: null,
effects: [],
auras,
summons: [],
areaEffects: [],
pendingGroundTarget: null,
combatEvents: [],
actors: {},
lastCombatResult: null,
feedback: nextFeedback(
"success",
options.preserveDefeated || preserveDefeatedIds.size > 0
? "The current pull has been reset; defeated enemies remain cleared."
: "The encounter has been reset.",
),
};
}),
allocateTalent: (treeId) => {
const state = get();
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
if (!result.ok) {
const message = result.reason === "no-points"
? "No talent points are available."
: result.reason === "ability-essence"
? "Not enough Ability Essence."
: result.reason === "talent-essence"
? "Not enough Talent Essence."
: result.reason === "point-gate"
? "Invest the required Ability and Talent Essence in this tab first."
: result.reason === "level-locked"
? "That node requires a higher level."
: result.reason === "max-rank"
? "That talent is already at maximum rank."
: result.reason === "tier-locked"
? "Spend five points per earlier tier in this tree first."
: result.reason === "prerequisite"
? "The prerequisite talent must be completed first."
: "That talent is unavailable.";
set({ feedback: nextFeedback("error", message) });
return false;
}
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
);
set({
talentRanks: result.ranks,
talentModifiers,
maxHealth,
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras,
feedback: nextFeedback("talent", "Conquest node rank allocated."),
});
return true;
},
resetTalents: () => set((state) => {
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
);
return {
talentRanks,
talentModifiers,
maxHealth,
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras,
actionBindings: withoutUnlearnedTalentBindings(state.actionBindings, state.abilities, talentRanks),
feedback: nextFeedback("talent", "Talent points reset."),
};
}),
setActionBinding: (layer, control, abilityId) => {
const state = get();
if (abilityId !== null && !state.abilities.some((ability) => (
ability.id === abilityId && !ability.passive
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, state.talentRanks)))
))) return false;
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
return true;
},
resetActionBindingLayer: (layer) => set((state) => ({
actionBindings: restoreActionBindingLayer(
state.actionBindings,
layer,
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
),
})),
resetActionBindings: () => set((state) => ({
actionBindings: defaultActionBindings(
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
),
})),
updateSettings: (patch) => set((state) => {
const settings = normalizedSettings(state.settings, patch);
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
}),
resetSettings: () => set((state) => (
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
? state
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
)),
}));
export function selectedMob(state: CombatState): MobCombatState | null {
return state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null;
}
export function cooldownRemainingMs(
state: Pick<CombatState, "cooldowns">,
abilityId: string,
now = combatNow(),
): number {
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
}
export function globalCooldownRemainingMs(
state: Pick<CombatState, "globalCooldownEndsAt">,
now = combatNow(),
): number {
return Math.max(0, state.globalCooldownEndsAt - now);
}
let collectingBossLoot = false;
useCombatStore.subscribe((state) => {
if (collectingBossLoot) return;
if (useGameStore.getState().gameMode === "manastorm") return;
const lootableBosses = Object.values(state.mobs).filter((mob) => (
mob.boss
&& mob.dead
&& mob.hasLoot
&& !mob.looted
&& !mob.despawned
));
if (!lootableBosses.length) return;
collectingBossLoot = true;
try {
for (const boss of lootableBosses) {
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
}
} finally {
collectingBossLoot = false;
}
});
export function combatPercent(current: number, maximum: number): number {
if (maximum <= 0) return 0;
return clamp((current / maximum) * 100, 0, 100);
}