Files
healer-man/src/game/combatStore.ts
T
2026-09-05 21:27:30 -04:00

8052 lines
312 KiB
TypeScript

import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useGameStore } from "./store";
import {
baseClassFor,
type ClassId,
type RaceId,
} from "../app/characterCatalog";
import {
abilitiesForClass,
abilitiesForCharacter,
abilitiesUnlockedBetweenLevels,
abilityAtLevel,
abilityById,
abilityRankForLevel,
defaultActionBarForClass,
defaultActionBarForCharacter,
isAbilityUnlocked,
resourceProfileForClass,
triggeredAbilityBySpellId,
type AbilityAmountScaling,
type AbilityDefinition,
type AbilityEffect,
type MobStatusKind,
type ResourceType,
} from "./abilityCatalog";
import {
abilityPushbackResistance,
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 { 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 { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
equipInventoryItem,
normalizeEquipment,
unequipInventorySlot,
type EquipmentAssignments,
type EquipmentSlot,
} from "./equipment";
import {
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import {
EMPTY_ITEM_STATS,
itemStatSummary,
type DamageSchool,
type ItemStats,
} from "./itemStats";
import {
damageAfterEquipmentMitigation,
deriveCharacterStats,
equipmentAttackPower,
hastedDuration,
ratingPercent,
type CharacterStats,
type WotlkAttackKind,
} from "./wotlkStats";
import {
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
resolveCombatResult,
type CombatAttackerStats,
type CombatDefenderStats,
type CombatResult,
type CombatResultRules,
} from "./combatResults";
import {
DEFAULT_ENEMY_ATTACK,
maximumEnemyAttackRange,
normalizeEnemyAttacks,
type EnemyAttackAnimation,
type EnemyAttackDefinition,
type EnemyControlMechanic,
type EnemyDamageSchool,
} from "./enemyAttacks";
import {
creatureRuntimeBaseStats,
creatureTemplateReference,
type AzerothCoreMechanic,
type AzerothCoreSchool,
} from "./azerothCoreReference";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
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 {
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;
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 endsAt: number;
readonly magnitude?: number;
readonly breakOnDamage?: boolean;
}
export interface MobAura {
readonly id: string;
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: 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 {
readonly id: string;
readonly name: string;
readonly serverEntry: number | null;
readonly summonedBy?: 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 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 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 {
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 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 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 equipment?: unknown;
readonly settings?: Partial<GameplaySettings>;
}
export interface MobRegistration {
readonly name: string;
readonly serverEntry?: number;
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;
}
interface PlayerDamageRollContext {
readonly attackKind: WotlkAttackKind;
readonly seed: string;
readonly abilityId?: string;
readonly periodic?: boolean;
readonly rules?: CombatResultRules;
}
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[];
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;
/** 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;
addHealingThreat: (source: ThreatSource, effectiveHealing: 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) => boolean;
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: 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;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => 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[];
}
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,
): 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 } : {}),
});
}
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,
): void {
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
emitPlayerAbilityPresentation(
ability,
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
occurredAt,
origin,
targetId,
targetPosition,
);
}
function emitEnemyAttackPresentation(
mob: MobCombatState,
attack: EnemyAttackDefinition,
phase: CombatPresentationPhase,
occurredAt: number,
origin: CombatPosition,
targetActorId: AggroActorId | null,
targetPosition: CombatPosition | null,
): 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 } : {}),
});
}
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 ROM_RAGE_PER_AUTO_ATTACK = 6;
const ROM_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);
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 {
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) return "spell";
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
return "spell";
}
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
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";
}
function equipmentAdjustedCombatAmount(
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
level: number,
gearStats: ItemStats,
classId: ClassId,
ability: AbilityDefinition,
): number {
const kind = 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,
);
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
}
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): CombatDefenderStats {
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
return {
level: mob.level,
armor: mob.armor,
dodgePercent: 5 + levelAdvantage * 0.5,
parryPercent: 5 + levelAdvantage * 0.5,
blockPercent: 0,
blockValue: 0,
resistances: {
arcane: mob.resistances.arcane,
fire: mob.resistances.fire,
frost: mob.resistances.frost,
nature: mob.resistances.nature,
shadow: mob.resistances.shadow,
},
};
}
function stableCombatRoll(seed: string) {
return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`);
}
function resolvePlayerDamageResult(
classId: ClassId,
work: MutableCastWork,
mob: MobCombatState,
amount: number,
attackKind: WotlkAttackKind,
school: DamageSchool,
seed: string,
ability?: AbilityDefinition,
periodic = false,
rules: CombatResultRules = {},
): CombatResult {
const baseStats = playerCharacterStats(
classId,
work.progression.level,
work.gearStats,
work.raceId,
work.secondaryClassId,
work.talentModifiers,
);
const playerAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
const stats = applyAuraStats(baseStats, playerAuras);
const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind);
const criticalChanceFromTalents = 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,
};
const immune = mob.schoolImmunities.includes(school);
return resolveCombatResult({
amount,
attackKind,
damageSchool: school,
attacker,
defender: npcCombatDefender(mob, stats.level),
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 resolveEnemyDamageResult(
mob: MobCombatState,
attack: EnemyAttackDefinition,
defender: CharacterStats,
amount: number,
actorId: AggroActorId,
attackRevision: number,
now: number,
): CombatResult {
const attackKind = enemyAttackKind(attack);
return resolveCombatResult({
amount,
attackKind,
damageSchool: attack.school,
attacker: {
level: mob.level,
criticalChancePercent: 5,
},
defender: combatDefenderFromCharacterStats(defender),
rules: {
isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
canDodge: attackKind === "melee",
canParry: attackKind === "melee",
canBlock: attackKind !== "spell",
},
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 node = ability.talentEntryId === undefined
? null
: talentNodeById(`coa-entry-${ability.talentEntryId}`);
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,
): { 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 : 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">>,
): 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.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"),
));
}
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") {
if (condition.encounterId !== "wailing-caverns:naralex") return false;
return condition.states.includes(useWailingEncounterStore.getState().phase);
}
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,
): 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,
summonedBy: summoner.id,
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 {
return {
...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
};
}
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",
);
return {
...base,
strength: primary(base.strength, "strength"),
agility: primary(base.agility, "agility", "dexterity"),
stamina: primary(base.stamina, "stamina"),
intellect: primary(base.intellect, "intellect"),
spirit: primary(base.spirit, "spirit", "wisdom"),
health: stat(base.health, "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, "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"),
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 syncPassiveAuras(
current: readonly ActiveAura[],
abilities: readonly AbilityDefinition[],
level: number,
talentRanks: TalentRanks,
now: number,
): ActiveAura[] {
const definitions = abilities.flatMap((catalogAbility) => {
if (!catalogAbility.passive || !isAbilityUnlocked(catalogAbility, level)) return [];
const learnedRank = catalogAbility.talentEntryId === undefined
? undefined
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
if (catalogAbility.talentEntryId !== undefined && !learnedRank) return [];
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
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;
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) {
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
} 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-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 result = evaluateAuraProcs(
work.auras,
event,
now,
({ aura, proc }) => stableCombatUnit(
`${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`,
),
);
work.auras = [...result.auras];
executeTriggeredAuraProcsInWork(work, result.triggered, now);
return result.triggered;
}
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[id];
return Boolean(mob && !mob.dead);
});
}
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);
progression.maxHealth = talentAdjustedMaxHealth(
classId,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
);
progression.health = progression.maxHealth;
work.health = progression.health;
work.maxHealth = progression.maxHealth;
work.maxResource = talentAdjustedMaxResource(
classId,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
);
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
}
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;
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) {
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) {
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) {
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) {
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) 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 adjustedCombatResult = combatResult
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
: undefined;
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.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,
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 canAward = killed && !mob.experienceGranted;
const beforeLevel = work.progression.level;
const pendingLoot = killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
bonusItemChance: mob.bonusLootChance,
})
: mob.pendingLoot;
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 : 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),
);
work.mobs[id] = {
...engagedMob,
health,
dead: killed,
hasLoot,
pendingLoot,
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 : 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 = {},
): DamageResult {
const mob = work.mobs[id];
if (!mob || mob.dead || amount <= 0) {
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
}
const sourceAuras = aurasForEntity(work.auras, source.actorId);
const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, {
direction: "dealt",
school,
attackKind,
});
const combatResult = resolvePlayerDamageResult(
classId,
work,
mob,
modifiedAmount,
attackKind,
school,
seed,
ability,
periodic,
rules,
);
const result = damageMobInWork(
classId,
work,
id,
amount,
now,
source,
school,
work.gearStats.spellPenetration,
combatResult,
);
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 (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,
): 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 && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
? previousDiminishing.applications
: 0;
const multiplier = mob.boss && 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 && diminishingStatuses.has(effect.status)
? {
...mob.controlDiminishingReturns,
[effect.status]: {
applications: Math.min(3, applications + 1),
resetAt: now + 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,
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) {
resolveAndDamageMobInWork(
work.classId,
work,
mobId,
amount,
now,
source,
attackKind,
school,
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
ability,
);
}
} else if (effect.kind === "heal") {
const rawAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
work.talentModifiers,
"healing-percent",
ability,
);
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
aurasForEntity(work.auras, recipient),
"taken",
));
if (recipient === PLAYER_AURA_ENTITY_ID) {
const healed = Math.min(amount, work.maxHealth - work.health);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
} 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;
work.auras = [...applyCombatAura(work.auras, {
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
}).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 recipient = targetId && !work.mobs[targetId] ? targetId : null;
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) 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 = effect.abilityName
? work.abilities.filter((candidate) => candidate.name.toLowerCase() === effect.abilityName!.toLowerCase())
: work.abilities;
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" && depth < 1 && effect.spellId !== spellId) {
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1, allowCoaHidden);
}
}
}
function timedEffect(
kind: "dot" | "hot",
abilityId: string,
sourceActorId: AggroActorId,
targetId: string | null,
scaling: AbilityAmountScaling & { readonly coefficient: number },
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.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
remainingTicks: ticks,
intervalMs,
nextTickAt: now + intervalMs,
...(tag ? { tag } : {}),
...(damageSchool ? { damageSchool } : {}),
...(spellPenetration ? { spellPenetration } : {}),
};
}
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 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,
)]),
);
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[],
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,
);
const initialPools = initialResourcePools(classId, abilities, maxResource);
set((state) => ({
...initialState(),
classId,
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: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
inventory,
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 = talentAdjustedMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
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 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 && registration.damageMultiplier !== undefined
? Math.max(1, Math.round(
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
))
: existing?.attackDamage ?? Math.max(1, Math.round(
defaults.attackDamage * damageMultiplier,
));
const mob: MobCombatState = existing
? {
...existing,
name: registration.name || existing.name,
serverEntry,
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: Math.min(existing.health, maxHealth),
xpReward,
hasLoot,
}
: {
id,
name: registration.name || "Unknown Creature",
serverEntry,
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,
acquiredAt: now,
} satisfies BossLootReward
: null;
return {
inventory: [...state.inventory, ...mob.pendingLoot],
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),
})),
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 = talentAdjustedMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
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 = talentAdjustedMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
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])),
}));
},
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 learnedTalentRank = catalogAbility?.talentEntryId === undefined
? undefined
: state.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
const ability = catalogAbility
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
: null;
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
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 = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null;
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 (ability.talentEntryId !== undefined && !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) 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 (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";
}
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 === "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];
}
const castTimeMs = talentAdjustedCastTime(
ability,
modifiers,
state.gearStats,
state.level,
state.classId,
);
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),
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
groundPosition,
totalTicks: ability.castMode === "channel" ? 5 : 0,
ticksCompleted: 0,
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / 5 : 0,
};
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) {
channelCooldowns[ability.id] = now + cooldownMs;
}
set({
activeCast,
...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: { ...state.actors },
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,
};
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 effectsToResolve = completingCast?.mode === "channel"
? ability.effects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
: ability.effects;
for (const effect of effectsToResolve) {
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,
),
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}:direct`,
ability,
);
}
} else if (effect.kind === "finisher-damage") {
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
coefficient,
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}: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 / 100);
let directHealing = 0;
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)
: resolution.effectiveAmount;
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 };
} else if (!friendlyTarget) {
work.health += healed;
work.progression.health = work.health;
}
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) {
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
work.health += beaconHealing;
work.progression.health = work.health;
work.totalHealing += beaconHealing;
} 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;
work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0, 5);
work.comboTargetId = comboTargetId;
} else if (effect.kind === "dot") {
if (targeting?.shape === "ground") 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,
effect.ticks,
effect.intervalMs,
now,
effect.tag,
damageSchool,
work.gearStats.spellPenetration,
));
}
} else if (effect.kind === "hot") {
if (targeting?.shape === "ground") continue;
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
work.effects = work.effects.filter((entry) => !(
entry.kind === "hot"
&& entry.sourceAbilityId === ability.id
&& entry.targetId === hotTargetId
));
work.effects.push(timedEffect(
"hot", ability.id, castThreatSource.actorId, hotTargetId, 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;
for (const id of candidates) {
const mob = work.mobs[id];
if (mob) work.mobs[id] = applyStatus(mob, {
...effect,
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const sourceDurationMs = 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
: 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) {
work.auras = [...applyCombatAura(work.auras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).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;
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);
}
}
} 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.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];
if (!mob || mob.dead) 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") {
const returned = effect.burn ? 0 : 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 (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.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)) {
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
work.health += healed;
work.progression.health = work.health;
work.totalHealing += healed;
}
} 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")
));
const restored = Math.min(
work.maxHealth - work.health,
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
);
work.health += restored;
work.progression.health = work.health;
work.totalHealing += restored;
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))
));
}
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 = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
work.health += healed;
work.progression.health = work.health;
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 = adjustment.abilityName
? state.abilities.filter((candidate) => candidate.name.toLowerCase() === adjustment.abilityName!.toLowerCase())
: state.abilities;
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 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,
);
return {
ok: true,
abilityId: ability.id,
affectedMobIds,
damage: work.totalDamage,
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,
)
: damageMobInWork(
state.classId,
work,
id,
amount,
now,
threatSource,
school,
state.gearStats.spellPenetration,
);
set({
mobs: work.mobs,
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;
},
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);
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 };
}),
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) 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;
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()) => {
let controlled = false;
set((state) => {
const mob = state.mobs[id];
if (!mob || mob.dead) return state;
const next = applyStatus(mob, {
kind: "status",
status,
durationMs: Math.max(1, durationMs),
}, sourceAbilityId, now);
controlled = next.statuses.some((entry) => (
entry.sourceAbilityId === sourceAbilityId
&& entry.kind === status
&& entry.endsAt > now
));
return controlled ? { mobs: { ...state.mobs, [id]: next } } : state;
});
return controlled;
},
dispelActor: (id, categories, maxCount = 1) => {
let removedCount = 0;
let clearedCompanionActor: CombatActorState | null = null;
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;
if (id === PLAYER_AURA_ENTITY_ID && result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
return { auras: [...untouched, ...result.auras], 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: [...untouched, ...result.auras],
actors: { ...state.actors, [id]: clearedCompanionActor },
};
}
}
return { auras: [...untouched, ...result.auras] };
});
if (clearedCompanionActor) mirrorCompanionActor(clearedCompanionActor);
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;
set({
health: restored,
controlledUntil: 0,
controlMechanic: null,
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;
const durationMs = effect.aura.durationMs;
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const result = applyCombatAura(state.auras, {
definition,
sourceId: sourceActorId,
targetId: targetActorId,
now,
});
set({
auras: result.auras,
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,
}),
});
return true;
},
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId]) return false;
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
const sourceActor = state.actors[sourceActorId];
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const amount = sourceMember
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
sourceMember.level * 4
+ sourceMember.gearStats.spellPower
+ sourceMember.gearStats.attackPower * 0.35
+ sourceMember.gearStats.rangedAttackPower * 0.35
)))
: 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,
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)
)
? ROM_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)),
));
set({
health: restored,
controlledUntil: 0,
controlMechanic: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
});
return restored;
},
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) 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;
},
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;
}> = [];
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 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;
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)
|| 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;
}
}
}
const inRange = mob.attacks.some((attack) => (
!enemyAbilityNeedsHostileRange(attack)
|| distanceSquared <= attack.range * attack.range
));
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 = targets[0] ?? resolution.targetActorId;
emitEnemyAttackPresentation(
mob,
attack,
"release",
now,
position,
presentationTargetId,
actorPosition(runtimeState, presentationTargetId),
);
if (effect.kind === "damage") {
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
});
}
} 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 }),
},
];
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",
},
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",
},
sourceId: id,
targetId: actorId,
now,
}).auras];
}
} else if (effect.kind === "control") {
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: `${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 === "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 summonId = `${id}:${attack.id}:summon:${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,
);
}
} else if (effect.kind === "encounter-event") {
if (effect.eventId === "wailing-caverns:naralex-start") {
useWailingEncounterStore.getState().start(now);
}
}
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,
);
}
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;
}
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,
);
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)
)
? ROM_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")
));
}
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 adjusted = resolveAuraDamageValue(
hit.amount,
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: hit.amount,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: applied > 0 ? "hit" : "absorb",
}),
});
appliedDamage += applied;
continue;
}
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) {
const current = get();
const adjusted = resolveAuraDamageValue(
hit.amount,
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: hit.amount,
effectiveAmount: damaged,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: damaged > 0 ? "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,
);
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 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 nextActor: CombatActorState = {
...authoritativeActor,
health: authoritativeActor.health - damaged,
alive: authoritativeActor.health - damaged > 0,
cast: authoritativeActor.health - damaged > 0 ? nextCast : null,
};
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: namedAbsorb.auras,
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 partyHotTicks: Array<{
memberId: string;
amount: number;
sourceActorId: AggroActorId;
sourceAbilityId: 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 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(state.auras, 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: [...expireAuras(state.auras, now).active],
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: 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,
};
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
&& 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,
ROM_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;
}
}
}
}
for (const effect of state.effects) {
let remainingTicks = effect.remainingTicks;
let nextTickAt = effect.nextTickAt;
while (remainingTicks > 0 && nextTickAt <= now) {
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.targetId) {
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
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,
},
);
} 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 = work.actors[effect.targetId]
?? ensureCompanionActorInWork(work, effect.targetId)
?? (work.summons.some((summon) => summon.id === effect.targetId)
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
: null);
if (target?.alive) partyHotTicks.push({
memberId: target.id,
amount: modifiedHealing,
sourceActorId: effect.sourceActorId,
sourceAbilityId: effect.sourceAbilityId,
});
} else {
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
work.health += healed;
work.progression.health = work.health;
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;
}
const targetAlive = effect.kind === "dot"
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
: !effect.targetId || usePartyStore.getState().members.some(
(member) => member.id === effect.targetId && member.health > 0,
);
if (remainingTicks > 0 && targetAlive) {
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);
const healed = member
? healCompanionActorInWork(work, friendlyId, amount)
: Math.min(amount, work.maxHealth - work.health);
if (!member) {
work.health += healed;
work.progression.health = work.health;
}
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.nextActionAt > now
|| summon.globalCooldownEndsAt > now
|| summon.cast
|| summon.attackDamage <= 0
) return summon;
const summonAbility = abilityById(summon.sourceAbilityId);
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
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
? Math.min(summon.attackDamage, work.maxHealth - work.health)
: healCompanionActorInWork(work, target.id, summon.attackDamage);
if (target.id === PLAYER_AURA_ENTITY_ID) {
work.health += healed;
work.progression.health = work.health;
}
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]?.dead
? summon.targetId
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
? state.selectedTargetId
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
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 (
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
&& combatHasLineOfSight(position, targetPosition)
) {
damageMobInWork(
state.classId,
work,
targetId,
summon.attackDamage,
now,
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
"physical",
);
}
}
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
});
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 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 learnedTalentRank = catalogAbility?.talentEntryId === undefined
? undefined
: get().talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
const ability = catalogAbility
? abilityAtLevel(catalogAbility, get().level, learnedTalentRank)
: 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 damageAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * (effect.kind === "dot" ? effect.ticks : 1)
: 0),
0,
) / cast.totalTicks;
const healingAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * (effect.kind === "hot" ? effect.ticks : 1)
: 0),
0,
) / cast.totalTicks;
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 {
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: () => set((state) => {
resetCombatPresentationSession();
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]) => [id, {
...mob,
health: mob.maxHealth,
dead: false,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
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,
}]));
return {
health: state.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: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
summons: [],
areaEffects: [],
pendingGroundTarget: null,
combatEvents: [],
actors: {},
lastCombatResult: null,
feedback: nextFeedback("success", "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 maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
);
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: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
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 maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
);
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: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
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
))) 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);
}