Files
healer-man/src/game/combatStore.ts
T
2026-08-18 16:22:19 -04:00

5252 lines
191 KiB
TypeScript

import { create } from "zustand";
import { useGameStore } from "./store";
import {
baseClassFor,
type ClassId,
type RaceId,
} from "../app/characterCatalog";
import {
abilitiesForClass,
abilitiesForCharacter,
abilitiesUnlockedBetweenLevels,
abilityAtLevel,
abilityById,
abilityRankForLevel,
defaultActionBarForClass,
defaultActionBarForCharacter,
isAbilityUnlocked,
resourceProfileForClass,
type AbilityAmountScaling,
type AbilityDefinition,
type AbilityEffect,
type MobStatusKind,
type ResourceType,
} from "./abilityCatalog";
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,
mobRuntimeDistanceSquared,
nearbyMobRuntimeIds,
removeMobPosition,
setMobPosition,
type CombatPosition,
} from "./mobRuntimeRegistry";
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 ProcEvent,
type TriggeredAuraProc,
} from "./combatAuras";
import { rollBossLoot } from "./lootCatalog";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
equipmentStats,
activeEquipmentStatsForClass,
equipInventoryItem,
normalizeEquipment,
unequipInventorySlot,
type EquipmentAssignments,
type EquipmentSlot,
} from "./equipment";
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 {
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";
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;
}
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.8,
uiScale: 1,
minimapRotation: "player-up",
});
/** 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;
export interface MobStatus {
readonly kind: MobStatusKind;
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: number;
}
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 startedAt: number;
readonly completesAt: 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 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 origin: CombatPosition;
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;
readonly basePoints?: number;
readonly dieSides?: number;
readonly pointsPerLevel?: 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"
| "no-target"
| "target-dead"
| "target-unavailable"
| "too-close"
| "out-of-range";
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 CharacterCombatSeed {
readonly classId: ClassId;
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;
}
export interface CombatState {
classId: ClassId;
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>>>;
resourceName: string;
comboPoints: number;
abilities: readonly AbilityDefinition[];
cooldowns: Readonly<Record<string, number>>;
globalCooldownEndsAt: number;
activeCast: ActiveCast | null;
playerAnimationEvent: CharacterCombatAnimationEvent | null;
autoAttackTargetId: string | null;
nextAutoAttackAt: number;
mobs: Readonly<Record<string, MobCombatState>>;
selectedTargetId: string | null;
effects: readonly TimedCombatEffect[];
/** Named buffs/debuffs for the player, mobs, and party members. */
auras: readonly ActiveAura[];
summons: readonly CombatSummon[];
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;
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;
setPlayerPosition: (position: CombatPosition) => void;
castAbility: (abilityId: string, origin?: CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
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;
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: 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[];
health: number;
maxHealth: number;
shield: number;
shieldExpiresAt: number;
resource: number;
maxResource: number;
resourcePools: Partial<Record<ResourceType, number>>;
maxResourcePools: Partial<Record<ResourceType, number>>;
comboPoints: number;
progression: MutableProgression;
totalDamage: number;
totalHealing: number;
combatResults: CombatResult[];
talentRanks: TalentRanks;
talentModifiers: readonly TalentModifier[];
gearStats: ItemStats;
raceId: RaceId;
secondaryClassId: ClassId | null;
}
let feedbackSequence = 0;
let bossLootRewardSequence = 0;
let effectSequence = 0;
let castSequence = 0;
let animationSequence = 0;
let summonSequence = 0;
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 {
emitCombatPresentation({
occurredAt,
phase,
actorGroup: "player",
sourceActorId: PLAYER_AGGRO_ID,
...(targetId ? { targetActorId: targetId } : {}),
abilityId: ability.id,
sourceSpellId: ability.dbcSpellId,
name: ability.name,
source: presentationSourceForAbility(ability),
school: presentationSchoolForAbility(ability),
delivery: presentationDeliveryForAbility(ability),
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_RAGE_DECAY_PER_SECOND = 3;
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>> } {
if (!hasRomRagePool(classId, secondaryClassId) || amount <= 0) return { resource, resourcePools };
const activeType = resourceProfileForClass(classId).type;
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">, actorId: AggroActorId): CombatPosition | null {
return actorId === PLAYER_AGGRO_ID
? state.playerPosition
: getPartyRuntimePosition(actorId);
}
function eligibleAggroActorIds(
state: Pick<CombatState, "health" | "playerPosition">,
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);
}
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;
if (typeof scaling === "number" || scaling.basePoints === undefined) {
const coefficient = typeof scaling === "number" ? scaling : scaling.coefficient;
return Math.max(
0,
Math.round(coefficient * (playerCombatPower(level) + equipmentPower)),
);
}
const sourceLevel = Math.max(1, scaling.sourceLevel ?? level);
const scalingLevel = Math.min(level, scaling.maxScalingLevel || level);
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 ?? scaling.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 };
}
function abilityCosts(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>();
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const adjusted = Math.max(0, Math.round(applyTalentModifierValue(
cost.amount,
modifiers,
"resource-cost-percent",
abilityTalentContext(ability),
)));
const maximum = cost.resource === "health"
? state.maxHealth
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const rawAmount = cost.percentage ? Math.round(maximum * adjusted / 100) : adjusted;
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,
};
}
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;
}
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: [],
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 auraDefinitionForAbility(
definition: AuraDefinition,
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
return {
...definition,
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 === "damage" && actionTarget && work.mobs[actionTarget]) {
const mob = work.mobs[actionTarget];
const amount = Math.min(mob.health, Math.max(0, Math.round(action.amount)));
work.mobs[actionTarget] = {
...mob,
health: mob.health - amount,
dead: mob.health - amount <= 0,
};
work.totalDamage += amount;
} 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"
) {
executeTriggeredSpellInWork(
work,
action.data.spellId,
triggered.sourceId,
triggered.event.otherId ?? triggered.targetId,
now,
0,
);
}
}
}
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 "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 "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, Date.now());
}
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">,
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;
}
}
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)));
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 ? [] : 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 (
(effect.status === "fear" || effect.status === "root" || effect.status === "stun")
&& mob.mechanicImmunities.includes(effect.status)
) return mob;
const durationMs = mob.boss && effect.status === "stun"
? Math.min(3000, effect.durationMs)
: effect.durationMs;
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),
},
};
}
const status: MobStatus = {
kind: effect.status,
sourceAbilityId: abilityId,
endsAt: now + durationMs,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
};
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 }
: {}),
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,
): void {
if (depth > 1) return;
const catalogAbility = work.abilities.find((candidate) => (
candidate.dbcSpellId === spellId
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
));
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 = usePartyStore.getState().healMember(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);
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + effect.amount, 0, maximum);
if (resource === resourceProfileForClass(work.classId).type) {
work.resource = work.resourcePools[resource] ?? work.resource;
}
} else if (effect.kind === "trigger-spell" && depth < 1 && effect.spellId !== spellId) {
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1);
}
}
}
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.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 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,
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,
resourceName: resource.name,
comboPoints: 0,
abilities,
cooldowns: {},
globalCooldownEndsAt: 0,
activeCast: null,
playerAnimationEvent: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
mobs: {},
selectedTargetId: null,
effects: [],
auras: [],
summons: [],
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 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 = 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,
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
talentRanks,
talentModifiers,
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
inventory,
equipment,
gearStats,
settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}),
playerPosition: state.playerPosition,
}));
},
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: [],
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 = Date.now()) => {
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 = 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 = 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,
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,
feedback: state.selectedTargetId
? nextFeedback("target", "Target cleared.")
: state.feedback,
})),
setPlayerPosition: (position) => set((state) => {
const next: CombatPosition = [position[0], position[1], position[2]];
const cast = state.activeCast;
const movedWhileCasting = Boolean(cast && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
playerAnimationEvent: movedWhileCasting
? nextAnimationEvent("ability-cancel", cast!.abilityId)
: state.playerAnimationEvent,
feedback: movedWhileCasting
? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId })
: state.feedback,
};
}),
castAbility: (abilityId, origin, now = Date.now(), completingCast) => {
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 modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? abilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
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" || effect.kind === "utility"
))) 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 (state.activeCast && !completingCast) failure = "already-casting";
else if (!completingCast && state.globalCooldownEndsAt > now) failure = "global-cooldown";
else if ((state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource";
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
: ability?.target === "hostile"
? state.selectedTargetId
: ability?.target === "friendly"
? party.selectedMemberId
: null;
let targetPosition: CombatPosition | null = null;
if (!failure && ability?.target === "hostile") {
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?.target === "friendly" && targetId) {
const target = party.members.find((member) => member.id === targetId);
if (!target) failure = "target-unavailable";
else if (target.health <= 0 && !ability.effects.some((effect) => effect.kind === "resurrection")) {
failure = "target-dead";
}
else {
targetPosition = getPartyRuntimePosition(targetId);
if (!targetPosition) failure = "target-unavailable";
else {
const distance = Math.sqrt(partyRuntimeDistanceSquared(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";
}
}
}
const autoAttackTarget = targetId ? state.mobs[targetId] : null;
const startsAutoAttack = Boolean(
ability
&& ability.target === "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
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
: {}),
});
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
return emptyCastResult(reason, abilityId);
}
let affectedMobIds: string[] = [];
if (ability.radius !== undefined) {
const center = ability.target === "hostile" ? targetPosition! : playerOrigin;
affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(ability.radius, ability, modifiers)).filter((id) => {
const mob = state.mobs[id];
return Boolean(mob && !mob.dead);
});
} else if (ability.target === "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 spentPools = { ...state.resourcePools };
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);
}
const activeResourceType = resourceProfileForClass(state.classId).type;
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,
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
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,
cooldowns: channelCooldowns,
globalCooldownEndsAt: now + gcdMs,
selectedTargetId: ability.target === "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],
health: healthAfterCosts,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: activeResourceAfterCosts,
maxResource: state.maxResource,
resourcePools: spentPools,
maxResourcePools: { ...state.maxResourcePools },
comboPoints: state.comboPoints,
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);
const hasHostileEffect = 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) {
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" }>> = [];
for (const effect of ability.effects) {
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;
} else if (effect.kind === "heal") {
const friendlyTarget = ability.target === "friendly" && targetId
? usePartyStore.getState().members.find((member) => member.id === targetId)
: null;
const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? work.maxHealth;
const unmodifiedAmount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
effect,
work.progression.level,
work.gearStats,
state.classId,
ability,
)
+ Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)),
modifiers,
"healing-percent",
ability,
);
const healingTargetId = friendlyTarget && targetId ? targetId : PLAYER_AURA_ENTITY_ID;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(
unmodifiedAmount,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
"done",
),
aurasForEntity(work.auras, healingTargetId),
"taken",
));
const healed = friendlyTarget && targetId
? usePartyStore.getState().healMember(targetId, amount)
: Math.min(amount, work.maxHealth - work.health);
if (!friendlyTarget) {
work.health += healed;
work.progression.health = work.health;
}
work.totalHealing += healed;
addHealingThreatInWork(work, castThreatSource, healed);
if (healed > 0) {
evaluateProcsInWork(work, {
trigger: "heal",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: healingTargetId,
abilityId: ability.id,
amount: healed,
}, now);
}
} else if (effect.kind === "resurrection") {
const restored = targetId
? usePartyStore.getState().reviveMember(targetId, effect.percentMaxHealth)
: 0;
work.totalHealing += restored;
addHealingThreatInWork(work, castThreatSource, restored);
} else if (effect.kind === "summon") {
summonSequence += 1;
work.summons.push({
id: `player-summon-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.creatureId,
targetId,
expiresAt: now + effect.durationMs,
});
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,
);
work.shield = Math.max(work.shield, amount);
work.shieldExpiresAt = now + talentAdjustedDuration(effect.durationMs, ability, modifiers);
} else if (effect.kind === "resource") {
const resource = effect.resource ?? activeResourceType;
const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
const amount = resolveAuraResourceValue(
resolveAuraResourceValue(effect.amount, targetAuras, resource, "generation"),
targetAuras,
"power",
"generation",
);
const maximum = work.maxResourcePools[resource]
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
} else if (effect.kind === "combo") {
work.comboPoints = clamp(work.comboPoints + effect.amount, 0, 5);
} else if (effect.kind === "dot") {
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") {
const hotTargetId = ability.target === "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 candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
for (const id of candidates) {
const mob = work.mobs[id];
if (!mob || mob.dead) continue;
work.mobs[id] = {
...mob,
threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId),
targetActorId: castThreatSource.actorId,
forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + durationMs },
};
}
} 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 durationMs = effect.aura.durationMs === null
? null
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.recipient === "caster"
? [PLAYER_AURA_ENTITY_ID]
: ability.target === "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 (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 = ability.target === "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:"))
) usePartyStore.getState().clearMemberControl(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") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (!mob || mob.dead) continue;
const currentThreat = mob.threatByActor[castThreatSource.actorId] ?? 0;
const nextThreat = effect.mode === "redirect"
? 0
: effect.mode === "percent"
? currentThreat * Math.max(0, 1 + effect.amount / 100)
: Math.max(0, currentThreat + effect.amount);
work.mobs[id] = {
...mob,
threatByActor: { ...mob.threatByActor, [castThreatSource.actorId]: nextThreat },
};
}
} 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 (targetPosition) {
playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]];
}
} else if (effect.kind === "dispel-mechanic") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (mob) work.mobs[id] = { ...mob, statuses: mob.statuses.slice(effect.maxCount) };
}
} else if (effect.kind === "pet") {
if ((effect.action === "summon" || effect.action === "call" || effect.action === "tame") && effect.creatureId) {
summonSequence += 1;
work.summons.push({
id: `player-pet-${summonSequence}`,
sourceAbilityId: ability.id,
creatureId: effect.creatureId,
targetId,
expiresAt: Number.MAX_SAFE_INTEGER,
});
} else if (effect.action === "dismiss") {
work.summons = work.summons.filter((summon) => summon.sourceAbilityId !== ability.id);
}
} else if (effect.kind === "totem") {
work.summons = [];
} else if (effect.kind === "rune") {
const maximum = work.maxResourcePools["runic-power"] ?? 100;
work.resourcePools["runic-power"] = clamp((work.resourcePools["runic-power"] ?? 0) + effect.count * 10, 0, maximum);
if (activeResourceType === "runic-power") work.resource = work.resourcePools["runic-power"] ?? work.resource;
} 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,
);
}
}
}
evaluateProcsInWork(work, {
trigger: "cast",
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: targetId ?? undefined,
abilityId: ability.id,
}, now);
// 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> = { ...state.cooldowns };
if (cooldownMs > 0) cooldowns[ability.id] = now + cooldownMs;
if (ability.cooldownGroup) {
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 cooldowns[candidate.id] = Math.max(now, (cooldowns[candidate.id] ?? now) - (adjustment.amountMs ?? 0));
}
}
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}` : ""}.`;
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,
talentModifiers: work.talentModifiers,
comboPoints: work.comboPoints,
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,
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");
}
return get().castAbility(abilityId, origin, now);
},
damageMob: (id, amount, now = Date.now(), 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],
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
comboPoints: state.comboPoints,
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,
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,
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 = Date.now()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) return false;
set((state) => {
const mobs: Record<string, MobCombatState> = { ...state.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,
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, durationMs) }
: 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 true;
},
damagePlayer: (amount, school = "physical", attackerLevel) => {
const state = get();
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: Date.now(),
});
const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage);
const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed);
const health = state.health - healthDamage;
const rage = gainRomRage(
state.classId,
state.secondaryClassId,
healthDamage > 0 && hasRomWarriorRagePool(state.classId, state.secondaryClassId)
? ROM_RAGE_PER_HIT_TAKEN
: 0,
state.resource,
state.maxResource,
{ ...state.resourcePools },
{ ...state.maxResourcePools },
);
const procResult = evaluateAuraProcs(namedAbsorb.auras, {
trigger: "damage-taken",
subjectId: PLAYER_AURA_ENTITY_ID,
amount: healthDamage,
school,
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`));
set({
auras: procResult.auras,
shield: state.shield - absorbed,
health,
resource: rage.resource,
resourcePools: rage.resourcePools,
activeCast: health <= 0 ? null : 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", health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }),
});
return healthDamage;
},
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,
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
set({ auras: procResult.auras, health: state.health + healed, 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({
activeCast: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
});
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = Date.now(), 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 = Date.now(), 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: [],
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;
enemyActiveCast = {
attackId: attack.id,
name: attack.name,
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
school: attack.school,
interruptible: attack.interruptible !== false,
targetActorId: resolution.targetActorId,
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!;
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 targets = attack.target === "nearby-party"
? eligibleActors.filter((actorId) => {
const candidate = actorPosition(runtimeState, actorId);
if (!candidate) return false;
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 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;
})
.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 === "control") {
for (const actorId of targets) {
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}`,
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,
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,
);
}
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)
? 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],
health,
maxHealth: current.maxHealth,
shield: current.shield - absorbed,
shieldExpiresAt: current.shieldExpiresAt,
resource: rage.resource,
maxResource: current.maxResource,
resourcePools: { ...rage.resourcePools },
maxResourcePools: { ...current.maxResourcePools },
comboPoints: current.comboPoints,
progression: procProgression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: current.talentRanks,
talentModifiers: current.talentModifiers,
gearStats: current.gearStats,
raceId: current.raceId,
secondaryClassId: current.secondaryClassId,
};
if (procEvent) evaluateProcsInWork(procWork, procEvent, now);
set({
auras: procWork.auras,
mobs: procWork.mobs,
shield: procWork.shield,
health: procWork.health,
resource: procWork.resource,
resourcePools: procWork.resourcePools,
lastCombatResult: combatResult,
activeCast: procWork.health <= 0 ? null : 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 member = partyState.members.find((candidate) => candidate.id === hit.actorId);
if (!member || 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 damaged = partyState.damageMemberResolved(hit.actorId, namedAbsorb.remainingDamage);
appliedDamage += damaged;
set({
auras: namedAbsorb.auras,
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) {
usePartyStore.getState().controlMember(
control.memberId,
control.mechanic,
control.endsAt,
);
}
return appliedDamage;
},
tick: (deltaSeconds, now = Date.now()) => {
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 hasNativeRomRage = hasRomRagePool(state.classId, state.secondaryClassId);
const baseRegeneration = applyTalentModifierValue(
hasNativeRomRage && resourceProfile.type === "rage"
? 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",
);
let resource = clamp(
state.resource + regeneration * delta,
0,
state.maxResource,
);
const resourcePools: Partial<Record<ResourceType, number>> = { ...state.resourcePools };
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId);
const passiveRates: Partial<Record<ResourceType, number>> = {
mana: 4,
rage: hasNativeRomRage ? 0 : 5,
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] = clamp((resourcePools[type] ?? 0) + (passiveRates[type] ?? 0) * delta, 0, maximum);
}
resourcePools[resourceProfile.type] = resource;
if (hasNativeRomWarriorRage && !inCombat) {
const maximum = state.maxResourcePools.rage
?? (resourceProfile.type === "rage" ? state.maxResource : 100);
const rage = clamp(
(resourcePools.rage ?? (resourceProfile.type === "rage" ? resource : 0))
- ROM_RAGE_DECAY_PER_SECOND * delta,
0,
maximum,
);
resourcePools.rage = rage;
if (resourceProfile.type === "rage") resource = rage;
}
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),
health: Math.min(
state.maxHealth,
state.health + (state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01) * delta,
),
maxHealth: state.maxHealth,
shield,
shieldExpiresAt,
resource,
maxResource: state.maxResource,
resourcePools,
maxResourcePools: { ...state.maxResourcePools },
comboPoints: state.comboPoints,
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 canAutoAttack = (
!state.activeCast
&& state.controlledUntil <= now
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
);
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) {
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 = ability
? equipmentAdjustedCombatAmount(
effect,
progression.level,
state.gearStats,
state.classId,
ability,
)
: Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level)));
const amount = ability
? 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 = usePartyStore.getState().members.find((member) => member.id === effect.targetId);
if (target && target.health > 0) 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 },
);
}
}
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 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,
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,
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId,
autoAttackTargetId,
nextAutoAttackAt,
playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent,
comboPoints: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? 0 : state.comboPoints,
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 = usePartyStore.getState().healMember(tick.memberId, tick.amount);
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 targetPosition = cast.targetId ? getMobPosition(cast.targetId) : null;
const distance = cast.targetId ? Math.sqrt(mobRuntimeDistanceSquared(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;
if (!target || target.dead || !targetPosition || distance < minimumRange || distance > maximumRange) {
get().cancelCast(target?.dead ? "Channel ended: the target was defeated." : "Channel interrupted: target unavailable.");
return;
}
const damageAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "damage"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
)
: 0),
0,
) / cast.totalTicks;
const healingAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "heal"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
)
: 0),
0,
) / cast.totalTicks;
if (damageAmount > 0 && cast.targetId) {
get().damageMob(
cast.targetId,
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}:${cast.targetId}:${nextTickAt}:channel:${ticksCompleted}`,
abilityId: ability.id,
periodic: true,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
},
},
);
}
if (healingAmount > 0) {
const healed = get().healPlayer(talentAdjustedAmount(
healingAmount,
get().talentModifiers,
"healing-percent",
ability,
));
if (healed > 0) {
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
}
}
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) {
set({
activeCast: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("success", `${ability?.name ?? "Channel"} completed.`, {
abilityId: cast.abilityId,
targetId: cast.targetId ?? undefined,
}),
});
if (ability) {
emitPlayerAbilityPresentation(
ability,
"release",
now,
get().playerPosition,
cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
);
}
} 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: [],
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),
comboPoints: 0,
cooldowns: {},
globalCooldownEndsAt: 0,
activeCast: null,
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
autoAttackTargetId: null,
nextAutoAttackAt: 0,
mobs,
selectedTargetId: null,
effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
summons: [],
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, Date.now()),
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, Date.now()),
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) => ({ settings: normalizedSettings(state.settings, patch) })),
resetSettings: () => set({ 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 = Date.now(),
): number {
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
}
export function globalCooldownRemainingMs(
state: Pick<CombatState, "globalCooldownEndsAt">,
now = Date.now(),
): 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 ?? Date.now());
}
} finally {
collectingBossLoot = false;
}
});
export function combatPercent(current: number, maximum: number): number {
if (maximum <= 0) return 0;
return clamp((current / maximum) * 100, 0, 100);
}