forked from phenom/healer-man
8052 lines
312 KiB
TypeScript
8052 lines
312 KiB
TypeScript
import { create } from "zustand";
|
|
import { combatNow } from "./combatClock";
|
|
import { useGameStore } from "./store";
|
|
import {
|
|
baseClassFor,
|
|
type ClassId,
|
|
type RaceId,
|
|
} from "../app/characterCatalog";
|
|
import {
|
|
abilitiesForClass,
|
|
abilitiesForCharacter,
|
|
abilitiesUnlockedBetweenLevels,
|
|
abilityAtLevel,
|
|
abilityById,
|
|
abilityRankForLevel,
|
|
defaultActionBarForClass,
|
|
defaultActionBarForCharacter,
|
|
isAbilityUnlocked,
|
|
resourceProfileForClass,
|
|
triggeredAbilityBySpellId,
|
|
type AbilityAmountScaling,
|
|
type AbilityDefinition,
|
|
type AbilityEffect,
|
|
type MobStatusKind,
|
|
type ResourceType,
|
|
} from "./abilityCatalog";
|
|
import {
|
|
abilityPushbackResistance,
|
|
canAbilityCastWhileMoving,
|
|
runeCostForAbility,
|
|
targetingRulesForAbility,
|
|
} from "./abilityRules";
|
|
import {
|
|
combatHasLineOfSight,
|
|
combatSpatialActor,
|
|
isPositionInFacingArc,
|
|
isSourceBehindTarget,
|
|
projectCombatGroundPoint,
|
|
} from "./combatSpatial";
|
|
import {
|
|
appendCombatEvent,
|
|
type CombatEventInput,
|
|
type StructuredCombatEvent,
|
|
} from "./combatEvents";
|
|
import {
|
|
createDeathKnightRunes,
|
|
normalizeActorResourcePool,
|
|
type CombatActorState,
|
|
type DeathKnightRuneState,
|
|
} from "./combatActors";
|
|
import {
|
|
companionActorFromMember,
|
|
companionActorWithMemberRuntime,
|
|
memberFromCompanionActor,
|
|
} from "./combatActorAuthority";
|
|
import {
|
|
activateDeathKnightRunes,
|
|
advanceCombatResourcePool,
|
|
spendDeathKnightRunes,
|
|
} from "./combatResources";
|
|
import { resolveHealing, type HealingResolution } from "./combatHealing";
|
|
import {
|
|
awardExperience,
|
|
healerManExperienceAward,
|
|
normalizeProgression,
|
|
playerCombatPower,
|
|
xpRequiredForNextLevel,
|
|
} from "./progression";
|
|
import {
|
|
runtimeAllocateTalentPoint as allocateTalentPoint,
|
|
runtimeNormalizeTalentRanks as normalizeTalentRanks,
|
|
runtimeResetTalentRanks as resetTalentRanks,
|
|
runtimeTalentNodeById as talentNodeById,
|
|
runtimeTalentPointsForLevel as talentPointsForLevel,
|
|
type RuntimeTalentRanks as TalentRanks,
|
|
} from "./talentRuntimeCatalog";
|
|
import {
|
|
applyTalentModifierValue,
|
|
parseTalentModifiers,
|
|
talentModifiersForRanks,
|
|
type TalentModifier,
|
|
type TalentModifierContext,
|
|
} from "./talentModifiers";
|
|
import {
|
|
getMobPosition,
|
|
getMobRuntimeTransform,
|
|
mobRuntimeDistanceSquared,
|
|
nearbyMobRuntimeIds,
|
|
removeMobPosition,
|
|
setMobPosition,
|
|
type CombatPosition,
|
|
} from "./mobRuntimeRegistry";
|
|
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
|
import {
|
|
getPartyRuntimePosition,
|
|
partyRuntimeDistanceSquared,
|
|
updatePartyRuntimePosition,
|
|
} from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import {
|
|
actionBindingId,
|
|
defaultActionBindings,
|
|
normalizeActionBindings,
|
|
resetActionBindingLayer as restoreActionBindingLayer,
|
|
withActionBinding,
|
|
type ActionBindingControl,
|
|
type ActionBindingLayer,
|
|
type ActionBindings,
|
|
} from "./actionBindings";
|
|
import type {
|
|
CharacterCombatAnimationEvent,
|
|
CharacterCombatAnimationKind,
|
|
} from "./combatAnimation";
|
|
import {
|
|
absorbDamage as absorbAuraDamage,
|
|
applyAura as applyCombatAura,
|
|
dispelAuras as dispelCombatAuras,
|
|
evaluateAuraProcs,
|
|
expireAuras,
|
|
resolveDamageValue as resolveAuraDamageValue,
|
|
resolveHealingValue as resolveAuraHealingValue,
|
|
resolveResourceValue as resolveAuraResourceValue,
|
|
resolveStatValue as resolveAuraStatValue,
|
|
type ActiveAura,
|
|
type AuraDefinition,
|
|
type DispelCategory,
|
|
type ProcEvent,
|
|
type TriggeredAuraProc,
|
|
} from "./combatAuras";
|
|
import { rollBossLoot } from "./lootCatalog";
|
|
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
|
import {
|
|
PLAYER_EQUIPMENT_OWNER_ID,
|
|
activeEquipmentStatsForClass,
|
|
equipInventoryItem,
|
|
normalizeEquipment,
|
|
unequipInventorySlot,
|
|
type EquipmentAssignments,
|
|
type EquipmentSlot,
|
|
} from "./equipment";
|
|
import {
|
|
resolvedEquipmentStats,
|
|
type HeirloomContext,
|
|
} from "./heirloomEquipment";
|
|
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
|
import { specializationById } from "./specializationCatalog";
|
|
import {
|
|
EMPTY_ITEM_STATS,
|
|
itemStatSummary,
|
|
type DamageSchool,
|
|
type ItemStats,
|
|
} from "./itemStats";
|
|
import {
|
|
damageAfterEquipmentMitigation,
|
|
deriveCharacterStats,
|
|
equipmentAttackPower,
|
|
hastedDuration,
|
|
ratingPercent,
|
|
type CharacterStats,
|
|
type WotlkAttackKind,
|
|
} from "./wotlkStats";
|
|
import {
|
|
combatAttackerFromCharacterStats,
|
|
combatDefenderFromCharacterStats,
|
|
resolveCombatResult,
|
|
type CombatAttackerStats,
|
|
type CombatDefenderStats,
|
|
type CombatResult,
|
|
type CombatResultRules,
|
|
} from "./combatResults";
|
|
import {
|
|
DEFAULT_ENEMY_ATTACK,
|
|
maximumEnemyAttackRange,
|
|
normalizeEnemyAttacks,
|
|
type EnemyAttackAnimation,
|
|
type EnemyAttackDefinition,
|
|
type EnemyControlMechanic,
|
|
type EnemyDamageSchool,
|
|
} from "./enemyAttacks";
|
|
import {
|
|
creatureRuntimeBaseStats,
|
|
creatureTemplateReference,
|
|
type AzerothCoreMechanic,
|
|
type AzerothCoreSchool,
|
|
} from "./azerothCoreReference";
|
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
|
import {
|
|
PLAYER_AGGRO_ID,
|
|
addActorThreat,
|
|
pruneThreatTable,
|
|
resolveAggroTarget,
|
|
seedActorThreat,
|
|
tauntThreat,
|
|
threatFromDamage,
|
|
threatFromHealing,
|
|
type AggroActorId,
|
|
type ForcedTarget,
|
|
type ThreatSource,
|
|
type ThreatTable,
|
|
} from "./aggro";
|
|
import { distanceSquared, planarDistanceSquared } from "./mobAi";
|
|
import {
|
|
onlinePlayerRuntimeActor,
|
|
onlinePlayerRuntimeActors,
|
|
} from "./onlineSessionRuntime";
|
|
import {
|
|
emitCombatPresentation,
|
|
presentationDeliveryForAbility,
|
|
presentationSchoolForAbility,
|
|
presentationSourceForAbility,
|
|
presentationSourceForSpellId,
|
|
resetCombatPresentationSession,
|
|
type CombatPresentationPhase,
|
|
} from "./combatPresentation";
|
|
|
|
/** Stable target id used by named player auras and HUD consumers. */
|
|
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
|
|
import {
|
|
DEFAULT_THREAT_METER_POSITION,
|
|
normalizeHudPosition,
|
|
type NormalizedHudPosition,
|
|
} from "./hudLayout";
|
|
import {
|
|
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
|
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
|
normalizeGameplayFrameRateLimit,
|
|
normalizePreviewFrameRateLimit,
|
|
type GameplayFrameRateLimit,
|
|
type PreviewFrameRateLimit,
|
|
} from "./frameRateSettings";
|
|
|
|
export type MinimapRotation = "north-up" | "player-up";
|
|
|
|
export interface GameplaySettings {
|
|
readonly showMobHealthBars: boolean;
|
|
readonly showMobAggroRanges: boolean;
|
|
readonly showTargetFrame: boolean;
|
|
readonly showThreatMeter: boolean;
|
|
readonly threatMeterPosition: NormalizedHudPosition;
|
|
readonly showFloatingCombatText: boolean;
|
|
readonly autoTargetNearest: boolean;
|
|
readonly enableScreenShake: boolean;
|
|
readonly masterVolume: number;
|
|
readonly uiScale: number;
|
|
readonly minimapRotation: MinimapRotation;
|
|
readonly gameplayFrameRate: GameplayFrameRateLimit;
|
|
readonly previewFrameRate: PreviewFrameRateLimit;
|
|
}
|
|
|
|
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
|
showMobHealthBars: true,
|
|
showMobAggroRanges: false,
|
|
showTargetFrame: true,
|
|
showThreatMeter: false,
|
|
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
|
|
showFloatingCombatText: true,
|
|
autoTargetNearest: true,
|
|
enableScreenShake: true,
|
|
masterVolume: 0,
|
|
uiScale: 1,
|
|
minimapRotation: "player-up",
|
|
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
|
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
|
});
|
|
|
|
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
|
export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
|
|
/** Empty corpses remain visible long enough to read the death, then clean themselves up. */
|
|
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
|
|
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
|
|
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
|
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
|
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
|
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
|
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
|
|
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
|
|
|
|
export interface MobStatus {
|
|
readonly kind: MobStatusKind;
|
|
readonly sourceAbilityId: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
readonly breakOnDamage?: boolean;
|
|
}
|
|
|
|
export interface MobAura {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
}
|
|
|
|
export interface MobMagicResistances {
|
|
readonly arcane: number;
|
|
readonly fire: number;
|
|
readonly frost: number;
|
|
readonly nature: number;
|
|
readonly shadow: number;
|
|
}
|
|
|
|
/** Public enemy cast state used by combat logic now and cast-bar UI later. */
|
|
export interface EnemyActiveCast {
|
|
readonly attackId: string;
|
|
readonly name: string;
|
|
readonly spellId?: number;
|
|
readonly school: EnemyDamageSchool;
|
|
readonly interruptible: boolean;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly sourcePosition: CombatPosition;
|
|
readonly targetPosition: CombatPosition;
|
|
readonly sourceYaw: number;
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
}
|
|
|
|
export interface DiminishingReturnState {
|
|
readonly applications: number;
|
|
readonly resetAt: number;
|
|
}
|
|
|
|
export interface MobCombatState {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly serverEntry: number | null;
|
|
readonly summonedBy?: string;
|
|
readonly boss: boolean;
|
|
readonly lootSourceId: string | null;
|
|
readonly level: number;
|
|
readonly armor: number;
|
|
readonly maxMana: number;
|
|
readonly attackPower: number;
|
|
readonly mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities: readonly AzerothCoreSchool[];
|
|
readonly resistances: MobMagicResistances;
|
|
readonly health: number;
|
|
readonly maxHealth: number;
|
|
readonly healthMultiplier: number;
|
|
readonly damageMultiplier: number;
|
|
readonly bonusLootChance: number;
|
|
readonly dead: boolean;
|
|
readonly hasLoot: boolean;
|
|
readonly pendingLoot: readonly InventoryItem[];
|
|
readonly looted: boolean;
|
|
readonly despawned: boolean;
|
|
readonly diedAt: number | null;
|
|
readonly corpseDespawnAt: number | null;
|
|
readonly xpReward: number;
|
|
readonly experienceGranted: boolean;
|
|
readonly statuses: readonly MobStatus[];
|
|
readonly controlDiminishingReturns?: Readonly<Partial<Record<MobStatusKind, DiminishingReturnState>>>;
|
|
readonly tauntDiminishingReturns?: DiminishingReturnState;
|
|
readonly auras: readonly MobAura[];
|
|
readonly threatByActor: ThreatTable;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly forcedTarget: ForcedTarget | null;
|
|
readonly lastDamageAt: number;
|
|
readonly engaged: boolean;
|
|
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
|
readonly homePosition: CombatPosition | null;
|
|
readonly nextAttackAt: number;
|
|
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
|
readonly abilityCastCounts: Readonly<Record<string, number>>;
|
|
readonly activeCast: EnemyActiveCast | null;
|
|
readonly schoolLockedUntil: Readonly<Partial<Record<EnemyDamageSchool, number>>>;
|
|
readonly attackRange: number;
|
|
readonly attackDamage: number;
|
|
readonly swingMs: number;
|
|
readonly aggroRange: number;
|
|
readonly leashRange: number;
|
|
readonly moveSpeed: number;
|
|
readonly attackRevision: number;
|
|
readonly attackAnimation: EnemyAttackAnimation;
|
|
readonly attacks: readonly EnemyAttackDefinition[];
|
|
readonly nextAttackIndex: number;
|
|
readonly lastAttackId: string | null;
|
|
readonly woundRevision: number;
|
|
readonly nextWoundAnimationAt: number;
|
|
readonly deathRevision: number;
|
|
}
|
|
|
|
export interface ActiveCast {
|
|
readonly id: number;
|
|
readonly abilityId: string;
|
|
readonly targetId: string | null;
|
|
readonly mode: "cast" | "channel";
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
readonly originalDurationMs: number;
|
|
readonly pushbackHits: number;
|
|
readonly school: DamageSchool;
|
|
readonly canCastWhileMoving: boolean;
|
|
readonly origin: CombatPosition;
|
|
readonly groundPosition: CombatPosition | null;
|
|
readonly totalTicks: number;
|
|
readonly ticksCompleted: number;
|
|
readonly nextTickAt: number;
|
|
}
|
|
|
|
export interface TimedCombatEffect {
|
|
readonly id: string;
|
|
readonly kind: "dot" | "hot";
|
|
readonly sourceAbilityId: string;
|
|
readonly sourceActorId: AggroActorId;
|
|
readonly targetId: string | null;
|
|
readonly coefficient: number;
|
|
/** Pre-scaled amount for non-player actors whose equipment is not the player loadout. */
|
|
readonly fixedAmount?: number;
|
|
readonly basePoints?: number;
|
|
readonly dieSides?: number;
|
|
readonly pointsPerLevel?: number;
|
|
readonly coefficientPerLevel?: number;
|
|
readonly bonusPowerCoefficient?: number;
|
|
readonly sourceLevel?: number;
|
|
readonly maxScalingLevel?: number;
|
|
readonly remainingTicks: number;
|
|
readonly intervalMs: number;
|
|
readonly nextTickAt: number;
|
|
readonly damageSchool?: DamageSchool;
|
|
readonly spellPenetration?: number;
|
|
readonly tag?: string;
|
|
}
|
|
|
|
export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot";
|
|
|
|
export interface CombatFeedback {
|
|
readonly id: number;
|
|
readonly kind: CombatFeedbackKind;
|
|
readonly message: string;
|
|
readonly abilityId?: string;
|
|
readonly targetId?: string;
|
|
readonly amount?: number;
|
|
}
|
|
|
|
export interface BossLootReward {
|
|
readonly id: number;
|
|
readonly bossId: string;
|
|
readonly bossName: string;
|
|
readonly items: readonly InventoryItem[];
|
|
readonly acquiredAt: number;
|
|
}
|
|
|
|
export type CastFailureReason =
|
|
| "unknown-ability"
|
|
| "wrong-class"
|
|
| "not-learned"
|
|
| "passive"
|
|
| "unsupported"
|
|
| "level-locked"
|
|
| "player-dead"
|
|
| "player-controlled"
|
|
| "already-casting"
|
|
| "global-cooldown"
|
|
| "cooldown"
|
|
| "not-enough-resource"
|
|
| "not-enough-runes"
|
|
| "condition-not-met"
|
|
| "no-target"
|
|
| "target-dead"
|
|
| "target-unavailable"
|
|
| "line-of-sight"
|
|
| "not-facing"
|
|
| "must-be-behind"
|
|
| "invalid-destination"
|
|
| "school-locked"
|
|
| "too-close"
|
|
| "out-of-range";
|
|
|
|
export interface CastRequest {
|
|
readonly origin?: CombatPosition;
|
|
readonly now?: number;
|
|
readonly targetActorId?: string | null;
|
|
readonly groundPosition?: CombatPosition;
|
|
readonly completingCast?: ActiveCast;
|
|
}
|
|
|
|
export interface CastResult {
|
|
readonly ok: boolean;
|
|
readonly abilityId?: string;
|
|
readonly reason?: CastFailureReason;
|
|
readonly affectedMobIds: readonly string[];
|
|
readonly damage: number;
|
|
readonly healing: number;
|
|
readonly levelsGained: number;
|
|
}
|
|
|
|
export interface ActorHealingRequest {
|
|
readonly sourceActorId: string;
|
|
readonly abilityId?: string;
|
|
readonly school?: DamageSchool;
|
|
readonly criticalChance?: number;
|
|
readonly criticalMultiplier?: number;
|
|
readonly seed?: string;
|
|
readonly now?: number;
|
|
readonly amountAlreadyModified?: boolean;
|
|
}
|
|
|
|
export interface CharacterCombatSeed {
|
|
readonly classId: ClassId;
|
|
readonly specializationId?: string | null;
|
|
readonly role?: PartyRole;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly raceId?: RaceId;
|
|
readonly level?: number;
|
|
readonly xp?: number;
|
|
/** Backward-compatible profile field accepted by the integration bridge. */
|
|
readonly experience?: number;
|
|
readonly talentRanks?: TalentRanks;
|
|
readonly actionBindings?: unknown;
|
|
readonly inventory?: unknown;
|
|
readonly equipment?: unknown;
|
|
readonly settings?: Partial<GameplaySettings>;
|
|
}
|
|
|
|
export interface MobRegistration {
|
|
readonly name: string;
|
|
readonly serverEntry?: number;
|
|
readonly boss?: boolean;
|
|
readonly level?: number;
|
|
readonly resistances?: Partial<MobMagicResistances>;
|
|
readonly maxHealth?: number;
|
|
readonly xpReward?: number;
|
|
readonly healthMultiplier?: number;
|
|
readonly damageMultiplier?: number;
|
|
readonly bonusLootChance?: number;
|
|
readonly moveSpeed?: number;
|
|
readonly aggroRange?: number;
|
|
readonly leashRange?: number;
|
|
readonly attacks?: readonly EnemyAttackDefinition[];
|
|
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities?: readonly AzerothCoreSchool[];
|
|
readonly hasLoot?: boolean;
|
|
/** Stable SQL boss key, independent from this spawned mob's runtime id. */
|
|
readonly lootSourceId?: string;
|
|
}
|
|
|
|
export type TargetCycleDirection = "next" | "previous" | 1 | -1;
|
|
|
|
export interface DamageResult {
|
|
readonly amount: number;
|
|
readonly killed: boolean;
|
|
readonly xpAwarded: number;
|
|
readonly levelsGained: number;
|
|
readonly combatResult?: CombatResult;
|
|
}
|
|
|
|
interface PlayerDamageRollContext {
|
|
readonly attackKind: WotlkAttackKind;
|
|
readonly seed: string;
|
|
readonly abilityId?: string;
|
|
readonly periodic?: boolean;
|
|
readonly rules?: CombatResultRules;
|
|
}
|
|
|
|
export interface CombatSummon {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly creatureId: number;
|
|
readonly targetId: string | null;
|
|
readonly expiresAt: number;
|
|
readonly kind: "pet" | "totem" | "summon";
|
|
readonly name: string;
|
|
readonly ownerActorId: AggroActorId;
|
|
readonly health: number;
|
|
readonly maxHealth: number;
|
|
readonly position: CombatPosition;
|
|
readonly nextActionAt: number;
|
|
readonly pulseIntervalMs: number;
|
|
readonly attackDamage: number;
|
|
readonly abilityIds: readonly string[];
|
|
readonly resources: CombatActorState["resources"];
|
|
readonly cooldowns: CombatActorState["cooldowns"];
|
|
readonly globalCooldownEndsAt: number;
|
|
readonly cast: CombatActorState["cast"];
|
|
readonly controlledUntil: number;
|
|
readonly schoolLockedUntil: CombatActorState["schoolLockedUntil"];
|
|
readonly exclusiveGroup?: string;
|
|
}
|
|
|
|
export interface CombatAreaEffect {
|
|
readonly id: string;
|
|
readonly sourceActorId: AggroActorId;
|
|
readonly sourceAbilityId: string;
|
|
readonly position: CombatPosition;
|
|
readonly radius: number;
|
|
readonly createdAt: number;
|
|
readonly expiresAt: number;
|
|
readonly tickIntervalMs: number;
|
|
readonly nextTickAt: number;
|
|
readonly fixedDamagePerTick?: number;
|
|
readonly fixedHealingPerTick?: number;
|
|
}
|
|
|
|
export interface PendingGroundTarget {
|
|
readonly abilityId: string;
|
|
readonly maximumRange: number;
|
|
readonly radius: number;
|
|
readonly position: CombatPosition;
|
|
readonly valid: boolean;
|
|
}
|
|
|
|
export interface CombatState {
|
|
classId: ClassId;
|
|
specializationId: string | null;
|
|
gearRole: PartyRole;
|
|
secondaryClassId: ClassId | null;
|
|
raceId: RaceId;
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
controlledUntil: number;
|
|
controlMechanic: EnemyControlMechanic | null;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
maxResourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
|
resourceName: string;
|
|
comboPoints: number;
|
|
comboTargetId: string | null;
|
|
deathKnightRunes: readonly DeathKnightRuneState[];
|
|
abilities: readonly AbilityDefinition[];
|
|
cooldowns: Readonly<Record<string, number>>;
|
|
globalCooldownEndsAt: number;
|
|
activeCast: ActiveCast | null;
|
|
schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
|
playerAnimationEvent: CharacterCombatAnimationEvent | null;
|
|
autoAttackTargetId: string | null;
|
|
nextAutoAttackAt: number;
|
|
mobs: Readonly<Record<string, MobCombatState>>;
|
|
selectedTargetId: string | null;
|
|
selectedFriendlyActorId: string | null;
|
|
effects: readonly TimedCombatEffect[];
|
|
/** Named buffs/debuffs for the player, mobs, and party members. */
|
|
auras: readonly ActiveAura[];
|
|
summons: readonly CombatSummon[];
|
|
areaEffects: readonly CombatAreaEffect[];
|
|
pendingGroundTarget: PendingGroundTarget | null;
|
|
actors: Readonly<Record<string, CombatActorState>>;
|
|
combatEvents: readonly StructuredCombatEvent[];
|
|
feedback: CombatFeedback | null;
|
|
/** Most recent discrete attack result, retained for proc and combat-log consumers. */
|
|
lastCombatResult: CombatResult | null;
|
|
bossLootRewards: readonly BossLootReward[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
actionBindings: ActionBindings;
|
|
inventory: readonly InventoryItem[];
|
|
equipment: EquipmentAssignments;
|
|
gearStats: ItemStats;
|
|
settings: GameplaySettings;
|
|
playerPosition: CombatPosition;
|
|
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
|
refreshResolvedGear: () => void;
|
|
registerMob: (id: string, registration: MobRegistration) => void;
|
|
removeMob: (id: string) => void;
|
|
lootMob: (id: string, now?: number) => boolean;
|
|
dismissBossLootReward: (id?: number) => void;
|
|
equipItem: (instanceId: string, ownerId?: string) => boolean;
|
|
unequipItem: (ownerId: string, slot: EquipmentSlot) => boolean;
|
|
selectMob: (id: string) => boolean;
|
|
selectNearestTarget: (origin?: CombatPosition, maxRange?: number) => string | null;
|
|
cycleTarget: (direction?: TargetCycleDirection, origin?: CombatPosition, maxRange?: number) => string | null;
|
|
clearTarget: () => void;
|
|
selectFriendlyActor: (id: string | null) => boolean;
|
|
setPlayerPosition: (position: CombatPosition) => void;
|
|
castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
|
|
beginGroundTarget: (abilityId: string) => boolean;
|
|
moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void;
|
|
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
|
cancelGroundTarget: () => void;
|
|
synchronizeActors: () => void;
|
|
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
|
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
|
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
|
castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, origin?: CombatPosition, now?: number) => CastResult;
|
|
damageMob: (
|
|
id: string,
|
|
amount: number,
|
|
now?: number,
|
|
source?: ThreatSource,
|
|
school?: DamageSchool,
|
|
rollContext?: PlayerDamageRollContext,
|
|
) => DamageResult;
|
|
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
|
|
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
|
interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean;
|
|
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number) => boolean;
|
|
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number) => number;
|
|
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
|
|
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
|
|
restoreActorResource: (id: string, resource: ResourceType, amount: number, percentage?: boolean, sourceActorId?: string, abilityId?: string, now?: number) => number;
|
|
shieldActor: (id: string, amount: number, durationMs: number, sourceActorId: string, ability: AbilityDefinition, now?: number) => number;
|
|
applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "apply-aura" }>, now?: number) => boolean;
|
|
applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>, now?: number) => boolean;
|
|
summonActor: (ownerActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "summon" }>, position: CombatPosition, targetId?: string | null, now?: number) => string | null;
|
|
createActorAreaEffect: (sourceActorId: string, ability: AbilityDefinition, position: CombatPosition, now?: number) => string | null;
|
|
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
|
damageSummon: (id: string, amount: number) => number;
|
|
healPlayer: (amount: number) => number;
|
|
revivePlayer: (percentMaxHealth?: number) => number;
|
|
cancelCast: (message?: string) => boolean;
|
|
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
|
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
|
tick: (deltaSeconds: number, now?: number) => void;
|
|
resetEncounter: () => void;
|
|
allocateTalent: (treeId: string) => boolean;
|
|
resetTalents: () => void;
|
|
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
|
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
|
|
resetActionBindings: () => void;
|
|
updateSettings: (patch: Partial<GameplaySettings>) => void;
|
|
resetSettings: () => void;
|
|
}
|
|
|
|
interface MutableProgression {
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
levelsGained: number;
|
|
}
|
|
|
|
interface MutableCastWork {
|
|
classId: ClassId;
|
|
abilities: readonly AbilityDefinition[];
|
|
mobs: Record<string, MobCombatState>;
|
|
effects: TimedCombatEffect[];
|
|
auras: ActiveAura[];
|
|
summons: CombatSummon[];
|
|
areaEffects: CombatAreaEffect[];
|
|
combatEvents: StructuredCombatEvent[];
|
|
actors: Record<string, CombatActorState>;
|
|
cooldowns: Record<string, number>;
|
|
health: number;
|
|
maxHealth: number;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Partial<Record<ResourceType, number>>;
|
|
maxResourcePools: Partial<Record<ResourceType, number>>;
|
|
lastResourceSpentAt: Partial<Record<ResourceType, number>>;
|
|
comboPoints: number;
|
|
comboTargetId: string | null;
|
|
deathKnightRunes: readonly DeathKnightRuneState[];
|
|
progression: MutableProgression;
|
|
totalDamage: number;
|
|
totalHealing: number;
|
|
combatResults: CombatResult[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
gearStats: ItemStats;
|
|
raceId: RaceId;
|
|
secondaryClassId: ClassId | null;
|
|
/** Active synchronous spell/source/target keys used to break proc cycles. */
|
|
triggeredSpellStack?: string[];
|
|
}
|
|
|
|
let feedbackSequence = 0;
|
|
let bossLootRewardSequence = 0;
|
|
let effectSequence = 0;
|
|
let castSequence = 0;
|
|
let animationSequence = 0;
|
|
let summonSequence = 0;
|
|
let areaEffectSequence = 0;
|
|
let combatEventSequence = 0;
|
|
|
|
function initialSummonCombatRuntime(): Pick<CombatSummon,
|
|
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
|
> {
|
|
return {
|
|
abilityIds: [],
|
|
resources: {},
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
cast: null,
|
|
controlledUntil: 0,
|
|
schoolLockedUntil: {},
|
|
};
|
|
}
|
|
|
|
function combatActorFromSummon(
|
|
summon: CombatSummon,
|
|
level: number,
|
|
auras: readonly ActiveAura[],
|
|
): CombatActorState {
|
|
return {
|
|
id: summon.id,
|
|
threatActorId: summon.id,
|
|
kind: summon.kind,
|
|
ownerId: summon.ownerActorId,
|
|
level,
|
|
health: summon.health,
|
|
maxHealth: summon.maxHealth,
|
|
absorb: entityAbsorbAmount(auras, summon.id),
|
|
resources: summon.resources,
|
|
cooldowns: summon.cooldowns,
|
|
globalCooldownEndsAt: summon.globalCooldownEndsAt,
|
|
cast: summon.cast,
|
|
controlledUntil: summon.controlledUntil,
|
|
schoolLockedUntil: summon.schoolLockedUntil,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilityIds: summon.abilityIds,
|
|
alive: summon.health > 0,
|
|
};
|
|
}
|
|
|
|
function recordCombatEvent(
|
|
events: readonly StructuredCombatEvent[],
|
|
input: CombatEventInput,
|
|
): readonly StructuredCombatEvent[] {
|
|
combatEventSequence += 1;
|
|
return appendCombatEvent(events, input, combatEventSequence);
|
|
}
|
|
|
|
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
|
|
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
|
|
}
|
|
|
|
function nextFeedback(
|
|
kind: CombatFeedbackKind,
|
|
message: string,
|
|
details: Omit<CombatFeedback, "id" | "kind" | "message"> = {},
|
|
): CombatFeedback {
|
|
feedbackSequence += 1;
|
|
return { id: feedbackSequence, kind, message, ...details };
|
|
}
|
|
|
|
function nextAnimationEvent(
|
|
kind: CharacterCombatAnimationKind,
|
|
abilityId?: string,
|
|
): CharacterCombatAnimationEvent {
|
|
animationSequence += 1;
|
|
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
|
|
}
|
|
|
|
function emitPlayerAbilityPresentation(
|
|
ability: AbilityDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
const delivery = presentationDeliveryForAbility(ability);
|
|
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery,
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
|
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
|
});
|
|
}
|
|
|
|
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
|
const ability = abilityById(abilityId);
|
|
if (!ability) return;
|
|
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
|
}
|
|
|
|
function emitPlayerAbilityResolutionPresentation(
|
|
ability: AbilityDefinition,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
|
occurredAt,
|
|
origin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
}
|
|
|
|
function emitEnemyAttackPresentation(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetActorId: AggroActorId | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "enemy",
|
|
sourceActorId: mob.id,
|
|
...(targetActorId ? { targetActorId } : {}),
|
|
abilityId: attack.id,
|
|
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
|
|
name: attack.name,
|
|
source: presentationSourceForSpellId(attack.spellId),
|
|
school: attack.school,
|
|
delivery: attack.delivery,
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(attack.radius === undefined ? {} : { radius: attack.radius }),
|
|
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
|
|
});
|
|
}
|
|
|
|
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
|
|
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
const ROM_RAGE_CLASSES = new Set<ClassId>(["rom-warrior", "rom-champion"]);
|
|
const ROM_RAGE_PER_AUTO_ATTACK = 6;
|
|
const ROM_RAGE_PER_HIT_TAKEN = 7;
|
|
const ROM_CHAMPION_RAGE_PER_SECOND = 6;
|
|
const PLAYER_AUTO_ATTACK_RANGE = 5;
|
|
|
|
function hasRomClassPool(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
resourceClassId: ClassId,
|
|
): boolean {
|
|
return classId === resourceClassId || secondaryClassId === resourceClassId;
|
|
}
|
|
|
|
function hasRomRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return ROM_RAGE_CLASSES.has(classId)
|
|
|| Boolean(secondaryClassId && ROM_RAGE_CLASSES.has(secondaryClassId));
|
|
}
|
|
|
|
function hasRomWarriorRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-warrior");
|
|
}
|
|
|
|
function hasRomChampionRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-champion");
|
|
}
|
|
|
|
function gainRomRage(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
amount: number,
|
|
resource: number,
|
|
maxResource: number,
|
|
resourcePools: Partial<Record<ResourceType, number>>,
|
|
maxResourcePools: Partial<Record<ResourceType, number>>,
|
|
): { resource: number; resourcePools: Partial<Record<ResourceType, number>> } {
|
|
const activeType = resourceProfileForClass(classId).type;
|
|
const hasRage = hasRomRagePool(classId, secondaryClassId)
|
|
|| activeType === "rage"
|
|
|| maxResourcePools.rage !== undefined;
|
|
if (!hasRage || amount <= 0) return { resource, resourcePools };
|
|
const maximum = maxResourcePools.rage ?? (activeType === "rage" ? maxResource : 100);
|
|
const rage = clamp((resourcePools.rage ?? (activeType === "rage" ? resource : 0)) + amount, 0, maximum);
|
|
return {
|
|
resource: activeType === "rage" ? rage : resource,
|
|
resourcePools: { ...resourcePools, rage },
|
|
};
|
|
}
|
|
|
|
function playerAutoAttackProfile(
|
|
state: Pick<CombatState, "classId" | "secondaryClassId" | "raceId" | "level" | "gearStats">,
|
|
): { damage: number; intervalMs: number; hasteMultiplier: number } {
|
|
const stats = deriveCharacterStats(
|
|
state.classId,
|
|
state.level,
|
|
state.gearStats,
|
|
state.raceId,
|
|
{},
|
|
state.secondaryClassId,
|
|
);
|
|
const weapon = state.gearStats.meleeWeapon;
|
|
const speedMs = weapon?.speedMs ?? 2_400;
|
|
const speedSeconds = speedMs / 1_000;
|
|
const weaponDamage = weapon
|
|
? (weapon.damageMin + weapon.damageMax) / 2
|
|
: playerCombatPower(state.level) * 0.35;
|
|
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
|
|
return {
|
|
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
|
|
intervalMs,
|
|
hasteMultiplier: speedMs / intervalMs,
|
|
};
|
|
}
|
|
|
|
function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext {
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase();
|
|
const tags = new Set<string>();
|
|
tags.add(ability.castTimeMs > 0 ? "cast-time" : "instant");
|
|
if (periodic) tags.add("periodic");
|
|
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot")) tags.add("damage");
|
|
if (ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield")) tags.add("healing");
|
|
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
|
|
if (searchable.includes(school)) tags.add(school);
|
|
}
|
|
return { abilityName: ability.name, tags: [...tags] };
|
|
}
|
|
|
|
function playerThreatSource(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
const ability = abilityId ? abilityById(abilityId) : null;
|
|
const threatScale = ability
|
|
? applyTalentModifierValue(
|
|
1,
|
|
state.talentModifiers,
|
|
"threat-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: 1;
|
|
return {
|
|
actorId: PLAYER_AGGRO_ID,
|
|
role: usePartyStore.getState().playerRole,
|
|
...(abilityId ? { abilityId } : {}),
|
|
threatScale,
|
|
};
|
|
}
|
|
|
|
function threatSourceForActor(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
actorId: AggroActorId,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
if (actorId === PLAYER_AGGRO_ID) return playerThreatSource(state, abilityId, periodic);
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
|
return {
|
|
actorId,
|
|
role: member?.role ?? "damage",
|
|
...(abilityId ? { abilityId } : {}),
|
|
};
|
|
}
|
|
|
|
function actorPosition(state: Pick<CombatState, "playerPosition" | "summons">, actorId: AggroActorId): CombatPosition | null {
|
|
if (actorId === PLAYER_AGGRO_ID) return state.playerPosition;
|
|
return getPartyRuntimePosition(actorId)
|
|
?? state.summons.find((summon) => summon.id === actorId && summon.health > 0)?.position
|
|
?? null;
|
|
}
|
|
|
|
function eligibleAggroActorIds(
|
|
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
|
|
mob: Pick<MobCombatState, "homePosition" | "leashRange">,
|
|
): Set<AggroActorId> {
|
|
const eligible = new Set<AggroActorId>();
|
|
const home = mob.homePosition;
|
|
const withinLeash = (position: CombatPosition | null) => {
|
|
if (!position) return false;
|
|
if (!home) return true;
|
|
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
|
|
};
|
|
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health > 0 && withinLeash(getPartyRuntimePosition(member.id))) eligible.add(member.id);
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) {
|
|
if (actor.health > 0 && withinLeash(getPartyRuntimePosition(actor.id))) eligible.add(actor.id);
|
|
}
|
|
for (const summon of state.summons) {
|
|
if (summon.health > 0 && withinLeash(summon.position)) eligible.add(summon.id);
|
|
}
|
|
return eligible;
|
|
}
|
|
|
|
function addHealingThreatInWork(
|
|
work: MutableCastWork,
|
|
source: ThreatSource,
|
|
effectiveHealing: number,
|
|
): void {
|
|
const engagedIds = Object.values(work.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
|
if (perMob <= 0) return;
|
|
for (const id of engagedIds) {
|
|
const mob = work.mobs[id];
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
}
|
|
|
|
function talentAdjustedAmount(
|
|
baseValue: number,
|
|
modifiers: readonly TalentModifier[],
|
|
kind: "damage-percent" | "healing-percent",
|
|
ability: AbilityDefinition,
|
|
periodic = false,
|
|
): number {
|
|
const context = abilityTalentContext(ability, periodic);
|
|
const scalar = applyTalentModifierValue(
|
|
baseValue,
|
|
modifiers,
|
|
kind,
|
|
context,
|
|
);
|
|
return Math.max(0, Math.round(scalar));
|
|
}
|
|
|
|
function talentAdjustedMaxHealth(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const staminaModifiers = playerModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "stamina"
|
|
));
|
|
const staminaPercent = applyTalentModifierValue(100, staminaModifiers, "attribute-percent") - 100;
|
|
const maxHealthPercent = applyTalentModifierValue(100, playerModifiers, "max-health-percent") - 100;
|
|
return Math.max(1, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
stamina: staminaPercent,
|
|
maxHealth: maxHealthPercent,
|
|
}, secondaryClassId).health);
|
|
}
|
|
|
|
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
const healing = ability.effects.some((effect) => (
|
|
effect.kind === "heal"
|
|
|| effect.kind === "hot"
|
|
|| effect.kind === "shield"
|
|
|| effect.kind === "resurrection"
|
|
));
|
|
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
|
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
|
if (healing) return "spell";
|
|
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
|
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
|
return "spell";
|
|
}
|
|
|
|
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
|
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
|
|
if (/\b(?:frost|ice|chill|blizzard)\b/.test(searchable)) return "frost";
|
|
if (/\b(?:shadow|curse|corruption|drain|haunt)\b/.test(searchable)) return "shadow";
|
|
if (/\b(?:arcane|starfire|moonfire)\b/.test(searchable)) return "arcane";
|
|
if (/\b(?:nature|lightning|wrath|insect|earth|storm)\b/.test(searchable)) return "nature";
|
|
if (classId === "paladin" || classId === "priest") return "holy";
|
|
if (classId === "warlock") return "shadow";
|
|
if (classId === "shaman" || classId === "druid") return "nature";
|
|
return "arcane";
|
|
}
|
|
|
|
function equipmentAdjustedCombatAmount(
|
|
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
classId: ClassId,
|
|
ability: AbilityDefinition,
|
|
): number {
|
|
const kind = abilityAttackKind(classId, ability);
|
|
const attackPower = equipmentAttackPower(classId, gearStats, kind);
|
|
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
|
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
|
const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : 0;
|
|
const equipmentPower = kind === "spell"
|
|
? attackPower
|
|
: attackPower / 14 * weaponSpeed + weaponDamage * 0.35;
|
|
const sourceLevel = Math.max(1, typeof scaling === "number" ? level : scaling.sourceLevel ?? level);
|
|
const scalingLevel = Math.min(level, typeof scaling === "number" ? level : scaling.maxScalingLevel || level);
|
|
const coefficient = typeof scaling === "number"
|
|
? scaling
|
|
: scaling.coefficient + Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.coefficientPerLevel ?? 0);
|
|
if (typeof scaling === "number" || scaling.basePoints === undefined) {
|
|
return Math.max(
|
|
0,
|
|
Math.round(coefficient * (playerCombatPower(level) + equipmentPower)),
|
|
);
|
|
}
|
|
const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0);
|
|
const averageRoll = Math.abs(scaling.basePoints)
|
|
+ Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2
|
|
+ levelGrowth;
|
|
// Starter-kit spells are granted early, but their raw DBC values were tuned
|
|
// for their original client level. Scale that base down to the player's
|
|
// current combat budget; once the source level is reached, use the DBC value.
|
|
const earlyLevelScale = level < sourceLevel
|
|
? playerCombatPower(level) / playerCombatPower(sourceLevel)
|
|
: 1;
|
|
const bonusPower = equipmentPower * Math.max(
|
|
0,
|
|
scaling.bonusPowerCoefficient ?? coefficient,
|
|
);
|
|
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
|
|
}
|
|
|
|
function playerCharacterStats(
|
|
classId: ClassId,
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
raceId: RaceId,
|
|
secondaryClassId: ClassId | null,
|
|
modifiers: readonly TalentModifier[],
|
|
): CharacterStats {
|
|
const attributePercent = (attribute: "agility" | "intellect" | "spirit" | "stamina" | "strength") => (
|
|
applyTalentModifierValue(
|
|
100,
|
|
modifiers.filter((modifier) => modifier.kind !== "attribute-percent" || modifier.attribute === attribute),
|
|
"attribute-percent",
|
|
) - 100
|
|
);
|
|
const armorPercent = applyTalentModifierValue(100, modifiers, "armor-percent") - 100;
|
|
const stats = deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
agility: attributePercent("agility"),
|
|
intellect: attributePercent("intellect"),
|
|
spirit: attributePercent("spirit"),
|
|
stamina: attributePercent("stamina"),
|
|
strength: attributePercent("strength"),
|
|
}, secondaryClassId);
|
|
return armorPercent === 0
|
|
? stats
|
|
: Object.freeze({
|
|
...stats,
|
|
armor: Math.max(0, Math.round(stats.armor * (1 + armorPercent / 100))),
|
|
});
|
|
}
|
|
|
|
function npcCombatDefender(mob: MobCombatState, attackerLevel: number): CombatDefenderStats {
|
|
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
|
|
return {
|
|
level: mob.level,
|
|
armor: mob.armor,
|
|
dodgePercent: 5 + levelAdvantage * 0.5,
|
|
parryPercent: 5 + levelAdvantage * 0.5,
|
|
blockPercent: 0,
|
|
blockValue: 0,
|
|
resistances: {
|
|
arcane: mob.resistances.arcane,
|
|
fire: mob.resistances.fire,
|
|
frost: mob.resistances.frost,
|
|
nature: mob.resistances.nature,
|
|
shadow: mob.resistances.shadow,
|
|
},
|
|
};
|
|
}
|
|
|
|
function stableCombatRoll(seed: string) {
|
|
return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`);
|
|
}
|
|
|
|
function resolvePlayerDamageResult(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
mob: MobCombatState,
|
|
amount: number,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
): CombatResult {
|
|
const baseStats = playerCharacterStats(
|
|
classId,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
work.talentModifiers,
|
|
);
|
|
const playerAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
|
|
const stats = applyAuraStats(baseStats, playerAuras);
|
|
const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind);
|
|
const criticalChanceFromTalents = ability
|
|
? applyTalentModifierValue(
|
|
0,
|
|
work.talentModifiers,
|
|
"critical-chance-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent");
|
|
const attacker: CombatAttackerStats = {
|
|
...baseAttacker,
|
|
criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents,
|
|
};
|
|
const immune = mob.schoolImmunities.includes(school);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: school,
|
|
attacker,
|
|
defender: npcCombatDefender(mob, stats.level),
|
|
rules: immune
|
|
? {
|
|
...rules,
|
|
immune: true,
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
resistanceMode: "binary",
|
|
}
|
|
: rules,
|
|
roll: stableCombatRoll(seed),
|
|
});
|
|
}
|
|
|
|
function combatResultDescription(result: CombatResult): string {
|
|
if (result.outcome === "miss") return "missed";
|
|
if (result.outcome === "dodge") return "was dodged";
|
|
if (result.outcome === "parry") return "was parried";
|
|
if (result.outcome === "resist") return "was resisted";
|
|
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
|
|
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`;
|
|
if (result.partiallyResisted) return `hit for ${result.finalDamage} (${result.resistedAmount} resisted)`;
|
|
return `hit for ${result.finalDamage}`;
|
|
}
|
|
|
|
function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
|
|
if (attack.delivery === "melee") return "melee";
|
|
if (attack.delivery === "projectile" && attack.school === "physical") return "ranged";
|
|
return "spell";
|
|
}
|
|
|
|
function resolveEnemyDamageResult(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
defender: CharacterStats,
|
|
amount: number,
|
|
actorId: AggroActorId,
|
|
attackRevision: number,
|
|
now: number,
|
|
): CombatResult {
|
|
const attackKind = enemyAttackKind(attack);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: attack.school,
|
|
attacker: {
|
|
level: mob.level,
|
|
criticalChancePercent: 5,
|
|
},
|
|
defender: combatDefenderFromCharacterStats(defender),
|
|
rules: {
|
|
isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
|
|
canDodge: attackKind === "melee",
|
|
canParry: attackKind === "melee",
|
|
canBlock: attackKind !== "spell",
|
|
},
|
|
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
|
|
});
|
|
}
|
|
|
|
function talentAdjustedMaxResource(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const primaryResourceModifiers = playerModifiers.filter((modifier) => {
|
|
if (modifier.kind !== "max-resource-percent") return true;
|
|
const scope = modifier.scope.toLowerCase();
|
|
const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/.test(scope);
|
|
if (!mentionsSpecificResource) return true;
|
|
if (profile.type === "mana") return /\bmana\b/.test(scope);
|
|
if (profile.type === "rage") return /\b(?:rage|felfury|deathfire)\b/.test(scope);
|
|
if (profile.type === "energy") return /\b(?:energy|focus)\b/.test(scope);
|
|
return /\brunic power\b/.test(scope);
|
|
});
|
|
const maxResourcePercent = applyTalentModifierValue(
|
|
100,
|
|
primaryResourceModifiers,
|
|
"max-resource-percent",
|
|
) - 100;
|
|
if (profile.type !== "mana") {
|
|
return Math.max(0, Math.round(profile.maximum * (1 + maxResourcePercent / 100)));
|
|
}
|
|
const intellectModifiers = primaryResourceModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "intellect"
|
|
));
|
|
const intellectPercent = applyTalentModifierValue(100, intellectModifiers, "attribute-percent") - 100;
|
|
return Math.max(0, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
intellect: intellectPercent,
|
|
maxMana: maxResourcePercent,
|
|
}, secondaryClassId).mana);
|
|
}
|
|
|
|
const AUTOMATIC_CLASS_PASSIVE_PREFIX = "class-passive:";
|
|
|
|
function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
|
|
const statedLevel = ability.description.match(/\bLevel\s+(\d+)\s+Passive\b/i);
|
|
return Math.max(ability.unlockLevel, statedLevel ? Number(statedLevel[1]) : 1);
|
|
}
|
|
|
|
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
|
|
if (!ability.passive) return false;
|
|
if (ability.source !== "coa-tree") return true;
|
|
const node = ability.talentEntryId === undefined
|
|
? null
|
|
: talentNodeById(`coa-entry-${ability.talentEntryId}`);
|
|
return Boolean(
|
|
node?.isPassive
|
|
&& (node.abilityEssenceCost ?? 0) === 0
|
|
&& (node.talentEssenceCost ?? 0) === 0,
|
|
);
|
|
}
|
|
|
|
function automaticClassPassiveModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
): readonly TalentModifier[] {
|
|
const modifiers: TalentModifier[] = [];
|
|
const seenNames = new Set<string>();
|
|
for (const definition of abilitiesForClass(classId)) {
|
|
const ability = abilityAtLevel(definition, level);
|
|
if (
|
|
!isAutomaticClassPassive(ability)
|
|
|| level < automaticClassPassiveUnlockLevel(ability)
|
|
|| seenNames.has(ability.name.toLowerCase())
|
|
) {
|
|
continue;
|
|
}
|
|
seenNames.add(ability.name.toLowerCase());
|
|
modifiers.push(...parseTalentModifiers(ability.description).map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: `${AUTOMATIC_CLASS_PASSIVE_PREFIX}${ability.id}`,
|
|
sourceTalentName: ability.name,
|
|
rank: abilityRankForLevel(ability, level)?.rank ?? 1,
|
|
description: ability.description,
|
|
})));
|
|
}
|
|
return modifiers;
|
|
}
|
|
|
|
function characterStatModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
ranks: TalentRanks,
|
|
): readonly TalentModifier[] {
|
|
return [
|
|
...automaticClassPassiveModifiers(classId, level),
|
|
...talentModifiersForRanks(ranks, classId),
|
|
];
|
|
}
|
|
|
|
function initialResourceForMaximum(classId: ClassId, maximum: number): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
return Math.round(maximum * (profile.maximum > 0 ? profile.initial / profile.maximum : 0));
|
|
}
|
|
|
|
function defaultResourceMaximum(type: ResourceType): number {
|
|
if (type === "nature-power") return 10;
|
|
if (type === "psi") return 6;
|
|
return 100;
|
|
}
|
|
|
|
function initialResourcePools(
|
|
classId: ClassId,
|
|
abilities: readonly AbilityDefinition[],
|
|
activeMaximum: number,
|
|
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
|
|
const activeProfile = resourceProfileForClass(classId);
|
|
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
|
|
for (const ability of abilities) for (const cost of ability.costs ?? [ability.cost]) resourceTypes.add(cost.resource);
|
|
const maximums: Partial<Record<ResourceType, number>> = {};
|
|
const pools: Partial<Record<ResourceType, number>> = {};
|
|
for (const type of resourceTypes) {
|
|
if (type === "health") continue;
|
|
const maximum = type === activeProfile.type ? activeMaximum : defaultResourceMaximum(type);
|
|
maximums[type] = maximum;
|
|
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
|
? 0
|
|
: maximum;
|
|
}
|
|
return { pools, maximums };
|
|
}
|
|
|
|
export function resolvedAbilityCosts(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
|
): readonly { resource: ResourceType; amount: number }[] {
|
|
const aggregated = new Map<ResourceType, number>();
|
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
|
const maximum = cost.resource === "health"
|
|
? state.maxHealth
|
|
: cost.resource === activeResourceType
|
|
? state.maxResource
|
|
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
|
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
|
|
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
|
|
baseAmount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID);
|
|
const resourceAdjusted = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(rawAmount, playerAuras, cost.resource, "cost"),
|
|
playerAuras,
|
|
"power",
|
|
"cost",
|
|
);
|
|
const amount = Math.max(0, Math.round(resourceAdjusted));
|
|
aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount);
|
|
}
|
|
return [...aggregated].map(([resource, amount]) => ({ resource, amount }));
|
|
}
|
|
|
|
function talentAdjustedResourceCost(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
ability.cost.amount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedCastTime(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.castTimeMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.castTimeMs, modifiers, "cast-time-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cast-time-percent", context);
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", context);
|
|
duration = hastedDuration(
|
|
duration,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
);
|
|
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
|
|
if (ability.cooldownMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.cooldownMs, modifiers, "cooldown-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cooldown-percent", context);
|
|
return Math.max(0, Math.round(duration));
|
|
}
|
|
|
|
function talentAdjustedGcd(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.gcdMs <= 0) return 0;
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
|
|
const duration = hastedDuration(
|
|
ability.gcdMs,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
1_000,
|
|
);
|
|
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedDuration(
|
|
durationMs: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
durationMs, modifiers, "duration-percent", abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedRange(
|
|
range: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability)));
|
|
}
|
|
|
|
function talentAdjustedIncomingDamage(amount: number, modifiers: readonly TalentModifier[]): number {
|
|
return Math.max(0, Math.round(
|
|
applyTalentModifierValue(amount, modifiers, "damage-taken-percent"),
|
|
));
|
|
}
|
|
|
|
function normalizedSettings(
|
|
current: GameplaySettings,
|
|
patch: Partial<GameplaySettings>,
|
|
): GameplaySettings {
|
|
return {
|
|
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
|
|
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
|
|
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
|
|
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
|
|
threatMeterPosition: normalizeHudPosition(
|
|
patch.threatMeterPosition ?? current.threatMeterPosition,
|
|
),
|
|
showFloatingCombatText: patch.showFloatingCombatText ?? current.showFloatingCombatText,
|
|
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
|
|
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
|
|
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
|
|
uiScale: clamp(Number.isFinite(patch.uiScale) ? patch.uiScale! : current.uiScale, 0.75, 1.25),
|
|
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
|
? patch.minimapRotation
|
|
: current.minimapRotation,
|
|
gameplayFrameRate: normalizeGameplayFrameRateLimit(
|
|
patch.gameplayFrameRate,
|
|
current.gameplayFrameRate,
|
|
),
|
|
previewFrameRate: normalizePreviewFrameRateLimit(
|
|
patch.previewFrameRate,
|
|
current.previewFrameRate,
|
|
),
|
|
};
|
|
}
|
|
|
|
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
|
|
return left.showMobHealthBars === right.showMobHealthBars
|
|
&& left.showMobAggroRanges === right.showMobAggroRanges
|
|
&& left.showTargetFrame === right.showTargetFrame
|
|
&& left.showThreatMeter === right.showThreatMeter
|
|
&& left.threatMeterPosition.x === right.threatMeterPosition.x
|
|
&& left.threatMeterPosition.y === right.threatMeterPosition.y
|
|
&& left.showFloatingCombatText === right.showFloatingCombatText
|
|
&& left.autoTargetNearest === right.autoTargetNearest
|
|
&& left.enableScreenShake === right.enableScreenShake
|
|
&& left.masterVolume === right.masterVolume
|
|
&& left.uiScale === right.uiScale
|
|
&& left.minimapRotation === right.minimapRotation
|
|
&& left.gameplayFrameRate === right.gameplayFrameRate
|
|
&& left.previewFrameRate === right.previewFrameRate;
|
|
}
|
|
|
|
function defaultMobStats(
|
|
playerLevel: number,
|
|
boss: boolean,
|
|
serverEntry?: number,
|
|
): {
|
|
maxHealth: number;
|
|
xpReward: number;
|
|
armor: number;
|
|
maxMana: number;
|
|
attackPower: number;
|
|
attackDamage: number;
|
|
swingMs: number;
|
|
mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
schoolImmunities: readonly AzerothCoreSchool[];
|
|
} {
|
|
const server = serverEntry ? creatureRuntimeBaseStats(serverEntry, playerLevel) : null;
|
|
const legacy = boss
|
|
? { maxHealth: 260 + playerLevel * 65, xpReward: 110 + playerLevel * 35 }
|
|
: { maxHealth: 72 + playerLevel * 22, xpReward: 28 + playerLevel * 7 };
|
|
return {
|
|
maxHealth: server?.health ?? legacy.maxHealth,
|
|
xpReward: legacy.xpReward,
|
|
armor: server?.armor ?? 0,
|
|
maxMana: server?.mana ?? 0,
|
|
attackPower: server?.attackPower ?? 0,
|
|
attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2),
|
|
swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900),
|
|
mechanicImmunities: server?.mechanicImmunities ?? [],
|
|
schoolImmunities: server?.schoolImmunities ?? [],
|
|
};
|
|
}
|
|
|
|
function stableCombatUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function applyWrathCastPushback(
|
|
cast: ActiveCast | null,
|
|
now: number,
|
|
seed: string,
|
|
): ActiveCast | null {
|
|
if (!cast || cast.pushbackHits >= 2) return cast;
|
|
const ability = abilityById(cast.abilityId);
|
|
const resistance = ability ? abilityPushbackResistance(ability) : 0;
|
|
if (resistance >= 1 || stableCombatUnit(`${seed}:pushback`) < resistance) return cast;
|
|
const pushbackHits = cast.pushbackHits + 1;
|
|
if (cast.mode === "channel") {
|
|
const lostTicks = Math.max(1, Math.ceil(cast.totalTicks * 0.25));
|
|
const totalTicks = Math.max(cast.ticksCompleted, cast.totalTicks - lostTicks);
|
|
return {
|
|
...cast,
|
|
pushbackHits,
|
|
totalTicks,
|
|
completesAt: Math.max(now, cast.completesAt - cast.originalDurationMs * 0.25),
|
|
};
|
|
}
|
|
return { ...cast, pushbackHits, completesAt: cast.completesAt + 500 };
|
|
}
|
|
|
|
export function deterministicEnemyCooldown(
|
|
runtimeId: string,
|
|
abilityId: string,
|
|
activation: number,
|
|
range: readonly [number, number],
|
|
): number {
|
|
const minimum = Math.max(0, Math.round(range[0]));
|
|
const maximum = Math.max(minimum, Math.round(range[1]));
|
|
if (maximum === minimum) return minimum;
|
|
return minimum + Math.floor(
|
|
stableCombatUnit(`${runtimeId}:${abilityId}:${activation}`) * (maximum - minimum + 1),
|
|
);
|
|
}
|
|
|
|
function enemyAbilityConditionMet(
|
|
mob: MobCombatState,
|
|
mobs: Readonly<Record<string, MobCombatState>>,
|
|
attack: EnemyAttackDefinition,
|
|
): boolean {
|
|
const condition = attack.condition;
|
|
if (!condition || condition.kind === "in-combat") return true;
|
|
if (condition.kind === "self-health-percent") {
|
|
const percent = mob.maxHealth > 0 ? mob.health / mob.maxHealth * 100 : 0;
|
|
return percent >= condition.minimum && percent <= condition.maximum;
|
|
}
|
|
if (condition.kind === "encounter-state") {
|
|
if (condition.encounterId !== "wailing-caverns:naralex") return false;
|
|
return condition.states.includes(useWailingEncounterStore.getState().phase);
|
|
}
|
|
const origin = getMobPosition(mob.id);
|
|
if (!origin) return false;
|
|
return Object.values(mobs).some((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - condition.amount) return false;
|
|
const position = getMobPosition(candidate.id);
|
|
if (!position) return false;
|
|
const dx = position[0] - origin[0];
|
|
const dy = position[1] - origin[1];
|
|
const dz = position[2] - origin[2];
|
|
return dx * dx + dy * dy + dz * dz <= condition.range * condition.range;
|
|
});
|
|
}
|
|
|
|
function enemyAbilityNeedsHostileRange(attack: EnemyAttackDefinition): boolean {
|
|
return attack.target === "primary"
|
|
|| attack.target === "random-party"
|
|
|| attack.target === "nearby-party";
|
|
}
|
|
|
|
function initialEnemyAbilityReadyAt(
|
|
mob: MobCombatState,
|
|
now: number,
|
|
): Readonly<Record<string, number>> {
|
|
return Object.fromEntries(mob.attacks.map((attack) => {
|
|
const range = attack.initialCooldownMs ?? [450, 450] as const;
|
|
return [
|
|
attack.id,
|
|
now + deterministicEnemyCooldown(mob.id, attack.id, mob.attackRevision, range),
|
|
];
|
|
}));
|
|
}
|
|
|
|
function summonedMobCombatState(
|
|
id: string,
|
|
summoner: MobCombatState,
|
|
creatureEntry: number,
|
|
targetActorId: AggroActorId,
|
|
position: CombatPosition,
|
|
): MobCombatState {
|
|
const stats = defaultMobStats(summoner.level, false, creatureEntry);
|
|
const template = creatureTemplateReference(creatureEntry);
|
|
const attack: EnemyAttackDefinition = normalizeEnemyAttacks([{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
cooldownMs: stats.swingMs,
|
|
repeatCooldownMs: [stats.swingMs, stats.swingMs],
|
|
initialCooldownMs: [450, 450],
|
|
}])[0];
|
|
return {
|
|
id,
|
|
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
|
|
serverEntry: creatureEntry,
|
|
summonedBy: summoner.id,
|
|
boss: false,
|
|
lootSourceId: null,
|
|
level: summoner.level,
|
|
armor: stats.armor,
|
|
maxMana: stats.maxMana,
|
|
attackPower: stats.attackPower,
|
|
mechanicImmunities: stats.mechanicImmunities,
|
|
schoolImmunities: stats.schoolImmunities,
|
|
resistances: { arcane: 0, fire: 0, frost: 0, nature: 0, shadow: 0 },
|
|
health: stats.maxHealth,
|
|
maxHealth: stats.maxHealth,
|
|
healthMultiplier: 1,
|
|
damageMultiplier: 1,
|
|
bonusLootChance: 0,
|
|
dead: false,
|
|
hasLoot: false,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward: 0,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: seedActorThreat({}, targetActorId),
|
|
targetActorId,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: position,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange: attack.range,
|
|
attackDamage: stats.attackDamage,
|
|
swingMs: stats.swingMs,
|
|
aggroRange: 0,
|
|
leashRange: summoner.leashRange,
|
|
moveSpeed: summoner.moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks: [attack],
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
}
|
|
|
|
function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosition {
|
|
return origin ?? state.playerPosition;
|
|
}
|
|
|
|
function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveAura[] {
|
|
return auras.filter((aura) => aura.targetId === entityId);
|
|
}
|
|
|
|
function hasControlImmunity(
|
|
auras: readonly ActiveAura[],
|
|
entityId: string,
|
|
mechanic: EnemyControlMechanic,
|
|
): boolean {
|
|
return aurasForEntity(auras, entityId).some((aura) => (
|
|
aura.definition.controlImmunities?.includes(mechanic)
|
|
));
|
|
}
|
|
|
|
function auraDefinitionForAbility(
|
|
definition: AuraDefinition,
|
|
ability: AbilityDefinition,
|
|
durationMs = definition.durationMs,
|
|
): AuraDefinition {
|
|
return {
|
|
...definition,
|
|
description: definition.description ?? ability.description,
|
|
icon: definition.icon ?? ability.icon,
|
|
durationMs,
|
|
};
|
|
}
|
|
|
|
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
|
const stat = (value: number, ...names: string[]) => names.reduce(
|
|
(current, name) => resolveAuraStatValue(current, auras, name),
|
|
value,
|
|
);
|
|
const primary = (value: number, name: string, alias?: string) => stat(
|
|
value,
|
|
name,
|
|
...(alias ? [alias] : []),
|
|
"all-primary",
|
|
"all-primary-attributes",
|
|
);
|
|
const resistance = (value: number, school: string) => stat(
|
|
value,
|
|
`resistance:${school}`,
|
|
"resistance:all",
|
|
"all-resistance",
|
|
"magical-defense",
|
|
);
|
|
return {
|
|
...base,
|
|
strength: primary(base.strength, "strength"),
|
|
agility: primary(base.agility, "agility", "dexterity"),
|
|
stamina: primary(base.stamina, "stamina"),
|
|
intellect: primary(base.intellect, "intellect"),
|
|
spirit: primary(base.spirit, "spirit", "wisdom"),
|
|
health: stat(base.health, "maximum-health"),
|
|
mana: stat(base.mana, "maximum-mana"),
|
|
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
|
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
|
spellPower: stat(base.spellPower, "spell-power", "magical-attack"),
|
|
armor: stat(base.armor, "armor", "physical-defense"),
|
|
blockValue: stat(base.blockValue, "block-value"),
|
|
meleeCritPercent: stat(base.meleeCritPercent, "critical-strike", "physical-critical-strike"),
|
|
rangedCritPercent: stat(base.rangedCritPercent, "critical-strike", "physical-critical-strike"),
|
|
spellCritPercent: stat(base.spellCritPercent, "spell-critical-strike", "magical-critical-strike"),
|
|
meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"),
|
|
rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit"),
|
|
spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"),
|
|
meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"),
|
|
rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"),
|
|
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
|
dodgePercent: stat(base.dodgePercent, "dodge"),
|
|
parryPercent: stat(base.parryPercent, "parry"),
|
|
blockPercent: stat(base.blockPercent, "block"),
|
|
expertise: stat(base.expertise, "expertise"),
|
|
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
|
fireResistance: resistance(base.fireResistance, "fire"),
|
|
frostResistance: resistance(base.frostResistance, "frost"),
|
|
natureResistance: resistance(base.natureResistance, "nature"),
|
|
shadowResistance: resistance(base.shadowResistance, "shadow"),
|
|
};
|
|
}
|
|
|
|
function syncPassiveAuras(
|
|
current: readonly ActiveAura[],
|
|
abilities: readonly AbilityDefinition[],
|
|
level: number,
|
|
talentRanks: TalentRanks,
|
|
now: number,
|
|
): ActiveAura[] {
|
|
const definitions = abilities.flatMap((catalogAbility) => {
|
|
if (!catalogAbility.passive || !isAbilityUnlocked(catalogAbility, level)) return [];
|
|
const learnedRank = catalogAbility.talentEntryId === undefined
|
|
? undefined
|
|
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
if (catalogAbility.talentEntryId !== undefined && !learnedRank) return [];
|
|
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
|
return ability.effects
|
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
|
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
|
});
|
|
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
|
let next = current.filter((aura) => (
|
|
aura.sourceId !== PLAYER_AURA_ENTITY_ID
|
|
|| !abilities.some((ability) => ability.passive && aura.definition.id.includes(`:${ability.dbcSpellId}:`))
|
|
|| passiveIds.has(aura.definition.id)
|
|
));
|
|
for (const definition of definitions) {
|
|
if (next.some((aura) => aura.definition.id === definition.id && aura.targetId === PLAYER_AURA_ENTITY_ID)) continue;
|
|
next = [...applyCombatAura(next, {
|
|
definition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function executeTriggeredAuraProcsInWork(
|
|
work: MutableCastWork,
|
|
triggeredProcs: readonly TriggeredAuraProc[],
|
|
now: number,
|
|
): void {
|
|
for (const triggered of triggeredProcs) {
|
|
const action = triggered.action;
|
|
const actionTarget = action.kind === "custom"
|
|
? null
|
|
: action.target === "aura-source"
|
|
? triggered.sourceId
|
|
: action.target === "aura-target"
|
|
? triggered.targetId
|
|
: triggered.event.otherId ?? null;
|
|
if (action.kind === "resource" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
const resource = action.resource as ResourceType;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
|
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
|
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
|
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
|
damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
actionTarget,
|
|
Math.max(0, action.amount),
|
|
now,
|
|
playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true),
|
|
action.school,
|
|
);
|
|
} else if (action.kind === "apply-aura" && actionTarget) {
|
|
const definition = work.auras.find((aura) => aura.definition.id === action.auraId)?.definition;
|
|
if (definition) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition,
|
|
sourceId: triggered.sourceId,
|
|
targetId: actionTarget,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (
|
|
action.kind === "custom"
|
|
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
|
&& typeof action.data?.spellId === "number"
|
|
) {
|
|
const spellId = action.data.spellId;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
|
|
const triggeredSpellStack = work.triggeredSpellStack ?? [];
|
|
if (
|
|
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|
|
|| triggeredSpellStack.includes(triggerKey)
|
|
) continue;
|
|
triggeredSpellStack.push(triggerKey);
|
|
work.triggeredSpellStack = triggeredSpellStack;
|
|
try {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
spellId,
|
|
triggered.sourceId,
|
|
targetId,
|
|
now,
|
|
0,
|
|
true,
|
|
);
|
|
} finally {
|
|
triggeredSpellStack.pop();
|
|
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
|
|
}
|
|
} else if (
|
|
action.kind === "custom"
|
|
&& action.id === "classic-status"
|
|
&& typeof action.data?.status === "string"
|
|
) {
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const mob = work.mobs[targetId];
|
|
if (mob) {
|
|
work.mobs[targetId] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: action.data.status as MobStatusKind,
|
|
durationMs: typeof action.data.durationMs === "number" ? action.data.durationMs : 3_000,
|
|
...(typeof action.data.magnitude === "number" ? { magnitude: action.data.magnitude } : {}),
|
|
}, `proc:${triggered.procId}`, now);
|
|
}
|
|
} else if (action.kind === "custom" && action.id === "rom-frost-arrow") {
|
|
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const mob = work.mobs[targetId];
|
|
if (mob && triggeringAbility?.name === "Wind Arrow") {
|
|
work.mobs[targetId] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "slow",
|
|
durationMs: 6_000,
|
|
magnitude: 0.4,
|
|
}, `proc:${triggered.procId}`, now);
|
|
}
|
|
} else if (action.kind === "custom" && action.id === "rom-entling-offering") {
|
|
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
if (work.mobs[targetId] && triggeringAbility?.name === "Shot") {
|
|
damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
targetId,
|
|
Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
|
|
now,
|
|
playerThreatSource({ talentModifiers: work.talentModifiers }, "rom-entling-offering", true),
|
|
"physical",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function evaluateProcsInWork(
|
|
work: MutableCastWork,
|
|
event: ProcEvent,
|
|
now: number,
|
|
): readonly TriggeredAuraProc[] {
|
|
const result = evaluateAuraProcs(
|
|
work.auras,
|
|
event,
|
|
now,
|
|
({ aura, proc }) => stableCombatUnit(
|
|
`${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`,
|
|
),
|
|
);
|
|
work.auras = [...result.auras];
|
|
executeTriggeredAuraProcsInWork(work, result.triggered, now);
|
|
return result.triggered;
|
|
}
|
|
|
|
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
|
|
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
return Boolean(mob && !mob.dead);
|
|
});
|
|
}
|
|
|
|
function castFailureMessage(reason: CastFailureReason, resourceName: string, unlockLevel?: number): string {
|
|
switch (reason) {
|
|
case "unknown-ability": return "That ability is unavailable.";
|
|
case "wrong-class": return "Your class cannot use that ability.";
|
|
case "not-learned": return "Learn that ability in the Conquest talent tree first.";
|
|
case "passive": return "Passive abilities activate automatically and cannot be cast.";
|
|
case "unsupported": return "That spell has no executable combat effect.";
|
|
case "level-locked": return `That ability unlocks at level ${unlockLevel ?? "a higher level"}.`;
|
|
case "player-dead": return "You cannot act while defeated.";
|
|
case "player-controlled": return "You cannot act while under an enemy control effect.";
|
|
case "already-casting": return "Another spell is already being cast.";
|
|
case "global-cooldown": return "Your abilities are not ready yet.";
|
|
case "cooldown": return "That ability is still recharging.";
|
|
case "not-enough-resource": return `Not enough ${resourceName}.`;
|
|
case "not-enough-runes": return "The required Death Knight runes are still recharging.";
|
|
case "condition-not-met": return "That ability's required RuneWaker state is not active.";
|
|
case "no-target": return "Select an enemy target.";
|
|
case "target-dead": return "That target is already defeated.";
|
|
case "target-unavailable": return "That target is not currently visible.";
|
|
case "line-of-sight": return "That target is not in line of sight.";
|
|
case "not-facing": return "You must face that target.";
|
|
case "must-be-behind": return "You must be behind that target.";
|
|
case "invalid-destination": return "That ground destination is invalid.";
|
|
case "school-locked": return "That spell school is locked out.";
|
|
case "too-close": return "That target is too close.";
|
|
case "out-of-range": return "That target is out of range.";
|
|
}
|
|
}
|
|
|
|
function levelUpMessage(classId: ClassId, previousLevel: number, currentLevel: number): string {
|
|
const parts = [`Level ${currentLevel}!`];
|
|
const unlocked = abilitiesUnlockedBetweenLevels(classId, previousLevel, currentLevel);
|
|
if (unlocked.length === 1) {
|
|
parts.push(`New ability unlocked: ${unlocked[0].name}.`);
|
|
} else if (unlocked.length > 1) {
|
|
parts.push(`New abilities unlocked: ${unlocked.map((ability) => ability.name).join(", ")}.`);
|
|
}
|
|
if (talentPointsForLevel(currentLevel) > talentPointsForLevel(previousLevel)) {
|
|
parts.push("A new talent point is available.");
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function progressionFromState(state: CombatState): MutableProgression {
|
|
return {
|
|
level: state.level,
|
|
xp: state.xp,
|
|
xpToNext: state.xpToNext,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
levelsGained: 0,
|
|
};
|
|
}
|
|
|
|
function grantMobExperience(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
xpReward: number,
|
|
): number {
|
|
const { progression } = work;
|
|
const scaledAward = healerManExperienceAward(xpReward, progression.level);
|
|
const result = awardExperience({ level: progression.level, xp: progression.xp }, scaledAward);
|
|
progression.level = result.level;
|
|
progression.xp = result.xp;
|
|
progression.xpToNext = result.xpToNext;
|
|
progression.levelsGained += result.levelsGained;
|
|
if (result.levelsGained > 0) {
|
|
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
|
|
progression.maxHealth = talentAdjustedMaxHealth(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
);
|
|
progression.health = progression.maxHealth;
|
|
work.health = progression.health;
|
|
work.maxHealth = progression.maxHealth;
|
|
work.maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
);
|
|
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
|
}
|
|
return scaledAward;
|
|
}
|
|
|
|
function mobGroupId(id: string): string {
|
|
if (id.startsWith("manastorm:guardian-spawn:")) {
|
|
const memberSeparator = id.lastIndexOf(":");
|
|
return memberSeparator > 0 ? id.slice(0, memberSeparator) : id;
|
|
}
|
|
// A Manastorm boss shares an aura with guardians, not their aggro group.
|
|
// Keeping its full runtime id lets players choose to pull it early without
|
|
// every guardian hit dragging the boss across the stage.
|
|
if (id.startsWith("manastorm:spawn:")) return id;
|
|
const separator = id.indexOf(":");
|
|
return separator > 0 ? id.slice(0, separator) : id;
|
|
}
|
|
|
|
function engageMobGroup(
|
|
mobs: Record<string, MobCombatState>,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): boolean {
|
|
const groupId = mobGroupId(sourceId);
|
|
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
|
|
mobGroupId(id) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
let changed = false;
|
|
for (const [id, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
|
const threatByActor = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, sourceActorId);
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor,
|
|
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(id))
|
|
: (getMobPosition(id) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function engageMobGroupInWork(
|
|
work: MutableCastWork,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): void {
|
|
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
|
|
}
|
|
|
|
function nearestAggroActorId(
|
|
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
|
|
mobPosition: CombatPosition,
|
|
aggroRange: number,
|
|
): AggroActorId | null {
|
|
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
|
|
let nearestId: AggroActorId | null = null;
|
|
let nearestDistanceSquared = aggroRange * aggroRange;
|
|
if (state.health > 0) {
|
|
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
|
if (playerDistanceSquared <= nearestDistanceSquared) {
|
|
nearestId = PLAYER_AGGRO_ID;
|
|
nearestDistanceSquared = playerDistanceSquared;
|
|
}
|
|
}
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health <= 0) continue;
|
|
const position = getPartyRuntimePosition(member.id);
|
|
if (!position) continue;
|
|
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
|
if (memberDistanceSquared < nearestDistanceSquared) {
|
|
nearestId = member.id;
|
|
nearestDistanceSquared = memberDistanceSquared;
|
|
}
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) {
|
|
if (actor.health <= 0) continue;
|
|
const position = getPartyRuntimePosition(actor.id);
|
|
if (!position) continue;
|
|
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
|
if (actorDistanceSquared < nearestDistanceSquared) {
|
|
nearestId = actor.id;
|
|
nearestDistanceSquared = actorDistanceSquared;
|
|
}
|
|
}
|
|
for (const summon of state.summons) {
|
|
if (summon.health <= 0) continue;
|
|
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
|
if (summonDistanceSquared < nearestDistanceSquared) {
|
|
nearestId = summon.id;
|
|
nearestDistanceSquared = summonDistanceSquared;
|
|
}
|
|
}
|
|
return nearestId;
|
|
}
|
|
|
|
function mobResistanceForSchool(
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
): number {
|
|
if (school === "arcane") return mob.resistances.arcane;
|
|
if (school === "fire") return mob.resistances.fire;
|
|
if (school === "frost") return mob.resistances.frost;
|
|
if (school === "nature") return mob.resistances.nature;
|
|
if (school === "shadow") return mob.resistances.shadow;
|
|
return 0;
|
|
}
|
|
|
|
function damageAfterMobResistance(
|
|
amount: number,
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
spellPenetration: number,
|
|
attackerLevel: number,
|
|
): number {
|
|
if (mob.schoolImmunities.includes(school)) return 0;
|
|
if (school === "physical" || school === "holy") return amount;
|
|
const resistance = Math.max(0, mobResistanceForSchool(mob, school) - Math.max(0, spellPenetration));
|
|
const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75;
|
|
return amount * (1 - Math.min(0.75, resisted));
|
|
}
|
|
|
|
function damageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
school: DamageSchool = "physical",
|
|
spellPenetration = 0,
|
|
combatResult?: CombatResult,
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
|
amount,
|
|
mob,
|
|
school,
|
|
spellPenetration,
|
|
work.progression.level,
|
|
);
|
|
const mitigated = resolveAuraDamageValue(
|
|
mitigatedBeforeAuras,
|
|
aurasForEntity(work.auras, id),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const auraAbsorb = absorbAuraDamage(work.auras, {
|
|
targetId: id,
|
|
amount: mitigated,
|
|
school,
|
|
now,
|
|
});
|
|
work.auras = [...auraAbsorb.auras];
|
|
const adjustedCombatResult = combatResult
|
|
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
|
|
: undefined;
|
|
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
|
|
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.remainingDamage)));
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: source.actorId,
|
|
targetActorId: id,
|
|
...(source.abilityId ? { abilityId: source.abilityId } : {}),
|
|
school,
|
|
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: auraAbsorb.absorbed,
|
|
critical: combatResult?.critical ?? false,
|
|
outcome: combatResult?.outcome ?? "hit",
|
|
});
|
|
const thorns = mob.auras.find((aura) => aura.id === "thorns");
|
|
if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) {
|
|
const reflected = Math.min(
|
|
work.health,
|
|
Math.max(1, Math.round(applied * (thorns.magnitude ?? 0.08))),
|
|
);
|
|
work.health -= reflected;
|
|
work.progression.health = work.health;
|
|
}
|
|
const health = mob.health - applied;
|
|
const killed = health <= 0;
|
|
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
|
const canAward = killed && !mob.experienceGranted;
|
|
const beforeLevel = work.progression.level;
|
|
const pendingLoot = killed && mob.lootSourceId
|
|
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
|
now,
|
|
bonusItemChance: mob.bonusLootChance,
|
|
})
|
|
: mob.pendingLoot;
|
|
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 : mob.hasLoot;
|
|
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
|
|
engageMobGroupInWork(work, id, now, source.actorId);
|
|
const engagedMob = work.mobs[id] ?? mob;
|
|
const threatByActor = killed
|
|
? {}
|
|
: addActorThreat(
|
|
engagedMob.threatByActor,
|
|
source.actorId,
|
|
threatFromDamage(applied, source.role, source.threatScale),
|
|
);
|
|
work.mobs[id] = {
|
|
...engagedMob,
|
|
health,
|
|
dead: killed,
|
|
hasLoot,
|
|
pendingLoot,
|
|
looted: killed ? false : mob.looted,
|
|
despawned: false,
|
|
diedAt: killed ? now : mob.diedAt,
|
|
corpseDespawnAt: killed
|
|
? (hasLoot ? null : now + EMPTY_CORPSE_DESPAWN_MS)
|
|
: mob.corpseDespawnAt,
|
|
experienceGranted: mob.experienceGranted || killed,
|
|
lastDamageAt: now,
|
|
statuses: killed ? [] : applied > 0
|
|
? mob.statuses.filter((status) => !(status.breakOnDamage
|
|
?? ["fear", "sleep", "blind", "confuse"].includes(status.kind)))
|
|
: mob.statuses,
|
|
threatByActor,
|
|
targetActorId: killed ? null : (engagedMob.targetActorId ?? source.actorId),
|
|
forcedTarget: killed ? null : engagedMob.forcedTarget,
|
|
engaged: !killed,
|
|
combatPhase: killed ? "idle" : "chasing",
|
|
homePosition: engagedMob.homePosition ?? getMobPosition(id),
|
|
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
|
|
activeCast: killed ? null : engagedMob.activeCast,
|
|
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
|
|
nextWoundAnimationAt: canPlayWoundAnimation
|
|
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
|
|
: mob.nextWoundAnimationAt,
|
|
deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision,
|
|
};
|
|
// Pulling a member links the whole authored pack even when the opening hit
|
|
// kills that member outright. The dead source remains inert because the
|
|
// group helper deliberately skips dead mobs.
|
|
work.totalDamage += applied;
|
|
if (applied > 0) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "damage-taken",
|
|
subjectId: id,
|
|
otherId: source.actorId,
|
|
abilityId: source.abilityId,
|
|
amount: applied,
|
|
school,
|
|
}, now);
|
|
}
|
|
return {
|
|
amount: applied,
|
|
killed,
|
|
xpAwarded,
|
|
levelsGained: work.progression.level - beforeLevel,
|
|
...(adjustedCombatResult ? { combatResult: adjustedCombatResult } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveAndDamageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0) {
|
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
}
|
|
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
|
const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, {
|
|
direction: "dealt",
|
|
school,
|
|
attackKind,
|
|
});
|
|
const combatResult = resolvePlayerDamageResult(
|
|
classId,
|
|
work,
|
|
mob,
|
|
modifiedAmount,
|
|
attackKind,
|
|
school,
|
|
seed,
|
|
ability,
|
|
periodic,
|
|
rules,
|
|
);
|
|
const result = damageMobInWork(
|
|
classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
source,
|
|
school,
|
|
work.gearStats.spellPenetration,
|
|
combatResult,
|
|
);
|
|
const trigger = combatResult.outcome === "crit"
|
|
? "crit"
|
|
: combatResult.outcome === "dodge"
|
|
? "dodge"
|
|
: combatResult.outcome === "parry"
|
|
? "parry"
|
|
: combatResult.outcome === "block"
|
|
? "block"
|
|
: combatResult.landed
|
|
? "hit"
|
|
: null;
|
|
if (trigger) {
|
|
evaluateProcsInWork(work, {
|
|
trigger,
|
|
subjectId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? id : source.actorId,
|
|
otherId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? source.actorId : id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
if (result.killed) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "kill",
|
|
subjectId: source.actorId,
|
|
otherId: id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function applyStatus(
|
|
mob: MobCombatState,
|
|
effect: Extract<AbilityEffect, { kind: "status" }>,
|
|
abilityId: string,
|
|
now: number,
|
|
): MobCombatState {
|
|
if (mob.dead) return mob;
|
|
if (mob.mechanicImmunities.includes(effect.status as AzerothCoreMechanic)) return mob;
|
|
const baseDurationMs = mob.boss && effect.status === "stun"
|
|
? Math.min(3000, effect.durationMs)
|
|
: effect.durationMs;
|
|
const diminishingStatuses = new Set<MobStatusKind>([
|
|
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
|
|
]);
|
|
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
|
|
const applications = mob.boss && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
|
|
? previousDiminishing.applications
|
|
: 0;
|
|
const multiplier = mob.boss && diminishingStatuses.has(effect.status)
|
|
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
|
|
: 1;
|
|
const durationMs = Math.round(baseDurationMs * multiplier);
|
|
const controlDiminishingReturns = mob.boss && diminishingStatuses.has(effect.status)
|
|
? {
|
|
...mob.controlDiminishingReturns,
|
|
[effect.status]: {
|
|
applications: Math.min(3, applications + 1),
|
|
resetAt: now + 15_000,
|
|
},
|
|
}
|
|
: mob.controlDiminishingReturns;
|
|
if (effect.status === "interrupt") {
|
|
const cast = mob.activeCast;
|
|
if (!cast?.interruptible) return mob;
|
|
return {
|
|
...mob,
|
|
activeCast: null,
|
|
nextAttackAt: now,
|
|
schoolLockedUntil: {
|
|
...mob.schoolLockedUntil,
|
|
[cast.school]: Math.max(mob.schoolLockedUntil[cast.school] ?? 0, now + durationMs),
|
|
},
|
|
};
|
|
}
|
|
if (durationMs <= 0) return { ...mob, controlDiminishingReturns };
|
|
const status: MobStatus = {
|
|
kind: effect.status,
|
|
sourceAbilityId: abilityId,
|
|
endsAt: now + durationMs,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
...(effect.breakOnDamage === undefined ? {} : { breakOnDamage: effect.breakOnDamage }),
|
|
};
|
|
const incapacitated = [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(effect.status);
|
|
const cancelsActiveCast = Boolean(mob.activeCast && (
|
|
incapacitated
|
|
|| (effect.status === "silence" && mob.activeCast!.school !== "physical")
|
|
|| (effect.status === "disarm" && mob.activeCast!.school === "physical")
|
|
));
|
|
return {
|
|
...mob,
|
|
...(cancelsActiveCast
|
|
? { activeCast: null, nextAttackAt: now }
|
|
: {}),
|
|
controlDiminishingReturns,
|
|
statuses: [...mob.statuses.filter((entry) => entry.kind !== effect.status), status],
|
|
};
|
|
}
|
|
|
|
function executeTriggeredSpellInWork(
|
|
work: MutableCastWork,
|
|
spellId: number,
|
|
sourceId: string,
|
|
targetId: string | null,
|
|
now: number,
|
|
depth: number,
|
|
allowCoaHidden = false,
|
|
): void {
|
|
if (depth > 1) return;
|
|
const catalogAbility = work.abilities.find((candidate) => (
|
|
candidate.dbcSpellId === spellId
|
|
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
|
|
)) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden);
|
|
if (!catalogAbility) return;
|
|
const ability = abilityAtLevel(catalogAbility, work.progression.level);
|
|
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
|
|
const attackKind = abilityAttackKind(work.classId, ability);
|
|
const school = abilityDamageSchool(work.classId, ability);
|
|
const mobTargets = targetId && work.mobs[targetId] && !work.mobs[targetId].dead ? [targetId] : [];
|
|
for (const effect of ability.effects) {
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const mobId of mobTargets) {
|
|
resolveAndDamageMobInWork(
|
|
work.classId,
|
|
work,
|
|
mobId,
|
|
amount,
|
|
now,
|
|
source,
|
|
attackKind,
|
|
school,
|
|
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
|
ability,
|
|
);
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const rawAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
|
aurasForEntity(work.auras, recipient),
|
|
"taken",
|
|
));
|
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
|
const healed = Math.min(amount, work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
} else {
|
|
const healed = healCompanionActorInWork(work, recipient, amount);
|
|
work.totalHealing += healed;
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], effect, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const recipient = effect.recipient === "caster"
|
|
? sourceId
|
|
: targetId ?? sourceId;
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: auraDefinitionForAbility(effect.aura, ability),
|
|
sourceId,
|
|
targetId: recipient,
|
|
now,
|
|
}).auras];
|
|
if (effect.control && work.mobs[recipient]) {
|
|
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs: effect.aura.durationMs ?? 1_000,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${effect.aura.id}`, now);
|
|
}
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum);
|
|
if (resource === resourceProfileForClass(work.classId).type) {
|
|
work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "dot") {
|
|
for (const mobId of mobTargets) {
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "dot"
|
|
&& entry.targetId === mobId
|
|
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"dot", ability.id, source.actorId, mobId, effect, effect.ticks, effect.intervalMs, now,
|
|
effect.tag, school, work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
} else if (effect.kind === "hot") {
|
|
const recipient = targetId && !work.mobs[targetId] ? targetId : null;
|
|
work.effects.push(timedEffect(
|
|
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
|
));
|
|
} else if (effect.kind === "shield") {
|
|
const amount = equipmentAdjustedCombatAmount(
|
|
effect, work.progression.level, work.gearStats, work.classId, ability,
|
|
);
|
|
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
|
work.shield = Math.max(work.shield, amount);
|
|
work.shieldExpiresAt = now + effect.durationMs;
|
|
} else {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: {
|
|
id: `coa-triggered-shield:${spellId}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs: effect.durationMs,
|
|
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
|
|
},
|
|
sourceId,
|
|
targetId: recipient,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "summon") {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-triggered-summon-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: /totem/i.test(ability.name) ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId: sourceId,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
position: getMobPosition(targetId ?? "") ?? [0, 0, 0],
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
});
|
|
} else if (effect.kind === "resource-drain") {
|
|
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
if (mobTargets.length && !effect.burn) {
|
|
const resourceBefore = work.resourcePools[resource] ?? 0;
|
|
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
|
|
} else if (!mobTargets.length) {
|
|
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount);
|
|
}
|
|
if (resource === resourceProfileForClass(work.classId).type) {
|
|
work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "pull") {
|
|
const playerPosition = useCombatStore.getState().playerPosition;
|
|
for (const mobId of mobTargets) {
|
|
const position = getMobPosition(mobId);
|
|
if (!position || effect.toward !== "caster") continue;
|
|
const dx = position[0] - playerPosition[0];
|
|
const dz = position[2] - playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(mobId, [
|
|
playerPosition[0] + dx / length * 1.5,
|
|
position[1],
|
|
playerPosition[2] + dz / length * 1.5,
|
|
]);
|
|
}
|
|
} else if (effect.kind === "dispel") {
|
|
const recipient = targetId ?? sourceId;
|
|
const targeted = work.auras.filter((aura) => aura.targetId === recipient);
|
|
const untouched = work.auras.filter((aura) => aura.targetId !== recipient);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship: effect.relationship === "target"
|
|
? work.mobs[recipient] ? "hostile" : "friendly"
|
|
: effect.relationship,
|
|
categories: effect.categories,
|
|
maxCount: effect.maxCount,
|
|
});
|
|
work.auras = [...untouched, ...result.auras];
|
|
} else if (effect.kind === "execute") {
|
|
for (const mobId of mobTargets) {
|
|
const mob = work.mobs[mobId];
|
|
if (mob) damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
mobId,
|
|
Math.max(mob.health, mob.maxHealth * effect.coefficient),
|
|
now,
|
|
source,
|
|
school,
|
|
);
|
|
}
|
|
} else if (effect.kind === "threat") {
|
|
for (const mobId of mobTargets) {
|
|
const mob = work.mobs[mobId];
|
|
if (!mob) continue;
|
|
const current = mob.threatByActor[source.actorId] ?? 0;
|
|
work.mobs[mobId] = {
|
|
...mob,
|
|
threatByActor: {
|
|
...mob.threatByActor,
|
|
[source.actorId]: effect.mode === "percent"
|
|
? current * Math.max(0, 1 + effect.amount / 100)
|
|
: effect.mode === "redirect" ? 0 : Math.max(0, current + effect.amount),
|
|
},
|
|
};
|
|
}
|
|
} else if (effect.kind === "cooldown-reset") {
|
|
const candidates = effect.abilityName
|
|
? work.abilities.filter((candidate) => candidate.name.toLowerCase() === effect.abilityName!.toLowerCase())
|
|
: work.abilities;
|
|
for (const candidate of candidates) {
|
|
if (effect.mode === "reset") work.cooldowns[candidate.id] = 0;
|
|
else {
|
|
const readyAt = work.cooldowns[candidate.id] ?? now;
|
|
const reduction = effect.amountPercent !== undefined
|
|
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent))
|
|
: effect.amountMs ?? 0;
|
|
work.cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
|
|
}
|
|
}
|
|
} else if (effect.kind === "trigger-spell" && depth < 1 && effect.spellId !== spellId) {
|
|
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1, allowCoaHidden);
|
|
}
|
|
}
|
|
}
|
|
|
|
function timedEffect(
|
|
kind: "dot" | "hot",
|
|
abilityId: string,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string | null,
|
|
scaling: AbilityAmountScaling & { readonly coefficient: number },
|
|
ticks: number,
|
|
intervalMs: number,
|
|
now: number,
|
|
tag?: string,
|
|
damageSchool?: DamageSchool,
|
|
spellPenetration?: number,
|
|
): TimedCombatEffect {
|
|
effectSequence += 1;
|
|
return {
|
|
id: `${abilityId}:${effectSequence}`,
|
|
kind,
|
|
sourceAbilityId: abilityId,
|
|
sourceActorId,
|
|
targetId,
|
|
coefficient: scaling.coefficient,
|
|
...(scaling.basePoints === undefined ? {} : { basePoints: scaling.basePoints }),
|
|
...(scaling.dieSides === undefined ? {} : { dieSides: scaling.dieSides }),
|
|
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
|
|
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
|
|
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
|
|
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
|
|
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
|
|
remainingTicks: ticks,
|
|
intervalMs,
|
|
nextTickAt: now + intervalMs,
|
|
...(tag ? { tag } : {}),
|
|
...(damageSchool ? { damageSchool } : {}),
|
|
...(spellPenetration ? { spellPenetration } : {}),
|
|
};
|
|
}
|
|
|
|
function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number {
|
|
return aurasForEntity(auras, entityId).reduce((total, aura) => (
|
|
total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0)
|
|
), 0);
|
|
}
|
|
|
|
function ensureCompanionActorInWork(work: MutableCastWork, id: string): CombatActorState | null {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (!member) return null;
|
|
const existing = work.actors[id];
|
|
const actor = companionActorFromMember(member, entityAbsorbAmount(work.auras, id), existing);
|
|
work.actors[id] = actor;
|
|
return actor;
|
|
}
|
|
|
|
function mirrorCompanionActor(actor: CombatActorState): void {
|
|
if (actor.kind !== "companion") return;
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actor.id);
|
|
if (!member) return;
|
|
const mirrored = memberFromCompanionActor(member, actor);
|
|
usePartyStore.getState().updateMemberCombat(actor.id, {
|
|
health: mirrored.health,
|
|
maxHealth: mirrored.maxHealth,
|
|
resource: mirrored.resource,
|
|
maxResource: mirrored.maxResource,
|
|
resourcePools: mirrored.resourcePools,
|
|
lastResourceSpentAt: mirrored.lastResourceSpentAt,
|
|
cooldowns: mirrored.cooldowns,
|
|
globalCooldownEndsAt: mirrored.globalCooldownEndsAt,
|
|
activeCast: mirrored.activeCast,
|
|
activeCastTargetId: mirrored.activeCastTargetId,
|
|
schoolLockedUntil: mirrored.schoolLockedUntil,
|
|
runes: mirrored.runes,
|
|
controlledUntil: mirrored.controlledUntil,
|
|
status: mirrored.status,
|
|
});
|
|
}
|
|
|
|
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (!actor || actor.health <= 0 || requested <= 0) return 0;
|
|
const healed = Math.min(requested, actor.maxHealth - actor.health);
|
|
if (healed <= 0) return 0;
|
|
const next = Object.freeze({ ...actor, health: actor.health + healed, alive: true });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
return healed;
|
|
}
|
|
|
|
function reviveCompanionActorInWork(work: MutableCastWork, id: string, percentMaxHealth: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
if (!actor || actor.health > 0) return 0;
|
|
const health = Math.max(1, Math.round(actor.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth))));
|
|
const next = Object.freeze({ ...actor, health, alive: true, controlledUntil: 0, cast: null });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, status: "ready", controlMechanic: null }
|
|
: member),
|
|
}));
|
|
return health;
|
|
}
|
|
|
|
function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (!actor || actor.health <= 0 || requested <= 0) return 0;
|
|
const applied = Math.min(actor.health, requested);
|
|
const health = actor.health - applied;
|
|
const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
if (health <= 0) {
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, status: "down", activeCast: null, activeCastTargetId: null }
|
|
: member),
|
|
}));
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
function clearCompanionControlInWork(work: MutableCastWork, id: string): boolean {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
if (!actor || actor.controlledUntil <= 0) return false;
|
|
const next = Object.freeze({ ...actor, controlledUntil: 0 });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, controlMechanic: null }
|
|
: member),
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
function combatActorSnapshots(state: CombatState): Readonly<Record<string, CombatActorState>> {
|
|
const actors: Record<string, CombatActorState> = {};
|
|
const resourceProfile = resourceProfileForClass(state.classId);
|
|
const playerResources = Object.fromEntries(
|
|
Object.entries(state.maxResourcePools).map(([id, maximum]) => [id, normalizeActorResourcePool(
|
|
id,
|
|
state.resourcePools[id as ResourceType] ?? 0,
|
|
maximum ?? 0,
|
|
id === resourceProfile.type ? resourceProfile.regenerationPerSecond : 0,
|
|
state.lastResourceSpentAt[id as ResourceType] ?? 0,
|
|
)]),
|
|
);
|
|
actors[PLAYER_AURA_ENTITY_ID] = {
|
|
id: PLAYER_AURA_ENTITY_ID,
|
|
threatActorId: PLAYER_AURA_ENTITY_ID,
|
|
kind: "player",
|
|
classId: state.classId,
|
|
level: state.level,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
absorb: state.shield + entityAbsorbAmount(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
resources: playerResources,
|
|
cooldowns: state.cooldowns,
|
|
globalCooldownEndsAt: state.globalCooldownEndsAt,
|
|
cast: state.activeCast ? {
|
|
abilityId: state.activeCast.abilityId,
|
|
school: state.activeCast.school,
|
|
mode: state.activeCast.mode,
|
|
startedAt: state.activeCast.startedAt,
|
|
completesAt: state.activeCast.completesAt,
|
|
originalDurationMs: state.activeCast.originalDurationMs,
|
|
pushbackHits: state.activeCast.pushbackHits,
|
|
} : null,
|
|
controlledUntil: state.controlledUntil,
|
|
schoolLockedUntil: state.schoolLockedUntil,
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
runes: state.deathKnightRunes,
|
|
alive: state.health > 0,
|
|
};
|
|
for (const member of usePartyStore.getState().members) {
|
|
actors[member.id] = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, member.id),
|
|
state.actors[member.id],
|
|
);
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) actors[actor.id] = actor;
|
|
for (const mob of Object.values(state.mobs)) {
|
|
actors[mob.id] = {
|
|
id: mob.id,
|
|
threatActorId: mob.id,
|
|
kind: "enemy",
|
|
level: mob.level,
|
|
health: mob.health,
|
|
maxHealth: mob.maxHealth,
|
|
absorb: entityAbsorbAmount(state.auras, mob.id),
|
|
resources: mob.maxMana > 0
|
|
? { mana: normalizeActorResourcePool("mana", mob.maxMana, mob.maxMana) }
|
|
: {},
|
|
cooldowns: mob.abilityReadyAt,
|
|
globalCooldownEndsAt: 0,
|
|
cast: mob.activeCast ? {
|
|
abilityId: mob.activeCast.attackId,
|
|
school: mob.activeCast.school,
|
|
mode: "cast",
|
|
startedAt: mob.activeCast.startedAt,
|
|
completesAt: mob.activeCast.completesAt,
|
|
originalDurationMs: mob.activeCast.completesAt - mob.activeCast.startedAt,
|
|
pushbackHits: 0,
|
|
} : null,
|
|
controlledUntil: Math.max(0, ...mob.statuses.map((status) => status.endsAt)),
|
|
schoolLockedUntil: mob.schoolLockedUntil,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
alive: !mob.dead,
|
|
};
|
|
}
|
|
for (const summon of state.summons) {
|
|
actors[summon.id] = combatActorFromSummon(summon, state.level, state.auras);
|
|
}
|
|
return Object.freeze(actors);
|
|
}
|
|
|
|
function initialState() {
|
|
const classId: ClassId = "priest";
|
|
const raceId: RaceId = "human";
|
|
const level = 1;
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks: TalentRanks = {};
|
|
const talentModifiers = characterStatModifiers(classId, level, talentRanks);
|
|
const maxHealth = talentAdjustedMaxHealth(classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
level,
|
|
talentModifiers,
|
|
EMPTY_ITEM_STATS,
|
|
raceId,
|
|
);
|
|
const abilities = abilitiesForClass(classId);
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
|
return {
|
|
classId,
|
|
specializationId: null,
|
|
gearRole: "healer" as PartyRole,
|
|
secondaryClassId: null,
|
|
raceId,
|
|
level,
|
|
xp: 0,
|
|
xpToNext: xpRequiredForNextLevel(level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
lastResourceSpentAt: {},
|
|
resourceName: resource.name,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
deathKnightRunes: [],
|
|
abilities,
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
playerAnimationEvent: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs: {},
|
|
selectedTargetId: null,
|
|
selectedFriendlyActorId: null,
|
|
effects: [],
|
|
auras: [],
|
|
summons: [],
|
|
areaEffects: [],
|
|
pendingGroundTarget: null,
|
|
actors: {},
|
|
combatEvents: [],
|
|
feedback: null,
|
|
lastCombatResult: null,
|
|
bossLootRewards: [] as readonly BossLootReward[],
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
|
|
inventory: [] as readonly InventoryItem[],
|
|
equipment: {} as EquipmentAssignments,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
settings: DEFAULT_GAMEPLAY_SETTINGS,
|
|
playerPosition: [0, 0, 0] as CombatPosition,
|
|
};
|
|
}
|
|
|
|
export const useCombatStore = create<CombatState>((set, get) => ({
|
|
...initialState(),
|
|
|
|
initializeCharacter: (profile) => {
|
|
resetCombatPresentationSession();
|
|
const classId = profile.classId;
|
|
const specialization = specializationById(profile.specializationId);
|
|
const specializationId = specialization?.classId === classId ? specialization.id : null;
|
|
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
|
|
const secondaryClassId = profile.secondaryClassId ?? null;
|
|
const raceId = profile.raceId ?? "human";
|
|
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
|
|
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
|
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
|
const inventory = normalizeInventory(profile.inventory);
|
|
const equipment = normalizeEquipment(profile.equipment, inventory);
|
|
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId,
|
|
specializationId,
|
|
role: gearRole,
|
|
level: progression.level,
|
|
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
|
set((state) => ({
|
|
...initialState(),
|
|
classId,
|
|
secondaryClassId,
|
|
raceId,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: xpRequiredForNextLevel(progression.level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
resourceName: resource.name,
|
|
abilities,
|
|
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
|
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
|
inventory,
|
|
equipment,
|
|
gearStats,
|
|
settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}),
|
|
playerPosition: state.playerPosition,
|
|
}));
|
|
},
|
|
|
|
refreshResolvedGear: () => set((state) => {
|
|
const context: HeirloomContext = {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
};
|
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
|
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
|
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
|
|
return {
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
|
|
};
|
|
}),
|
|
|
|
registerMob: (id, registration) => set((state) => {
|
|
if (!id) return state;
|
|
const existing = state.mobs[id];
|
|
const boss = registration.boss ?? existing?.boss ?? false;
|
|
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
|
|
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
|
|
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
|
|
const resistances: MobMagicResistances = {
|
|
arcane: Math.max(0, registration.resistances?.arcane ?? existing?.resistances.arcane ?? 0),
|
|
fire: Math.max(0, registration.resistances?.fire ?? existing?.resistances.fire ?? 0),
|
|
frost: Math.max(0, registration.resistances?.frost ?? existing?.resistances.frost ?? 0),
|
|
nature: Math.max(0, registration.resistances?.nature ?? existing?.resistances.nature ?? 0),
|
|
shadow: Math.max(0, registration.resistances?.shadow ?? existing?.resistances.shadow ?? 0),
|
|
};
|
|
const defaults = defaultMobStats(level, boss, serverEntry ?? undefined);
|
|
const mechanicImmunities = registration.mechanicImmunities
|
|
?? existing?.mechanicImmunities
|
|
?? defaults.mechanicImmunities;
|
|
const schoolImmunities = registration.schoolImmunities
|
|
?? existing?.schoolImmunities
|
|
?? defaults.schoolImmunities;
|
|
const healthMultiplier = Math.max(0.01, registration.healthMultiplier ?? existing?.healthMultiplier ?? 1);
|
|
const damageMultiplier = Math.max(0.01, registration.damageMultiplier ?? existing?.damageMultiplier ?? 1);
|
|
const bonusLootChance = clamp(
|
|
registration.bonusLootChance ?? existing?.bonusLootChance ?? 0,
|
|
0,
|
|
1,
|
|
);
|
|
const maxHealth = Math.max(1, Math.round(
|
|
registration.maxHealth
|
|
?? existing?.maxHealth
|
|
?? defaults.maxHealth * healthMultiplier,
|
|
));
|
|
const xpReward = Math.max(0, Math.round(registration.xpReward ?? existing?.xpReward ?? defaults.xpReward));
|
|
const hasLoot = registration.hasLoot ?? existing?.hasLoot ?? false;
|
|
const fallbackAttack = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
range: boss ? 5.2 : 4.3,
|
|
cooldownMs: defaults.swingMs,
|
|
};
|
|
const attacks = registration.attacks
|
|
? normalizeEnemyAttacks(registration.attacks, fallbackAttack)
|
|
: existing?.attacks ?? normalizeEnemyAttacks(undefined, fallbackAttack);
|
|
const attackRange = maximumEnemyAttackRange(attacks);
|
|
const swingMs = defaults.swingMs;
|
|
const leashRange = Math.max(
|
|
attackRange + 1,
|
|
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
|
|
);
|
|
const aggroRange = Math.max(
|
|
0,
|
|
Math.min(
|
|
leashRange,
|
|
registration.aggroRange
|
|
?? existing?.aggroRange
|
|
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
|
|
),
|
|
);
|
|
const moveSpeed = Math.max(
|
|
0,
|
|
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
|
);
|
|
const attackDamage = existing && registration.damageMultiplier !== undefined
|
|
? Math.max(1, Math.round(
|
|
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
|
))
|
|
: existing?.attackDamage ?? Math.max(1, Math.round(
|
|
defaults.attackDamage * damageMultiplier,
|
|
));
|
|
const mob: MobCombatState = existing
|
|
? {
|
|
...existing,
|
|
name: registration.name || existing.name,
|
|
serverEntry,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
attacks,
|
|
attackRange,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackDamage,
|
|
health: Math.min(existing.health, maxHealth),
|
|
xpReward,
|
|
hasLoot,
|
|
}
|
|
: {
|
|
id,
|
|
name: registration.name || "Unknown Creature",
|
|
serverEntry,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
health: maxHealth,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
dead: false,
|
|
hasLoot,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle",
|
|
homePosition: getMobPosition(id),
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange,
|
|
attackDamage,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks,
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
return { mobs: { ...state.mobs, [id]: mob } };
|
|
}),
|
|
|
|
removeMob: (id) => set((state) => {
|
|
if (!state.mobs[id]) return state;
|
|
const mobs = { ...state.mobs };
|
|
delete mobs[id];
|
|
return {
|
|
mobs,
|
|
selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
effects: state.effects.filter((effect) => effect.targetId !== id),
|
|
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
|
};
|
|
}),
|
|
|
|
lootMob: (id, now = combatNow()) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
|
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
|
const lootMessage = mob.pendingLoot.length
|
|
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
|
: `Looted ${mob.name}.`;
|
|
set((state) => {
|
|
const bossReward = mob.boss
|
|
? {
|
|
id: ++bossLootRewardSequence,
|
|
bossId: id,
|
|
bossName: mob.name,
|
|
items: mob.pendingLoot,
|
|
acquiredAt: now,
|
|
} satisfies BossLootReward
|
|
: null;
|
|
return {
|
|
inventory: [...state.inventory, ...mob.pendingLoot],
|
|
mobs: {
|
|
...state.mobs,
|
|
[id]: {
|
|
...state.mobs[id],
|
|
looted: true,
|
|
corpseDespawnAt,
|
|
despawned: now >= corpseDespawnAt,
|
|
},
|
|
},
|
|
...(bossReward
|
|
? { bossLootRewards: [...state.bossLootRewards, bossReward] }
|
|
: { feedback: nextFeedback("loot", lootMessage, { targetId: id }) }),
|
|
};
|
|
});
|
|
return true;
|
|
},
|
|
|
|
dismissBossLootReward: (id) => set((state) => ({
|
|
bossLootRewards: id === undefined
|
|
? state.bossLootRewards.slice(1)
|
|
: state.bossLootRewards.filter((reward) => reward.id !== id),
|
|
})),
|
|
|
|
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
|
|
const state = get();
|
|
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? state.classId
|
|
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.classId;
|
|
if (!ownerClassId) {
|
|
set({ feedback: nextFeedback("error", "That party member is not available.") });
|
|
return false;
|
|
}
|
|
const change = equipInventoryItem(
|
|
state.equipment,
|
|
state.inventory,
|
|
ownerId,
|
|
ownerClassId,
|
|
instanceId,
|
|
);
|
|
if (!change.ok) {
|
|
set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") });
|
|
return false;
|
|
}
|
|
const item = state.inventory.find((candidate) => candidate.instanceId === instanceId);
|
|
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? "You"
|
|
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: change.assignments,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
|
|
});
|
|
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
unequipItem: (ownerId, slot) => {
|
|
const state = get();
|
|
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
|
if (next === state.equipment) return false;
|
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: next,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", "Item returned to the shared inventory."),
|
|
});
|
|
usePartyStore.getState().syncEquipment(next, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
selectMob: (id) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || mob.dead) return false;
|
|
set((state) => ({
|
|
selectedTargetId: id,
|
|
autoAttackTargetId: state.autoAttackTargetId ? id : null,
|
|
nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id
|
|
? 0
|
|
: state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? state.comboPoints : 0,
|
|
comboTargetId: state.selectedTargetId === id ? state.comboTargetId : id,
|
|
feedback: nextFeedback("target", mob.name, { targetId: id }),
|
|
}));
|
|
return true;
|
|
},
|
|
|
|
selectNearestTarget: (origin, maxRange = 40) => {
|
|
const state = get();
|
|
const id = eligibleTargetIds(state, currentOrigin(state, origin), maxRange)[0] ?? null;
|
|
if (id) state.selectMob(id);
|
|
else state.clearTarget();
|
|
return id;
|
|
},
|
|
|
|
cycleTarget: (direction = "next", origin, maxRange = 40) => {
|
|
const state = get();
|
|
const ids = eligibleTargetIds(state, currentOrigin(state, origin), maxRange);
|
|
if (!ids.length) {
|
|
state.clearTarget();
|
|
return null;
|
|
}
|
|
const step = direction === "previous" || direction === -1 ? -1 : 1;
|
|
const currentIndex = state.selectedTargetId ? ids.indexOf(state.selectedTargetId) : -1;
|
|
const nextIndex = currentIndex < 0
|
|
? (step > 0 ? 0 : ids.length - 1)
|
|
: (currentIndex + step + ids.length) % ids.length;
|
|
const id = ids[nextIndex];
|
|
state.selectMob(id);
|
|
return id;
|
|
},
|
|
|
|
clearTarget: () => set((state) => ({
|
|
selectedTargetId: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
feedback: state.selectedTargetId
|
|
? nextFeedback("target", "Target cleared.")
|
|
: state.feedback,
|
|
})),
|
|
|
|
selectFriendlyActor: (id) => {
|
|
if (id === null) {
|
|
set({ selectedFriendlyActorId: null });
|
|
return true;
|
|
}
|
|
const state = get();
|
|
const actor = state.actors[id];
|
|
if (!actor || !["player", "companion", "pet", "totem", "summon"].includes(actor.kind)) return false;
|
|
set({
|
|
selectedFriendlyActorId: id,
|
|
feedback: nextFeedback("target", `Friendly target selected: ${state.summons.find((summon) => summon.id === id)?.name ?? id}.`, { targetId: id }),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
setPlayerPosition: (position) => set((state) => {
|
|
const next: CombatPosition = [position[0], position[1], position[2]];
|
|
const cast = state.activeCast;
|
|
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
|
|
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
|
));
|
|
const positionUnchanged = (
|
|
(next[0] - state.playerPosition[0]) ** 2
|
|
+ (next[1] - state.playerPosition[1]) ** 2
|
|
+ (next[2] - state.playerPosition[2]) ** 2
|
|
) <= 1e-8;
|
|
if (positionUnchanged && !movedWhileCasting) return state;
|
|
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
|
|
return {
|
|
playerPosition: next,
|
|
activeCast: movedWhileCasting ? null : cast,
|
|
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "interrupt",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: cast!.abilityId,
|
|
school: cast!.school,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "movement",
|
|
}) : state.combatEvents,
|
|
playerAnimationEvent: movedWhileCasting
|
|
? nextAnimationEvent("ability-cancel", cast!.abilityId)
|
|
: state.playerAnimationEvent,
|
|
feedback: movedWhileCasting
|
|
? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId })
|
|
: state.feedback,
|
|
};
|
|
}),
|
|
|
|
beginGroundTarget: (abilityId) => {
|
|
const ability = abilityById(abilityId);
|
|
if (!ability || targetingRulesForAbility(ability).shape !== "ground") return false;
|
|
const state = get();
|
|
const targeting = targetingRulesForAbility(ability);
|
|
const maximumRange = targeting.groundRange ?? ability.range.max;
|
|
const player = combatSpatialActor(PLAYER_AURA_ENTITY_ID);
|
|
const yaw = player?.yaw ?? 0;
|
|
const distance = Math.min(10, Math.max(0, maximumRange));
|
|
const requested: CombatPosition = [
|
|
state.playerPosition[0] + Math.sin(yaw) * distance,
|
|
state.playerPosition[1],
|
|
state.playerPosition[2] + Math.cos(yaw) * distance,
|
|
];
|
|
const projected = projectCombatGroundPoint(requested, maximumRange, state.playerPosition);
|
|
set({
|
|
pendingGroundTarget: {
|
|
abilityId,
|
|
maximumRange,
|
|
radius: targeting.radius ?? ability.radius ?? 4,
|
|
position: projected ?? requested,
|
|
valid: projected !== null && (!targeting.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)),
|
|
},
|
|
feedback: nextFeedback("target", `Choose a location for ${ability.name}.`, { abilityId }),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
moveGroundTarget: (moveX, moveForward, yaw, deltaSeconds) => set((state) => {
|
|
const pending = state.pendingGroundTarget;
|
|
if (!pending) return state;
|
|
const magnitude = Math.hypot(moveX, moveForward);
|
|
if (magnitude < 0.05 || !Number.isFinite(deltaSeconds)) return state;
|
|
const normalizedX = moveX / Math.max(1, magnitude);
|
|
const normalizedForward = moveForward / Math.max(1, magnitude);
|
|
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
|
|
const worldX = -normalizedX * Math.cos(safeYaw) + normalizedForward * Math.sin(safeYaw);
|
|
const worldZ = normalizedX * Math.sin(safeYaw) + normalizedForward * Math.cos(safeYaw);
|
|
const speed = 10;
|
|
const requested: CombatPosition = [
|
|
pending.position[0] + worldX * speed * clamp(deltaSeconds, 0, 0.1),
|
|
pending.position[1],
|
|
pending.position[2] + worldZ * speed * clamp(deltaSeconds, 0, 0.1),
|
|
];
|
|
const projected = projectCombatGroundPoint(requested, pending.maximumRange, state.playerPosition);
|
|
const ability = abilityById(pending.abilityId);
|
|
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
|
const valid = Boolean(projected
|
|
&& Math.sqrt(distanceSquared(state.playerPosition, projected)) <= pending.maximumRange
|
|
&& (!targeting?.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)));
|
|
return {
|
|
pendingGroundTarget: {
|
|
...pending,
|
|
position: projected ?? pending.position,
|
|
valid,
|
|
},
|
|
};
|
|
}),
|
|
|
|
confirmGroundTarget: (position, now = combatNow()) => {
|
|
const pending = get().pendingGroundTarget;
|
|
if (!pending) return emptyCastResult("invalid-destination");
|
|
const destination = position ?? pending.position;
|
|
if (!pending.valid && position === undefined) return emptyCastResult("invalid-destination", pending.abilityId);
|
|
set({ pendingGroundTarget: null });
|
|
return get().castAbility(pending.abilityId, { origin: get().playerPosition, groundPosition: destination, now });
|
|
},
|
|
|
|
cancelGroundTarget: () => set({ pendingGroundTarget: null }),
|
|
|
|
synchronizeActors: () => {
|
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
|
set((state) => {
|
|
actors = combatActorSnapshots(state);
|
|
return { actors };
|
|
});
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
|
|
}));
|
|
},
|
|
commitCompanionActors: (members) => {
|
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
|
set((state) => {
|
|
const next = { ...state.actors };
|
|
for (const member of members) {
|
|
const existing = next[member.id] ?? companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, member.id),
|
|
);
|
|
next[member.id] = companionActorWithMemberRuntime(existing, member);
|
|
}
|
|
actors = Object.freeze(next);
|
|
return { actors };
|
|
});
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => {
|
|
const runtime = members.find((candidate) => candidate.id === member.id) ?? member;
|
|
return memberFromCompanionActor(runtime, actors[member.id]);
|
|
}),
|
|
}));
|
|
},
|
|
|
|
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
|
|
const request: CastRequest = Array.isArray(requestOrOrigin)
|
|
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
|
|
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
|
|
const origin = request.origin;
|
|
const now = request.now ?? legacyNow;
|
|
const completingCast = request.completingCast ?? legacyCompletingCast;
|
|
const state = get();
|
|
const party = usePartyStore.getState();
|
|
const catalogAbility = abilityById(abilityId);
|
|
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
|
? undefined
|
|
: state.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
const ability = catalogAbility
|
|
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
|
: null;
|
|
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
|
const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag));
|
|
const holyAuraFriendly = Boolean(
|
|
ability?.source === "runewaker"
|
|
&& ability.name === "Holy Aura"
|
|
&& party.selectedMemberId
|
|
&& hasRuneWakerState("rom-holy-salvation-candle")
|
|
);
|
|
const effectiveTarget = holyAuraFriendly
|
|
? "friendly"
|
|
: ability?.target === "any"
|
|
? completingCast
|
|
? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly"
|
|
: state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile"
|
|
: ability?.target;
|
|
const modifiers = state.talentModifiers;
|
|
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
|
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
|
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
|
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
|
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
|
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
|
? runeCostForAbility(ability)
|
|
: null;
|
|
let failure: CastFailureReason | undefined;
|
|
if (!ability) failure = "unknown-ability";
|
|
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
|
|
else if (ability.talentEntryId !== undefined && !learnedTalentRank) failure = "not-learned";
|
|
else if (ability.passive) failure = "passive";
|
|
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
|
|
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
|
|
else if (state.health <= 0) failure = "player-dead";
|
|
else if (state.controlledUntil > now) failure = "player-controlled";
|
|
else if (
|
|
effectiveTarget === "hostile"
|
|
&& abilityAttackKind(state.classId, ability) === "melee"
|
|
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
|
) failure = "player-controlled";
|
|
else if (state.activeCast && !completingCast) failure = "already-casting";
|
|
else if (!completingCast && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
|
else if (!completingCast && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
|
|
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
|
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
|
|
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
|
else if (costs.some((cost) => cost.resource === "health"
|
|
? state.health <= cost.amount
|
|
: (cost.resource === resourceProfileForClass(state.classId).type
|
|
? state.resource
|
|
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
|
|
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
|
|
|
|
const playerOrigin = currentOrigin(state, origin);
|
|
// `null` is a meaningful snapshotted target for friendly casts: it means
|
|
// the player. Do not fall through to a newly selected ally on completion.
|
|
let targetId = completingCast
|
|
? completingCast.targetId
|
|
: request.targetActorId !== undefined
|
|
? request.targetActorId
|
|
: effectiveTarget === "hostile"
|
|
? state.selectedTargetId
|
|
: effectiveTarget === "friendly"
|
|
? state.selectedFriendlyActorId ?? party.selectedMemberId
|
|
: null;
|
|
let targetPosition: CombatPosition | null = null;
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.shape !== "ground") {
|
|
let target = targetId ? state.mobs[targetId] : null;
|
|
if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) {
|
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
|
const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers);
|
|
const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin));
|
|
return distance >= minimumRange;
|
|
});
|
|
targetId = eligible ?? null;
|
|
target = targetId ? state.mobs[targetId] : null;
|
|
}
|
|
if (!targetId || !target) failure = "no-target";
|
|
else if (target.dead) failure = "target-dead";
|
|
else {
|
|
targetPosition = getMobPosition(targetId);
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
} else if (!failure && ability && effectiveTarget === "friendly" && targetId) {
|
|
const partyTarget = party.members.find((member) => member.id === targetId);
|
|
const target = state.actors[targetId]
|
|
?? (partyTarget ? companionActorFromMember(partyTarget, entityAbsorbAmount(state.auras, targetId)) : null);
|
|
if (!target || !["companion", "pet", "totem", "summon"].includes(target.kind)) failure = "target-unavailable";
|
|
else if (!target.alive && !ability.effects.some((effect) => effect.kind === "resurrection")) {
|
|
failure = "target-dead";
|
|
}
|
|
else {
|
|
targetPosition = getPartyRuntimePosition(targetId)
|
|
?? state.summons.find((summon) => summon.id === targetId)?.position
|
|
?? null;
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(distanceSquared(playerOrigin, targetPosition));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!failure && ability && targeting?.requiresLineOfSight && targetPosition) {
|
|
if (!combatHasLineOfSight(playerOrigin, targetPosition)) failure = "line-of-sight";
|
|
}
|
|
const playerSpatial = combatSpatialActor(PLAYER_AGGRO_ID);
|
|
const targetSpatial = targetId ? combatSpatialActor(targetId) : null;
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresFacing && playerSpatial && targetPosition) {
|
|
if (!isPositionInFacingArc(playerSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) {
|
|
failure = "not-facing";
|
|
}
|
|
}
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresBehindTarget && targetSpatial) {
|
|
if (!isSourceBehindTarget(playerOrigin, targetSpatial)) failure = "must-be-behind";
|
|
}
|
|
let groundPosition: CombatPosition | null = completingCast?.groundPosition ?? null;
|
|
if (!failure && ability && targeting?.shape === "ground") {
|
|
const requestedGround = request.groundPosition ?? targetPosition ?? playerOrigin;
|
|
const maximumGroundRange = talentAdjustedRange(targeting.groundRange ?? ability.range.max, ability, modifiers);
|
|
const projected = projectCombatGroundPoint(requestedGround, maximumGroundRange, playerOrigin);
|
|
if (!projected || Math.sqrt(distanceSquared(playerOrigin, projected)) > maximumGroundRange) failure = "invalid-destination";
|
|
else if (targeting.requiresLineOfSight && !combatHasLineOfSight(playerOrigin, projected)) failure = "line-of-sight";
|
|
else groundPosition = [projected[0], projected[1], projected[2]];
|
|
}
|
|
|
|
const autoAttackTarget = targetId ? state.mobs[targetId] : null;
|
|
const startsAutoAttack = Boolean(
|
|
ability
|
|
&& effectiveTarget === "hostile"
|
|
&& abilityAttackKind(state.classId, ability) === "melee"
|
|
&& targetId
|
|
&& autoAttackTarget
|
|
&& !autoAttackTarget.dead
|
|
&& state.abilities.some((entry) => entry.id === ability.id)
|
|
&& !ability.passive
|
|
&& isAbilityUnlocked(ability, state.level)
|
|
&& state.health > 0,
|
|
);
|
|
const autoAttackUpdate = startsAutoAttack && targetId
|
|
? {
|
|
autoAttackTargetId: targetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now,
|
|
}
|
|
: {};
|
|
|
|
if (failure || !ability) {
|
|
const reason = failure ?? "unknown-ability";
|
|
set({
|
|
feedback: nextFeedback("error", castFailureMessage(reason, state.resourceName, ability?.unlockLevel), { abilityId }),
|
|
...autoAttackUpdate,
|
|
...(completingCast
|
|
? { activeCast: null, playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
|
|
: {}),
|
|
});
|
|
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
|
|
return emptyCastResult(reason, abilityId);
|
|
}
|
|
|
|
let affectedMobIds: string[] = [];
|
|
if (targeting?.shape === "chain" && effectiveTarget === "hostile" && targetId && targetPosition) {
|
|
let previous = targetPosition;
|
|
const ordered = [targetId, ...Object.keys(state.mobs).filter((id) => id !== targetId).sort()];
|
|
for (const id of ordered) {
|
|
if (affectedMobIds.length >= (targeting.maximumTargets ?? 3)) break;
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
if (!mob || mob.dead || !position) continue;
|
|
if (affectedMobIds.length > 0 && Math.sqrt(distanceSquared(previous, position)) > (targeting.jumpRange ?? 12)) continue;
|
|
if (!combatHasLineOfSight(previous, position)) continue;
|
|
affectedMobIds.push(id);
|
|
previous = position;
|
|
}
|
|
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
|
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
|
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
return Boolean(mob && !mob.dead && position
|
|
&& combatHasLineOfSight(playerOrigin, position)
|
|
&& (!playerSpatial || isPositionInFacingArc(playerSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
|
});
|
|
} else if (ability.radius !== undefined || targeting?.shape === "ground") {
|
|
const center = groundPosition ?? (effectiveTarget === "hostile" ? targetPosition! : playerOrigin);
|
|
affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(targeting?.radius ?? ability.radius ?? 0, ability, modifiers)).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(center, position));
|
|
});
|
|
} else if (effectiveTarget === "hostile" && targetId) {
|
|
affectedMobIds = [targetId];
|
|
}
|
|
|
|
const castTimeMs = talentAdjustedCastTime(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const gcdMs = talentAdjustedGcd(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
const spentPools: Partial<Record<ResourceType, number>> = {
|
|
...state.resourcePools,
|
|
[activeResourceType]: state.resource,
|
|
};
|
|
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
|
|
let healthAfterCosts = state.health;
|
|
for (const cost of costsToSpend) {
|
|
if (cost.resource === "health") healthAfterCosts = Math.max(1, healthAfterCosts - cost.amount);
|
|
else {
|
|
spentPools[cost.resource] = Math.max(0, (spentPools[cost.resource] ?? 0) - cost.amount);
|
|
if (cost.amount > 0) lastResourceSpentAt[cost.resource] = now;
|
|
}
|
|
}
|
|
const spentRunes = runeCost && completingCast?.mode !== "channel"
|
|
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
|
|
: state.deathKnightRunes;
|
|
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
|
if (!completingCast && castTimeMs > 0) {
|
|
castSequence += 1;
|
|
const activeCast: ActiveCast = {
|
|
id: castSequence,
|
|
abilityId: ability.id,
|
|
targetId,
|
|
mode: ability.castMode === "channel" ? "channel" : "cast",
|
|
startedAt: now,
|
|
completesAt: now + castTimeMs,
|
|
originalDurationMs: castTimeMs,
|
|
pushbackHits: 0,
|
|
school: spellSchool,
|
|
canCastWhileMoving: canAbilityCastWhileMoving(ability),
|
|
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
|
groundPosition,
|
|
totalTicks: ability.castMode === "channel" ? 5 : 0,
|
|
ticksCompleted: 0,
|
|
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / 5 : 0,
|
|
};
|
|
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
|
|
if (activeCast.mode === "channel" && cooldownMs > 0) {
|
|
channelCooldowns[ability.id] = now + cooldownMs;
|
|
}
|
|
set({
|
|
activeCast,
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
|
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
|
resource: activeCast.mode === "channel" ? activeResourceAfterCosts : state.resource,
|
|
resourcePools: activeCast.mode === "channel" ? spentPools : state.resourcePools,
|
|
lastResourceSpentAt: activeCast.mode === "channel" ? lastResourceSpentAt : state.lastResourceSpentAt,
|
|
deathKnightRunes: activeCast.mode === "channel" && spentRunes ? spentRunes : state.deathKnightRunes,
|
|
cooldowns: channelCooldowns,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "cast",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "started",
|
|
}),
|
|
globalCooldownEndsAt: now + gcdMs,
|
|
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
|
feedback: nextFeedback(
|
|
"success",
|
|
`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${ability.name}...`,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined },
|
|
),
|
|
});
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"cast-start",
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: healthAfterCosts,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: activeResourceAfterCosts,
|
|
maxResource: state.maxResource,
|
|
resourcePools: spentPools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt,
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: spentRunes ?? state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: modifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const castThreatSource = playerThreatSource(state, ability.id);
|
|
|
|
if (targeting?.shape === "ground" && groundPosition) {
|
|
const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot");
|
|
const durationMs = periodic.reduce((maximum, effect) => (
|
|
Math.max(maximum, effect.ticks * effect.intervalMs)
|
|
), 0);
|
|
if (durationMs > 0) {
|
|
areaEffectSequence += 1;
|
|
work.areaEffects.push({
|
|
id: `area-effect-${areaEffectSequence}`,
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
sourceAbilityId: ability.id,
|
|
position: groundPosition,
|
|
radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers),
|
|
createdAt: now,
|
|
expiresAt: now + durationMs,
|
|
tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)),
|
|
nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)),
|
|
});
|
|
}
|
|
}
|
|
|
|
const hasHostileEffect = (ability.target !== "any" || effectiveTarget === "hostile") && ability.effects.some((effect) => (
|
|
effect.kind === "damage"
|
|
|| effect.kind === "finisher-damage"
|
|
|| effect.kind === "dot"
|
|
|| effect.kind === "taunt"
|
|
|| effect.kind === "status"
|
|
|| (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")
|
|
|| effect.kind === "interrupt"
|
|
|| effect.kind === "pull"
|
|
|| effect.kind === "execute"
|
|
|| effect.kind === "resource-drain"
|
|
|| effect.kind === "threat"
|
|
));
|
|
if (hasHostileEffect) {
|
|
work.auras = work.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("stealth")
|
|
));
|
|
}
|
|
if (hasHostileEffect) {
|
|
for (const id of affectedMobIds) engageMobGroupInWork(work, id, now, castThreatSource.actorId);
|
|
}
|
|
|
|
const damageSchool = abilityDamageSchool(state.classId, ability);
|
|
const attackKind = abilityAttackKind(state.classId, ability);
|
|
let playerPositionAfterEffects: CombatPosition = state.playerPosition;
|
|
let clearedPlayerControl = false;
|
|
const cooldownAdjustments: Array<Extract<AbilityEffect, { kind: "cooldown-reset" }>> = [];
|
|
const effectsToResolve = completingCast?.mode === "channel"
|
|
? ability.effects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
|
: ability.effects;
|
|
for (const effect of effectsToResolve) {
|
|
if (
|
|
ability.target === "any"
|
|
&& effectiveTarget === "hostile"
|
|
&& ["heal", "hot", "shield", "resurrection"].includes(effect.kind)
|
|
) continue;
|
|
if (
|
|
effect.kind === "apply-aura"
|
|
&& ability.target === "any"
|
|
&& (
|
|
(effectiveTarget === "friendly" && effect.aura.disposition === "debuff")
|
|
|| (effectiveTarget === "hostile" && effect.aura.disposition === "buff" && effect.recipient !== "caster")
|
|
)
|
|
) continue;
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
|
ability,
|
|
);
|
|
}
|
|
} else if (effect.kind === "finisher-damage") {
|
|
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
coefficient,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:finisher`,
|
|
ability,
|
|
);
|
|
}
|
|
work.comboPoints = 0;
|
|
work.comboTargetId = null;
|
|
} else if (effect.kind === "heal") {
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const friendlySummons = work.summons.filter((summon) => summon.health > 0);
|
|
const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0);
|
|
const selectedFriendly = effectiveTarget === "friendly" && targetId
|
|
? partyMembers.find((member) => member.id === targetId)
|
|
: null;
|
|
const selectedFriendlySummon = effectiveTarget === "friendly" && targetId
|
|
? friendlySummons.find((summon) => summon.id === targetId)
|
|
: null;
|
|
const selectedOnlineFriendly = effectiveTarget === "friendly" && targetId
|
|
? onlineFriendlies.find((actor) => actor.id === targetId)
|
|
: null;
|
|
const allLivingFriendlies = [
|
|
PLAYER_AURA_ENTITY_ID,
|
|
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
|
|
...friendlySummons.map((summon) => summon.id),
|
|
...onlineFriendlies.map((actor) => actor.id),
|
|
];
|
|
const friendlyPosition = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID
|
|
? playerOrigin
|
|
: getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null;
|
|
const healingCenter = groundPosition ?? (targetId ? friendlyPosition(targetId) : null) ?? playerOrigin;
|
|
let healingTargetIds: string[];
|
|
if (targeting?.shape === "chain") {
|
|
const ordered = [...allLivingFriendlies].sort((left, right) => {
|
|
const selectedId = selectedFriendly?.id ?? selectedFriendlySummon?.id ?? PLAYER_AURA_ENTITY_ID;
|
|
if (left === selectedId) return -1;
|
|
if (right === selectedId) return 1;
|
|
const leftMember = partyMembers.find((member) => member.id === left);
|
|
const rightMember = partyMembers.find((member) => member.id === right);
|
|
const leftSummon = friendlySummons.find((summon) => summon.id === left);
|
|
const rightSummon = friendlySummons.find((summon) => summon.id === right);
|
|
const leftOnline = onlineFriendlies.find((actor) => actor.id === left);
|
|
const rightOnline = onlineFriendlies.find((actor) => actor.id === right);
|
|
const leftRatio = leftMember ? leftMember.health / leftMember.maxHealth
|
|
: leftSummon ? leftSummon.health / leftSummon.maxHealth
|
|
: leftOnline ? leftOnline.health / leftOnline.maxHealth
|
|
: work.health / work.maxHealth;
|
|
const rightRatio = rightMember ? rightMember.health / rightMember.maxHealth
|
|
: rightSummon ? rightSummon.health / rightSummon.maxHealth
|
|
: rightOnline ? rightOnline.health / rightOnline.maxHealth
|
|
: work.health / work.maxHealth;
|
|
return leftRatio - rightRatio || left.localeCompare(right);
|
|
});
|
|
healingTargetIds = [];
|
|
for (const candidate of ordered) {
|
|
if (healingTargetIds.length >= (targeting.maximumTargets ?? 3)) break;
|
|
const position = friendlyPosition(candidate);
|
|
const previousPosition = healingTargetIds.length
|
|
? friendlyPosition(healingTargetIds.at(-1)!)
|
|
: playerOrigin;
|
|
if (!position || !previousPosition) continue;
|
|
if (Math.sqrt(distanceSquared(previousPosition, position)) > (targeting.jumpRange ?? 12)) continue;
|
|
if (!combatHasLineOfSight(previousPosition, position)) continue;
|
|
healingTargetIds.push(candidate);
|
|
}
|
|
} else if ((targeting?.shape === "circle" || targeting?.shape === "ground") && effectiveTarget !== "hostile") {
|
|
const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers);
|
|
healingTargetIds = allLivingFriendlies.filter((id) => {
|
|
const position = friendlyPosition(id);
|
|
return Boolean(position
|
|
&& Math.sqrt(distanceSquared(healingCenter, position)) <= radius
|
|
&& combatHasLineOfSight(healingCenter, position));
|
|
});
|
|
} else {
|
|
healingTargetIds = [selectedFriendly?.id ?? selectedFriendlySummon?.id ?? selectedOnlineFriendly?.id ?? PLAYER_AURA_ENTITY_ID];
|
|
}
|
|
const casterStats = applyAuraStats(
|
|
playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers),
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
|
|
let directHealing = 0;
|
|
for (const healingTargetId of [...new Set(healingTargetIds)]) {
|
|
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
|
|
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
|
|
const friendlyOnline = onlineFriendlies.find((actor) => actor.id === healingTargetId);
|
|
const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? friendlySummon?.maxHealth ?? friendlyOnline?.maxHealth ?? work.maxHealth;
|
|
const healingTargetHealth = friendlyTarget?.health ?? friendlySummon?.health ?? friendlyOnline?.health ?? work.health;
|
|
const unmodifiedAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
unmodifiedAmount,
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, healingTargetId),
|
|
"taken",
|
|
));
|
|
const resolution = resolveHealing(
|
|
amount,
|
|
healingTargetHealth,
|
|
healingTargetMaxHealth,
|
|
criticalChance,
|
|
stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal`),
|
|
);
|
|
const healed = friendlyTarget
|
|
? healCompanionActorInWork(work, healingTargetId, resolution.rawAmount)
|
|
: friendlySummon
|
|
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
|
: friendlyOnline
|
|
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
|
: resolution.effectiveAmount;
|
|
if (friendlySummon && healed > 0) {
|
|
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
|
? { ...summon, health: summon.health + healed }
|
|
: summon);
|
|
const actor = work.actors[healingTargetId];
|
|
if (actor) work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
|
} else if (friendlyOnline && healed > 0) {
|
|
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
|
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
|
} else if (!friendlyTarget) {
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: healingTargetId,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: resolution.rawAmount,
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, resolution.rawAmount - healed),
|
|
absorbed: 0,
|
|
critical: resolution.critical,
|
|
});
|
|
directHealing += healed;
|
|
if (healed <= 0) continue;
|
|
evaluateProcsInWork(work, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: healingTargetId,
|
|
abilityId: ability.id,
|
|
amount: healed,
|
|
}, now);
|
|
}
|
|
work.totalHealing += directHealing;
|
|
const beacon = work.auras.find((aura) => (
|
|
aura.sourceId === PLAYER_AURA_ENTITY_ID
|
|
&& !healingTargetIds.includes(aura.targetId)
|
|
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
|
));
|
|
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
|
const beaconHealing = Math.min(directHealing, work.maxHealth - work.health);
|
|
work.health += beaconHealing;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += beaconHealing;
|
|
} else if (beacon) {
|
|
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
|
}
|
|
addHealingThreatInWork(work, castThreatSource, directHealing);
|
|
} else if (effect.kind === "resurrection") {
|
|
const restored = targetId
|
|
? reviveCompanionActorInWork(work, targetId, effect.percentMaxHealth)
|
|
: 0;
|
|
work.totalHealing += restored;
|
|
if (restored > 0 && targetId) recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: targetId,
|
|
abilityId: ability.id,
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
addHealingThreatInWork(work, castThreatSource, restored);
|
|
} else if (effect.kind === "summon") {
|
|
summonSequence += 1;
|
|
if (/totem/i.test(ability.name)) {
|
|
work.summons = work.summons.filter((summon) => summon.exclusiveGroup !== "totem");
|
|
}
|
|
work.summons.push({
|
|
id: `player-summon-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: /totem/i.test(ability.name) ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
position: groundPosition ?? playerOrigin,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}),
|
|
});
|
|
if (ability.target === "hostile") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:summon`,
|
|
ability,
|
|
);
|
|
}
|
|
}
|
|
} else if (effect.kind === "shield") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const recipients = ability.radius !== undefined
|
|
? [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => {
|
|
const position = getPartyRuntimePosition(member.id);
|
|
return member.health > 0 && Boolean(position
|
|
&& Math.sqrt(distanceSquared(playerOrigin, position)) <= ability.radius!
|
|
&& combatHasLineOfSight(playerOrigin, position));
|
|
}).map((member) => member.id)]
|
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
|
for (const recipientId of recipients) {
|
|
if (recipientId === PLAYER_AURA_ENTITY_ID) {
|
|
work.shield = Math.max(work.shield, amount);
|
|
work.shieldExpiresAt = now + durationMs;
|
|
} else {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: {
|
|
id: `shield:${ability.id}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs,
|
|
stackBehavior: "replace",
|
|
absorbs: [{ id: `${ability.id}:absorb`, amount }],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: recipientId,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: amount,
|
|
effectiveAmount: amount,
|
|
overheal: 0,
|
|
absorbed: amount,
|
|
critical: false,
|
|
});
|
|
}
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const friendlyResourceTarget = effectiveTarget === "friendly" && targetId
|
|
? usePartyStore.getState().members.find((member) => member.id === targetId)
|
|
: null;
|
|
if (friendlyResourceTarget) {
|
|
const maximum = friendlyResourceTarget.resourceType === resource
|
|
? friendlyResourceTarget.maxResource
|
|
: 100;
|
|
const current = friendlyResourceTarget.resourcePools[resource] ?? 0;
|
|
const requested = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
const restored = Math.max(0, Math.min(requested, maximum - current));
|
|
const actor = ensureCompanionActorInWork(work, friendlyResourceTarget.id);
|
|
if (actor) {
|
|
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, current, maximum);
|
|
const next = Object.freeze({
|
|
...actor,
|
|
resources: Object.freeze({
|
|
...actor.resources,
|
|
[resource]: Object.freeze({ ...pool, current: current + restored }),
|
|
}),
|
|
});
|
|
work.actors[friendlyResourceTarget.id] = next;
|
|
mirrorCompanionActor(next);
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: friendlyResourceTarget.id,
|
|
abilityId: ability.id,
|
|
rawAmount: requested,
|
|
effectiveAmount: restored,
|
|
overheal: Math.max(0, requested - restored),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
continue;
|
|
}
|
|
const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
const rawAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
const amount = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(rawAmount, targetAuras, resource, "generation"),
|
|
targetAuras,
|
|
"power",
|
|
"generation",
|
|
);
|
|
const resourceBefore = work.resourcePools[resource] ?? 0;
|
|
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
rawAmount: amount,
|
|
effectiveAmount: (work.resourcePools[resource] ?? 0) - resourceBefore,
|
|
overheal: Math.max(0, amount - ((work.resourcePools[resource] ?? 0) - resourceBefore)),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
} else if (effect.kind === "combo") {
|
|
const comboTargetId = targetId && work.mobs[targetId] ? targetId : null;
|
|
work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0, 5);
|
|
work.comboTargetId = comboTargetId;
|
|
} else if (effect.kind === "dot") {
|
|
if (targeting?.shape === "ground") continue;
|
|
for (const id of affectedMobIds) {
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "dot"
|
|
&& entry.targetId === id
|
|
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"dot",
|
|
ability.id,
|
|
castThreatSource.actorId,
|
|
id,
|
|
effect,
|
|
effect.ticks,
|
|
effect.intervalMs,
|
|
now,
|
|
effect.tag,
|
|
damageSchool,
|
|
work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
} else if (effect.kind === "hot") {
|
|
if (targeting?.shape === "ground") continue;
|
|
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "hot"
|
|
&& entry.sourceAbilityId === ability.id
|
|
&& entry.targetId === hotTargetId
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"hot", ability.id, castThreatSource.actorId, hotTargetId, effect, effect.ticks, effect.intervalMs, now,
|
|
));
|
|
} else if (effect.kind === "taunt") {
|
|
const friendlyAttackers = effectiveTarget === "friendly" && targetId
|
|
? Object.values(work.mobs)
|
|
.filter((mob) => !mob.dead && mob.targetActorId === targetId)
|
|
.map((mob) => mob.id)
|
|
: [];
|
|
const tauntTargets = affectedMobIds.length ? affectedMobIds : friendlyAttackers;
|
|
const candidates = effect.maxTargets ? tauntTargets.slice(0, effect.maxTargets) : tauntTargets;
|
|
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
const previous = mob.tauntDiminishingReturns;
|
|
const applications = mob.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
|
const multiplier = mob.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
|
const effectiveDurationMs = Math.round(durationMs * multiplier);
|
|
const tauntDiminishingReturns = mob.boss
|
|
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
|
|
: mob.tauntDiminishingReturns;
|
|
if (effectiveDurationMs <= 0) {
|
|
work.mobs[id] = { ...mob, tauntDiminishingReturns };
|
|
continue;
|
|
}
|
|
work.mobs[id] = {
|
|
...mob,
|
|
tauntDiminishingReturns,
|
|
threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId),
|
|
targetActorId: castThreatSource.actorId,
|
|
forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + effectiveDurationMs },
|
|
};
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
...effect,
|
|
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const sourceDurationMs = holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
|
|
? Math.max(1_000, effect.aura.durationMs - 2_000)
|
|
: effect.aura.durationMs;
|
|
const durationMs = sourceDurationMs === null
|
|
? null
|
|
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
|
|
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
|
const recipients = effect.scope === "party"
|
|
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)]
|
|
: holyAuraFriendly && ability.name === "Holy Aura" && targetId
|
|
? [targetId]
|
|
: effect.recipient === "caster"
|
|
? [PLAYER_AURA_ENTITY_ID]
|
|
: effectiveTarget === "hostile"
|
|
? affectedMobIds
|
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
|
for (const recipientId of recipients) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: auraDefinition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
}).auras];
|
|
if (auraDefinition.controlImmunities?.length) {
|
|
if (
|
|
recipientId === PLAYER_AURA_ENTITY_ID
|
|
&& state.controlMechanic
|
|
&& auraDefinition.controlImmunities.includes(state.controlMechanic)
|
|
) clearedPlayerControl = true;
|
|
else if (recipientId !== PLAYER_AURA_ENTITY_ID) {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === recipientId);
|
|
if (member?.controlMechanic && auraDefinition.controlImmunities.includes(member.controlMechanic)) {
|
|
clearCompanionControlInWork(work, recipientId);
|
|
}
|
|
}
|
|
}
|
|
if (effect.control && work.mobs[recipientId]) {
|
|
const durationMs = auraDefinition.durationMs ?? 1_000;
|
|
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${auraDefinition.id}`, now);
|
|
}
|
|
}
|
|
} else if (effect.kind === "dispel") {
|
|
const primaryRecipientId = effectiveTarget === "hostile"
|
|
? targetId
|
|
: targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const recipientIds = effect.scope === "party"
|
|
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)]
|
|
: primaryRecipientId ? [primaryRecipientId] : [];
|
|
for (const recipientId of recipientIds) {
|
|
const relationship = effect.relationship === "target"
|
|
? (work.mobs[recipientId] ? "hostile" : "friendly")
|
|
: effect.relationship;
|
|
const targeted = work.auras.filter((aura) => aura.targetId === recipientId);
|
|
const untouched = work.auras.filter((aura) => aura.targetId !== recipientId);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship,
|
|
categories: effect.categories,
|
|
maxCount: effect.maxCount,
|
|
});
|
|
work.auras = [...untouched, ...result.auras];
|
|
if (result.removed.length > 0 && work.mobs[recipientId]) {
|
|
const removedTokens = new Set(result.removed.map((removed) => `aura:${removed.definition.id}`));
|
|
work.mobs[recipientId] = {
|
|
...work.mobs[recipientId],
|
|
statuses: work.mobs[recipientId].statuses.filter((status) => !removedTokens.has(status.sourceAbilityId)),
|
|
auras: work.mobs[recipientId].auras.filter((legacy) => !result.removed.some((removed) => (
|
|
removed.definition.id === `enemy:${recipientId}:${legacy.id}`
|
|
))),
|
|
};
|
|
}
|
|
if (
|
|
recipientId === PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) clearedPlayerControl = true;
|
|
if (
|
|
recipientId !== PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) clearCompanionControlInWork(work, recipientId);
|
|
if (effect.mode === "steal") {
|
|
for (const removed of result.removed) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: { ...removed.definition, disposition: "buff" },
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
}
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "pull") {
|
|
if (ability.target === "friendly" && targetId && effect.toward === "caster") {
|
|
updatePartyRuntimePosition(
|
|
targetId,
|
|
state.playerPosition[0],
|
|
state.playerPosition[1],
|
|
state.playerPosition[2],
|
|
);
|
|
}
|
|
for (const id of affectedMobIds) {
|
|
const currentPosition = getMobPosition(id);
|
|
if (!currentPosition) continue;
|
|
if (effect.toward === "caster") {
|
|
const dx = currentPosition[0] - state.playerPosition[0];
|
|
const dz = currentPosition[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [
|
|
state.playerPosition[0] + dx / length * 1.5,
|
|
currentPosition[1],
|
|
state.playerPosition[2] + dz / length * 1.5,
|
|
]);
|
|
}
|
|
}
|
|
} else if (effect.kind === "execute") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
Math.max(mob.health, mob.maxHealth * effect.coefficient),
|
|
now,
|
|
castThreatSource,
|
|
damageSchool,
|
|
);
|
|
}
|
|
} else if (effect.kind === "resource-drain") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
if (ability.target === "hostile") {
|
|
const returned = effect.burn ? 0 : drainAmount;
|
|
if (returned > 0) {
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else {
|
|
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "threat") {
|
|
const threatTargets = affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs);
|
|
for (const id of threatTargets) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
const threatActorId = effect.recipient === "ability-target" && targetId && !work.mobs[targetId]
|
|
? targetId
|
|
: castThreatSource.actorId;
|
|
const currentThreat = mob.threatByActor[threatActorId] ?? 0;
|
|
const nextThreat = effect.mode === "redirect"
|
|
? 0
|
|
: effect.mode === "percent"
|
|
? currentThreat * Math.max(0, 1 + effect.amount / 100)
|
|
: Math.max(0, currentThreat + effect.amount);
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: {
|
|
...mob.threatByActor,
|
|
[threatActorId]: nextThreat,
|
|
...(effect.mode === "redirect" && targetId && !work.mobs[targetId]
|
|
? { [targetId]: (mob.threatByActor[targetId] ?? 0) + currentThreat }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
} else if (effect.kind === "movement") {
|
|
if (effect.movement === "sanctuary") {
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const threatByActor = { ...mob.threatByActor, [castThreatSource.actorId]: 0 };
|
|
work.mobs[id] = { ...mob, threatByActor };
|
|
}
|
|
} else if (effect.movement === "knockback") {
|
|
for (const id of affectedMobIds) {
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const dx = position[0] - state.playerPosition[0];
|
|
const dz = position[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [position[0] + dx / length * 4, position[1], position[2] + dz / length * 4]);
|
|
}
|
|
} else if (effect.toward === "away") {
|
|
const referenceId = state.selectedTargetId
|
|
?? nearbyMobRuntimeIds(state.playerPosition, 40).find((id) => !work.mobs[id]?.dead)
|
|
?? null;
|
|
const reference = referenceId ? getMobPosition(referenceId) : null;
|
|
const dx = reference ? state.playerPosition[0] - reference[0] : 0;
|
|
const dz = reference ? state.playerPosition[2] - reference[2] : 1;
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
playerPositionAfterEffects = [
|
|
state.playerPosition[0] + dx / length * 8,
|
|
state.playerPosition[1],
|
|
state.playerPosition[2] + dz / length * 8,
|
|
];
|
|
} else if (targetPosition) {
|
|
playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]];
|
|
}
|
|
} else if (effect.kind === "dispel-mechanic") {
|
|
const mechanicStatus: Partial<Record<number, MobStatusKind>> = {
|
|
1: "charm",
|
|
3: "disarm",
|
|
5: "fear",
|
|
7: "root",
|
|
9: "silence",
|
|
10: "sleep",
|
|
11: "slow",
|
|
12: "stun",
|
|
};
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob) continue;
|
|
const wanted = mechanicStatus[effect.mechanic];
|
|
let removed = 0;
|
|
work.mobs[id] = {
|
|
...mob,
|
|
statuses: mob.statuses.filter((status) => {
|
|
if (removed >= effect.maxCount || (wanted && status.kind !== wanted)) return true;
|
|
removed += 1;
|
|
return false;
|
|
}),
|
|
};
|
|
}
|
|
} else if (effect.kind === "pet") {
|
|
if (["summon", "call", "recall", "resurrect", "tame"].includes(effect.action)) {
|
|
summonSequence += 1;
|
|
const existingPet = work.summons.find((summon) => summon.id.startsWith("player-pet-"));
|
|
if (!existingPet) {
|
|
work.summons.push({
|
|
id: `player-pet-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId ?? 1,
|
|
targetId,
|
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
kind: "pet",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.5)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.5)),
|
|
position: playerOrigin,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: 2_000,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
|
|
...initialSummonCombatRuntime(),
|
|
exclusiveGroup: "combat-pet",
|
|
});
|
|
}
|
|
} else if (effect.action === "dismiss") {
|
|
work.summons = work.summons.filter((summon) => !summon.id.startsWith("player-pet-"));
|
|
}
|
|
} else if (effect.kind === "totem") {
|
|
work.summons = work.summons.filter((summon) => !/totem/i.test(abilityById(summon.sourceAbilityId)?.name ?? ""));
|
|
} else if (effect.kind === "rune") {
|
|
work.deathKnightRunes = activateDeathKnightRunes(work.deathKnightRunes, effect.count);
|
|
} else if (effect.kind === "cooldown-reset") {
|
|
cooldownAdjustments.push(effect);
|
|
} else if (effect.kind === "trigger-spell") {
|
|
if (effect.spellId !== ability.dbcSpellId) {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
effect.spellId,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
targetId,
|
|
now,
|
|
0,
|
|
);
|
|
}
|
|
} else if (effect.kind === "utility") {
|
|
if (effect.action === "create-item") {
|
|
if (/water|mana|spellstone/i.test(ability.name)) {
|
|
const restored = Math.max(1, Math.round(work.maxResource * 0.25));
|
|
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
|
work.resourcePools[activeResourceType] = work.resource;
|
|
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
|
const healed = Math.min(Math.max(1, Math.round(work.maxHealth * 0.25)), work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
}
|
|
} else if (effect.action === "portal") {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-utility-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.miscValue,
|
|
targetId: null,
|
|
expiresAt: now + Math.max(30_000, effect.durationMs),
|
|
kind: "summon",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: 1,
|
|
maxHealth: 1,
|
|
position: playerOrigin,
|
|
nextActionAt: Number.MAX_SAFE_INTEGER,
|
|
pulseIntervalMs: 0,
|
|
attackDamage: 0,
|
|
...initialSummonCombatRuntime(),
|
|
});
|
|
} else if (effect.action === "class-script" && ability.name === "Divine Intervention") {
|
|
work.health = 1;
|
|
work.progression.health = 1;
|
|
} else if (effect.action === "class-script" && ability.id === "rom-rogue-rom-warden-elite-60") {
|
|
const revival = work.auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
|
));
|
|
const restored = Math.min(
|
|
work.maxHealth - work.health,
|
|
Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)),
|
|
);
|
|
work.health += restored;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += restored;
|
|
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const consumedRuneWakerTags = ability.id === "rom-mage-primary-55"
|
|
? ["rom-charged"]
|
|
: ability.id === "rom-mage-primary-56"
|
|
? ["rom-static-field-charge"]
|
|
: holyAuraFriendly && ability.name === "Holy Aura"
|
|
? ["rom-holy-salvation-candle"]
|
|
: [];
|
|
if (consumedRuneWakerTags.length) {
|
|
work.auras = work.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag))
|
|
));
|
|
}
|
|
|
|
evaluateProcsInWork(work, {
|
|
trigger: "cast",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: targetId ?? undefined,
|
|
abilityId: ability.id,
|
|
}, now);
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "cast",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "completed",
|
|
});
|
|
|
|
// Judgement of Light restores 2% health when the marked target is struck.
|
|
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
|
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, castThreatSource, healed);
|
|
}
|
|
|
|
const cooldowns: Record<string, number> = { ...work.cooldowns };
|
|
if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs;
|
|
if (ability.cooldownGroup && completingCast?.mode !== "channel") {
|
|
for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) {
|
|
cooldowns[grouped.id] = now + Math.max(cooldownMs, talentAdjustedCooldown(grouped, modifiers));
|
|
}
|
|
}
|
|
for (const adjustment of cooldownAdjustments) {
|
|
const candidates = adjustment.abilityName
|
|
? state.abilities.filter((candidate) => candidate.name.toLowerCase() === adjustment.abilityName!.toLowerCase())
|
|
: state.abilities;
|
|
for (const candidate of candidates) {
|
|
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
|
|
else {
|
|
const readyAt = cooldowns[candidate.id] ?? now;
|
|
const reduction = adjustment.amountPercent !== undefined
|
|
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, adjustment.amountPercent))
|
|
: adjustment.amountMs ?? 0;
|
|
cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
|
|
}
|
|
}
|
|
}
|
|
|
|
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
|
const levelMessage = work.progression.levelsGained > 0
|
|
? levelUpMessage(state.classId, state.level, work.progression.level)
|
|
: primaryCombatResult
|
|
? `${ability.name} ${combatResultDescription(primaryCombatResult)}.`
|
|
: `${ability.name}${work.totalDamage ? ` dealt ${work.totalDamage}` : ""}${work.totalHealing ? ` healed ${work.totalHealing}` : ""}.`;
|
|
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
|
|
for (const [actorId, actor] of Object.entries(work.actors)) {
|
|
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) delete work.actors[actorId];
|
|
}
|
|
for (const summon of work.summons) {
|
|
work.actors[summon.id] = combatActorFromSummon(summon, work.progression.level, work.auras);
|
|
}
|
|
set({
|
|
level: work.progression.level,
|
|
xp: work.progression.xp,
|
|
xpToNext: work.progression.xpToNext,
|
|
health: work.progression.levelsGained > 0 ? work.progression.health : work.health,
|
|
maxHealth: work.progression.maxHealth,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [activeResourceType]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
lastResourceSpentAt: work.lastResourceSpentAt,
|
|
deathKnightRunes: work.deathKnightRunes,
|
|
talentModifiers: work.talentModifiers,
|
|
comboPoints: work.comboPoints,
|
|
comboTargetId: work.comboTargetId,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
areaEffects: work.areaEffects,
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
cooldowns,
|
|
globalCooldownEndsAt: completingCast ? state.globalCooldownEndsAt : now + gcdMs,
|
|
activeCast: null,
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
|
selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId,
|
|
playerPosition: playerPositionAfterEffects,
|
|
...(clearedPlayerControl ? { controlledUntil: 0, controlMechanic: null } : {}),
|
|
feedback: nextFeedback(
|
|
work.progression.levelsGained > 0 ? "level-up" : "success",
|
|
levelMessage,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
|
|
),
|
|
});
|
|
emitPlayerAbilityResolutionPresentation(
|
|
ability,
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
abilityId: ability.id,
|
|
affectedMobIds,
|
|
damage: work.totalDamage,
|
|
healing: work.totalHealing,
|
|
levelsGained: work.progression.levelsGained,
|
|
};
|
|
},
|
|
|
|
castSlot: (index, origin, now) => {
|
|
const ability = get().abilities[Math.trunc(index)];
|
|
if (!ability) {
|
|
set({ feedback: nextFeedback("error", "That action-bar slot is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
return get().castAbility(ability.id, origin, now);
|
|
},
|
|
|
|
castActionBinding: (layer, control, origin, now) => {
|
|
const abilityId = get().actionBindings[actionBindingId(layer, control)];
|
|
if (!abilityId) {
|
|
set({ feedback: nextFeedback("error", "That controller binding is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
const ability = abilityById(abilityId);
|
|
if (ability && targetingRulesForAbility(ability).shape === "ground") {
|
|
const pending = get().pendingGroundTarget;
|
|
if (pending?.abilityId !== abilityId) {
|
|
get().beginGroundTarget(abilityId);
|
|
return { ...emptyCastResult(), ok: true, abilityId };
|
|
}
|
|
return get().confirmGroundTarget(undefined, now);
|
|
}
|
|
return get().castAbility(abilityId, origin, now);
|
|
},
|
|
|
|
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
|
|
const state = get();
|
|
const threatSource = source ?? playerThreatSource(state);
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools },
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const result = rollContext
|
|
? resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
rollContext.attackKind,
|
|
school,
|
|
rollContext.seed,
|
|
rollContext.abilityId ? abilityById(rollContext.abilityId) ?? undefined : undefined,
|
|
rollContext.periodic,
|
|
rollContext.rules,
|
|
)
|
|
: damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
school,
|
|
state.gearStats.spellPenetration,
|
|
);
|
|
set({
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
cooldowns: work.cooldowns,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
health: progression.health,
|
|
maxHealth: progression.maxHealth,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
talentModifiers: work.talentModifiers,
|
|
lastCombatResult: result.combatResult ?? state.lastCombatResult,
|
|
selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null : state.comboTargetId,
|
|
feedback: result.levelsGained > 0
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: result.combatResult
|
|
? nextFeedback("damage", `${state.mobs[id]?.name ?? "Enemy"} ${combatResultDescription(result.combatResult)}.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
})
|
|
: nextFeedback("damage", result.killed ? `${state.mobs[id]?.name ?? "Enemy"} defeated.` : `${result.amount} damage.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
}),
|
|
});
|
|
return result;
|
|
},
|
|
|
|
addHealingThreat: (source, effectiveHealing) => set((state) => {
|
|
const engagedIds = Object.values(state.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
|
if (perMob <= 0) return state;
|
|
const mobs = { ...state.mobs };
|
|
for (const id of engagedIds) {
|
|
const mob = mobs[id];
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
return { mobs };
|
|
}),
|
|
|
|
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
|
const target = get().mobs[id];
|
|
if (!target || target.dead || durationMs <= 0) return false;
|
|
const previous = target.tauntDiminishingReturns;
|
|
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
|
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
|
const effectiveDurationMs = Math.round(durationMs * multiplier);
|
|
const nextDiminishing = target.boss
|
|
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
|
|
: target.tauntDiminishingReturns;
|
|
set((state) => {
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
if (effectiveDurationMs <= 0) {
|
|
mobs[id] = { ...mobs[id], tauntDiminishingReturns: nextDiminishing };
|
|
return { mobs };
|
|
}
|
|
const groupId = mobGroupId(id);
|
|
const groupWasEngaged = Object.entries(mobs).some(([mobId, mob]) => (
|
|
mobGroupId(mobId) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
for (const [mobId, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
|
const seeded = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, source.actorId);
|
|
mobs[mobId] = {
|
|
...mob,
|
|
...(mobId === id ? { tauntDiminishingReturns: nextDiminishing } : {}),
|
|
threatByActor: mobId === id ? tauntThreat(seeded, source.actorId) : seeded,
|
|
targetActorId: mobId === id
|
|
? source.actorId
|
|
: (mob.targetActorId ?? (groupWasEngaged ? null : source.actorId)),
|
|
forcedTarget: mobId === id
|
|
? { actorId: source.actorId, endsAt: now + Math.max(1, effectiveDurationMs) }
|
|
: mob.forcedTarget,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(mobId))
|
|
: (getMobPosition(mobId) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
}
|
|
return { mobs };
|
|
});
|
|
return effectiveDurationMs > 0;
|
|
},
|
|
|
|
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
|
|
let interrupted = false;
|
|
set((state) => {
|
|
const mob = state.mobs[id];
|
|
if (!mob?.activeCast?.interruptible) return state;
|
|
const next = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: Math.max(1, lockoutMs),
|
|
}, sourceAbilityId, now);
|
|
interrupted = next.activeCast === null;
|
|
return interrupted ? { mobs: { ...state.mobs, [id]: next } } : state;
|
|
});
|
|
return interrupted;
|
|
},
|
|
|
|
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
|
|
let controlled = false;
|
|
set((state) => {
|
|
const mob = state.mobs[id];
|
|
if (!mob || mob.dead) return state;
|
|
const next = applyStatus(mob, {
|
|
kind: "status",
|
|
status,
|
|
durationMs: Math.max(1, durationMs),
|
|
}, sourceAbilityId, now);
|
|
controlled = next.statuses.some((entry) => (
|
|
entry.sourceAbilityId === sourceAbilityId
|
|
&& entry.kind === status
|
|
&& entry.endsAt > now
|
|
));
|
|
return controlled ? { mobs: { ...state.mobs, [id]: next } } : state;
|
|
});
|
|
return controlled;
|
|
},
|
|
|
|
dispelActor: (id, categories, maxCount = 1) => {
|
|
let removedCount = 0;
|
|
let clearedCompanionActor: CombatActorState | null = null;
|
|
set((state) => {
|
|
const targeted = state.auras.filter((aura) => aura.targetId === id);
|
|
const untouched = state.auras.filter((aura) => aura.targetId !== id);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship: "friendly",
|
|
categories,
|
|
maxCount: Math.max(1, Math.trunc(maxCount)),
|
|
});
|
|
removedCount = result.removed.length;
|
|
if (!removedCount) return state;
|
|
if (id === PLAYER_AURA_ENTITY_ID && result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
|
|
return { auras: [...untouched, ...result.auras], controlledUntil: 0, controlMechanic: null };
|
|
}
|
|
if (result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (member) {
|
|
const actor = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, id),
|
|
state.actors[id],
|
|
);
|
|
clearedCompanionActor = { ...actor, controlledUntil: 0 };
|
|
return {
|
|
auras: [...untouched, ...result.auras],
|
|
actors: { ...state.actors, [id]: clearedCompanionActor },
|
|
};
|
|
}
|
|
}
|
|
return { auras: [...untouched, ...result.auras] };
|
|
});
|
|
if (clearedCompanionActor) mirrorCompanionActor(clearedCompanionActor);
|
|
return removedCount;
|
|
},
|
|
|
|
healActor: (id, amount, request) => {
|
|
const now = request.now ?? combatNow();
|
|
const state = get();
|
|
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
|
|
const targetAuras = aurasForEntity(state.auras, id);
|
|
const modified = request.amountAlreadyModified
|
|
? amount
|
|
: resolveAuraHealingValue(
|
|
resolveAuraHealingValue(amount, sourceAuras, "done"),
|
|
targetAuras,
|
|
"taken",
|
|
);
|
|
const partyMember = usePartyStore.getState().members.find((member) => member.id === id);
|
|
const summon = state.summons.find((candidate) => candidate.id === id);
|
|
const actor = partyMember
|
|
? companionActorFromMember(partyMember, entityAbsorbAmount(state.auras, id), state.actors[id])
|
|
: state.actors[id];
|
|
const currentHealth = id === PLAYER_AURA_ENTITY_ID
|
|
? state.health
|
|
: summon?.health ?? actor?.health ?? 0;
|
|
const maximumHealth = id === PLAYER_AURA_ENTITY_ID
|
|
? state.maxHealth
|
|
: summon?.maxHealth ?? actor?.maxHealth ?? 0;
|
|
if (currentHealth <= 0 || maximumHealth <= 0) return resolveHealing(0, currentHealth, maximumHealth);
|
|
const resolution = resolveHealing(
|
|
modified,
|
|
currentHealth,
|
|
maximumHealth,
|
|
request.criticalChance ?? 0,
|
|
stableCombatUnit(request.seed ?? `${request.sourceActorId}:${request.abilityId ?? "heal"}:${id}:${now}`),
|
|
request.criticalMultiplier ?? 1.5,
|
|
);
|
|
let healedCompanion: CombatActorState | null = null;
|
|
set((current) => {
|
|
const combatEvents = recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: request.sourceActorId,
|
|
targetActorId: id,
|
|
...(request.abilityId ? { abilityId: request.abilityId } : {}),
|
|
...(request.school ? { school: request.school } : {}),
|
|
rawAmount: resolution.rawAmount,
|
|
effectiveAmount: resolution.effectiveAmount,
|
|
overheal: resolution.overheal,
|
|
absorbed: 0,
|
|
critical: resolution.critical,
|
|
});
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
const playerActor = current.actors[id];
|
|
return {
|
|
health: current.health + resolution.effectiveAmount,
|
|
actors: playerActor ? {
|
|
...current.actors,
|
|
[id]: { ...playerActor, health: current.health + resolution.effectiveAmount, alive: true },
|
|
} : current.actors,
|
|
combatEvents,
|
|
};
|
|
}
|
|
const activeSummon = current.summons.find((candidate) => candidate.id === id);
|
|
if (activeSummon) {
|
|
const nextHealth = activeSummon.health + resolution.effectiveAmount;
|
|
const summonActor = current.actors[id];
|
|
return {
|
|
summons: current.summons.map((candidate) => candidate.id === id
|
|
? { ...candidate, health: nextHealth }
|
|
: candidate),
|
|
actors: summonActor ? {
|
|
...current.actors,
|
|
[id]: { ...summonActor, health: nextHealth, alive: true },
|
|
} : current.actors,
|
|
combatEvents,
|
|
};
|
|
}
|
|
if (!partyMember || !actor) return { combatEvents };
|
|
healedCompanion = { ...actor, health: actor.health + resolution.effectiveAmount, alive: true };
|
|
return {
|
|
actors: { ...current.actors, [id]: healedCompanion },
|
|
combatEvents,
|
|
};
|
|
});
|
|
if (healedCompanion) mirrorCompanionActor(healedCompanion);
|
|
return resolution;
|
|
},
|
|
|
|
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
|
|
const state = get();
|
|
const fraction = clamp(percentMaxHealth, 0.01, 1);
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
|
if (restored <= 0) return 0;
|
|
set({
|
|
health: restored,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return restored;
|
|
}
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (!member) {
|
|
const onlineActor = onlinePlayerRuntimeActor(id);
|
|
if (!onlineActor || onlineActor.health > 0) return 0;
|
|
const restored = Math.max(1, Math.round(onlineActor.maxHealth * fraction));
|
|
set({
|
|
actors: {
|
|
...state.actors,
|
|
[id]: { ...onlineActor, health: restored, alive: true, controlledUntil: 0, cast: null },
|
|
},
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return restored;
|
|
}
|
|
const actor = companionActorFromMember(member, entityAbsorbAmount(state.auras, id), state.actors[id]);
|
|
if (actor.alive || actor.health > 0) return 0;
|
|
const restored = Math.max(1, Math.round(actor.maxHealth * fraction));
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
health: restored,
|
|
alive: true,
|
|
controlledUntil: 0,
|
|
cast: null,
|
|
};
|
|
set({
|
|
actors: { ...state.actors, [id]: nextActor },
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
mirrorCompanionActor(nextActor);
|
|
return restored;
|
|
},
|
|
|
|
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
|
|
const state = get();
|
|
const actor = state.actors[id];
|
|
if (!actor) return 0;
|
|
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, 0, defaultResourceMaximum(resource));
|
|
const requested = Math.max(0, percentage ? pool.maximum * amount / 100 : amount);
|
|
const restored = Math.min(requested, pool.maximum - pool.current);
|
|
if (restored <= 0) return 0;
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
resources: {
|
|
...actor.resources,
|
|
[resource]: { ...pool, current: pool.current + restored },
|
|
},
|
|
};
|
|
const patch: Partial<CombatState> = {
|
|
actors: { ...state.actors, [id]: nextActor },
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: requested,
|
|
effectiveAmount: restored,
|
|
overheal: requested - restored,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
};
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
patch.resourcePools = { ...state.resourcePools, [resource]: pool.current + restored };
|
|
if (resource === resourceProfileForClass(state.classId).type) patch.resource = pool.current + restored;
|
|
}
|
|
set(patch);
|
|
if (nextActor.kind === "companion") mirrorCompanionActor(nextActor);
|
|
return restored;
|
|
},
|
|
|
|
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
|
|
const state = get();
|
|
const absorb = Math.max(0, Math.round(amount));
|
|
if (absorb <= 0 || !state.actors[id]) return 0;
|
|
const result = applyCombatAura(state.auras, {
|
|
definition: {
|
|
id: `shield:${sourceActorId}:${ability.id}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs: Math.max(1, durationMs),
|
|
stackBehavior: "replace",
|
|
absorbs: [{ id: `${ability.id}:absorb`, amount: absorb }],
|
|
},
|
|
sourceId: sourceActorId,
|
|
targetId: id,
|
|
now,
|
|
});
|
|
set({
|
|
auras: result.auras,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: absorb,
|
|
effectiveAmount: absorb,
|
|
overheal: 0,
|
|
absorbed: absorb,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return absorb;
|
|
},
|
|
|
|
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
|
const durationMs = effect.aura.durationMs;
|
|
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
|
const result = applyCombatAura(state.auras, {
|
|
definition,
|
|
sourceId: sourceActorId,
|
|
targetId: targetActorId,
|
|
now,
|
|
});
|
|
set({
|
|
auras: result.auras,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "aura",
|
|
sourceActorId,
|
|
targetActorId,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: 0,
|
|
effectiveAmount: 1,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.actors[sourceActorId]) return false;
|
|
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
|
if (effect.kind === "hot" && !state.actors[targetActorId]) return false;
|
|
const sourceActor = state.actors[sourceActorId];
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const amount = sourceMember
|
|
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
|
|
sourceMember.level * 4
|
|
+ sourceMember.gearStats.spellPower
|
|
+ sourceMember.gearStats.attackPower * 0.35
|
|
+ sourceMember.gearStats.rangedAttackPower * 0.35
|
|
)))
|
|
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
|
const next = state.effects.filter((entry) => !(
|
|
entry.kind === effect.kind
|
|
&& entry.sourceAbilityId === ability.id
|
|
&& entry.targetId === targetActorId
|
|
));
|
|
next.push({ ...timedEffect(
|
|
effect.kind,
|
|
ability.id,
|
|
sourceActorId,
|
|
targetActorId,
|
|
{ ...effect, coefficient: amount },
|
|
effect.ticks,
|
|
effect.intervalMs,
|
|
now,
|
|
effect.kind === "dot" ? effect.tag : undefined,
|
|
effect.kind === "dot" ? presentationSchoolForAbility(ability) : undefined,
|
|
sourceMember?.gearStats.spellPenetration,
|
|
), fixedAmount: amount });
|
|
set({ effects: next });
|
|
return true;
|
|
},
|
|
|
|
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
|
|
const state = get();
|
|
const owner = state.actors[ownerActorId];
|
|
if (!owner?.alive) return null;
|
|
summonSequence += 1;
|
|
const isTotem = /totem/i.test(ability.name);
|
|
const id = `${ownerActorId}:${isTotem ? "totem" : "summon"}-${summonSequence}`;
|
|
const maxHealth = Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3)));
|
|
const summon: CombatSummon = {
|
|
id,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: isTotem ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId,
|
|
health: maxHealth,
|
|
maxHealth,
|
|
position,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: isTotem ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}),
|
|
};
|
|
const summons = isTotem
|
|
? [...state.summons.filter((candidate) => candidate.exclusiveGroup !== summon.exclusiveGroup), summon]
|
|
: [...state.summons, summon];
|
|
set({ summons });
|
|
get().synchronizeActors();
|
|
return id;
|
|
},
|
|
|
|
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
|
|
const state = get();
|
|
const sourceActor = state.actors[sourceActorId];
|
|
if (!sourceActor?.alive) return null;
|
|
const targeting = targetingRulesForAbility(ability);
|
|
if (targeting.shape !== "ground") return null;
|
|
const sourcePosition = actorPosition(state, sourceActorId) ?? state.playerPosition;
|
|
const projected = projectCombatGroundPoint(position, targeting.groundRange ?? ability.range.max, sourcePosition);
|
|
if (!projected || !combatHasLineOfSight(sourcePosition, projected)) return null;
|
|
const periodic = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "dot" | "hot" }> => (
|
|
effect.kind === "dot" || effect.kind === "hot"
|
|
));
|
|
if (!periodic.length) return null;
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const power = sourceMember
|
|
? sourceMember.level * 4 + sourceMember.gearStats.spellPower
|
|
+ sourceMember.gearStats.attackPower * 0.35 + sourceMember.gearStats.rangedAttackPower * 0.35
|
|
: sourceActor.level * 4;
|
|
const scaled = (effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>) => Math.max(
|
|
1,
|
|
Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * Math.max(8, power)),
|
|
);
|
|
areaEffectSequence += 1;
|
|
const id = `${sourceActorId}:area-${areaEffectSequence}`;
|
|
const interval = Math.max(100, Math.min(...periodic.map((effect) => effect.intervalMs)));
|
|
const duration = Math.max(...periodic.map((effect) => effect.intervalMs * effect.ticks));
|
|
const area: CombatAreaEffect = {
|
|
id,
|
|
sourceActorId,
|
|
sourceAbilityId: ability.id,
|
|
position: projected,
|
|
radius: targeting.radius ?? ability.radius ?? 8,
|
|
createdAt: now,
|
|
expiresAt: now + duration,
|
|
tickIntervalMs: interval,
|
|
nextTickAt: now + interval,
|
|
fixedDamagePerTick: periodic.filter((effect) => effect.kind === "dot").reduce((sum, effect) => sum + scaled(effect), 0),
|
|
fixedHealingPerTick: periodic.filter((effect) => effect.kind === "hot").reduce((sum, effect) => sum + scaled(effect), 0),
|
|
};
|
|
set({ areaEffects: [...state.areaEffects, area] });
|
|
return id;
|
|
},
|
|
|
|
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
|
const state = get();
|
|
const now = combatNow();
|
|
const mitigated = damageAfterEquipmentMitigation(
|
|
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
|
state.gearStats,
|
|
state.level,
|
|
school,
|
|
attackerLevel ?? state.level,
|
|
);
|
|
const incoming = resolveAuraDamageValue(
|
|
mitigated,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(state.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: incoming,
|
|
school,
|
|
now,
|
|
});
|
|
const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed);
|
|
const remodeledBody = namedAbsorb.auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("rom-remodeled-body")
|
|
));
|
|
const preventedDeath = Boolean(remodeledBody && healthDamage >= state.health);
|
|
const health = preventedDeath ? state.maxHealth : state.health - healthDamage;
|
|
const aurasAfterCheatDeath = preventedDeath
|
|
? namedAbsorb.auras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId)
|
|
: namedAbsorb.auras;
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
healthDamage > 0 && (
|
|
hasRomWarriorRagePool(state.classId, state.secondaryClassId)
|
|
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && state.maxResourcePools.rage !== undefined)
|
|
)
|
|
? ROM_RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
state.resource,
|
|
state.maxResource,
|
|
{ ...state.resourcePools },
|
|
{ ...state.maxResourcePools },
|
|
);
|
|
const procResult = evaluateAuraProcs(aurasAfterCheatDeath, {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healthDamage,
|
|
school,
|
|
}, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`));
|
|
let nextAuras = procResult.auras;
|
|
if (
|
|
healthDamage > 0
|
|
&& state.abilities.some((ability) => ability.id === "rom-rogue-rom-warden-elite-60" && isAbilityUnlocked(ability, state.level))
|
|
) {
|
|
nextAuras = applyCombatAura(nextAuras, {
|
|
definition: {
|
|
id: "rom-state-revival-charge",
|
|
name: "Revival",
|
|
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
maxStacks: 10,
|
|
stackBehavior: "add",
|
|
utilityTags: ["rom-revival-charge"],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras;
|
|
}
|
|
set({
|
|
auras: nextAuras,
|
|
shield: state.shield - absorbed,
|
|
health,
|
|
resource: rage.resource,
|
|
resourcePools: rage.resourcePools,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: "external",
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
school,
|
|
rawAmount: incoming,
|
|
effectiveAmount: healthDamage,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed + absorbed,
|
|
critical: false,
|
|
outcome: healthDamage > 0 ? "hit" : "absorb",
|
|
}),
|
|
activeCast: health <= 0
|
|
? null
|
|
: healthDamage > 0
|
|
? applyWrathCastPushback(state.activeCast, now, `external:${school}:${state.health}`)
|
|
: state.activeCast,
|
|
autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(health <= 0 ? "death" : "wound")
|
|
: state.playerAnimationEvent,
|
|
feedback: nextFeedback("damage", preventedDeath ? "Remodeled Body prevented defeat and restored all health." : health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }),
|
|
});
|
|
return healthDamage;
|
|
},
|
|
|
|
damageSummon: (id, amount) => {
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (requested <= 0) return 0;
|
|
let applied = 0;
|
|
set((state) => {
|
|
const summon = state.summons.find((candidate) => candidate.id === id);
|
|
if (!summon) return state;
|
|
const absorbed = absorbAuraDamage(state.auras, {
|
|
targetId: id,
|
|
amount: requested,
|
|
school: "physical",
|
|
now: combatNow(),
|
|
});
|
|
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
const health = summon.health - applied;
|
|
const summons = health > 0
|
|
? state.summons.map((candidate) => candidate.id === id ? { ...candidate, health } : candidate)
|
|
: state.summons.filter((candidate) => candidate.id !== id);
|
|
const actors = { ...state.actors };
|
|
if (health > 0) actors[id] = combatActorFromSummon({ ...summon, health }, state.level, absorbed.auras);
|
|
else delete actors[id];
|
|
return {
|
|
auras: absorbed.auras,
|
|
summons,
|
|
actors,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "damage",
|
|
sourceActorId: "external",
|
|
targetActorId: id,
|
|
rawAmount: requested,
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical: false,
|
|
outcome: applied > 0 ? "hit" : "absorb",
|
|
}),
|
|
};
|
|
});
|
|
return applied;
|
|
},
|
|
|
|
healPlayer: (amount) => {
|
|
const state = get();
|
|
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
|
// Resurrection is an explicit combat action handled by revivePlayer.
|
|
if (state.health <= 0) return 0;
|
|
const modified = resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
"taken",
|
|
);
|
|
const healed = Math.min(Math.max(0, Math.round(modified)), state.maxHealth - state.health);
|
|
const procResult = evaluateAuraProcs(state.auras, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healed,
|
|
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
|
set({
|
|
auras: procResult.auras,
|
|
health: state.health + healed,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "healing",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
rawAmount: Math.max(0, Math.round(modified)),
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, Math.round(modified) - healed),
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
feedback: nextFeedback("heal", `${healed} health restored.`, { amount: healed }),
|
|
});
|
|
return healed;
|
|
},
|
|
|
|
revivePlayer: (percentMaxHealth = 0.35) => {
|
|
const state = get();
|
|
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
|
const restored = Math.max(1, Math.round(
|
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
|
));
|
|
set({
|
|
health: restored,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
|
});
|
|
return restored;
|
|
},
|
|
|
|
cancelCast: (message = "Casting interrupted.") => {
|
|
const cast = get().activeCast;
|
|
if (!cast) return false;
|
|
set((state) => ({
|
|
activeCast: null,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "interrupt",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: cast.abilityId,
|
|
school: cast.school,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "cancelled",
|
|
}),
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
|
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
|
}));
|
|
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
|
|
return true;
|
|
},
|
|
|
|
engageNearbyMobs: (now = combatNow(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let engagedGroups = 0;
|
|
for (const id of Object.keys(mobs)) {
|
|
const mob = mobs[id];
|
|
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
|
if (!actorId) continue;
|
|
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
|
|
}
|
|
if (engagedGroups > 0) set({ mobs });
|
|
return engagedGroups;
|
|
},
|
|
|
|
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let namedAuras = [...state.auras];
|
|
const incomingHits: Array<{
|
|
readonly actorId: AggroActorId;
|
|
readonly amount: number;
|
|
readonly mob: MobCombatState;
|
|
readonly attack: EnemyAttackDefinition;
|
|
readonly attackRevision: number;
|
|
}> = [];
|
|
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
|
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
|
const memberControls: Array<{
|
|
readonly memberId: string;
|
|
readonly mechanic: EnemyControlMechanic;
|
|
readonly endsAt: number;
|
|
}> = [];
|
|
let changed = false;
|
|
|
|
for (const [id, mob] of Object.entries(state.mobs)) {
|
|
if (!mob.engaged || mob.dead) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const home = mob.homePosition ?? position;
|
|
const eligible = eligibleAggroActorIds(runtimeState, { ...mob, homePosition: home });
|
|
const threatByActor = pruneThreatTable(mob.threatByActor, eligible);
|
|
const resolution = resolveAggroTarget(
|
|
threatByActor,
|
|
eligible,
|
|
mob.targetActorId,
|
|
mob.forcedTarget,
|
|
now,
|
|
);
|
|
const targetPosition = resolution.targetActorId
|
|
? actorPosition(runtimeState, resolution.targetActorId)
|
|
: null;
|
|
if (!resolution.targetActorId || !targetPosition) {
|
|
mobs[id] = {
|
|
...mob,
|
|
engaged: false,
|
|
combatPhase: "returning",
|
|
nextAttackAt: 0,
|
|
health: mob.maxHealth,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
};
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
const activeStatuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const cannotAttack = activeStatuses.some((status) => [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(status.kind));
|
|
const silenced = activeStatuses.some((status) => status.kind === "silence");
|
|
const disarmed = activeStatuses.some((status) => status.kind === "disarm");
|
|
const rooted = activeStatuses.some((status) => status.kind === "root");
|
|
const dx = position[0] - targetPosition[0];
|
|
const dy = position[1] - targetPosition[1];
|
|
const dz = position[2] - targetPosition[2];
|
|
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
|
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
|
? { ...mob.abilityReadyAt }
|
|
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
|
const abilityCastCounts = { ...mob.abilityCastCounts };
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
|
? null
|
|
: mob.activeCast;
|
|
let completingEnemyCast = false;
|
|
let selectedAttackIndex = -1;
|
|
let selectedAttack: EnemyAttackDefinition | null = null;
|
|
if (enemyActiveCast && now >= enemyActiveCast.completesAt) {
|
|
selectedAttackIndex = mob.attacks.findIndex((attack) => attack.id === enemyActiveCast!.attackId);
|
|
selectedAttack = selectedAttackIndex >= 0 ? mob.attacks[selectedAttackIndex] : null;
|
|
completingEnemyCast = selectedAttack !== null;
|
|
if (!selectedAttack) enemyActiveCast = null;
|
|
} else if (!enemyActiveCast && !cannotAttack) {
|
|
for (let offset = 0; offset < mob.attacks.length; offset += 1) {
|
|
const index = (mob.nextAttackIndex + offset) % mob.attacks.length;
|
|
const attack = mob.attacks[index];
|
|
const attackKind = enemyAttackKind(attack);
|
|
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
|
|| distanceSquared <= attack.range * attack.range;
|
|
if (
|
|
inAbilityRange
|
|
&& !(silenced && attackKind === "spell")
|
|
&& !(disarmed && attackKind === "melee")
|
|
&& now >= (abilityReadyAt[attack.id] ?? 0)
|
|
&& now >= (schoolLockedUntil[attack.school] ?? 0)
|
|
&& (!attack.maximumCasts || (abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts)
|
|
&& enemyAbilityConditionMet(mob, mobs, attack)
|
|
) {
|
|
selectedAttackIndex = index;
|
|
selectedAttack = attack;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const inRange = mob.attacks.some((attack) => (
|
|
!enemyAbilityNeedsHostileRange(attack)
|
|
|| distanceSquared <= attack.range * attack.range
|
|
));
|
|
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
|
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
let attackRevision = mob.attackRevision;
|
|
let attackAnimation = mob.attackAnimation;
|
|
let nextAttackIndex = mob.nextAttackIndex;
|
|
let lastAttackId = mob.lastAttackId;
|
|
let activeAuras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const startingEnemyCast = Boolean(
|
|
selectedAttack
|
|
&& !completingEnemyCast
|
|
&& (selectedAttack.castTimeMs ?? 0) > 0,
|
|
);
|
|
if (selectedAttack && startingEnemyCast) {
|
|
const attack = selectedAttack;
|
|
const sourceTransform = getMobRuntimeTransform(id);
|
|
enemyActiveCast = {
|
|
attackId: attack.id,
|
|
name: attack.name,
|
|
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
|
school: attack.school,
|
|
interruptible: attack.interruptible !== false,
|
|
targetActorId: resolution.targetActorId,
|
|
sourcePosition: [...position],
|
|
targetPosition: [...targetPosition],
|
|
sourceYaw: sourceTransform?.yaw
|
|
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
|
startedAt: now,
|
|
completesAt: now + (attack.castTimeMs ?? 0),
|
|
};
|
|
attackAnimation = "cast";
|
|
lastAttackId = attack.id;
|
|
nextAttackAt = enemyActiveCast.completesAt;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"cast-start",
|
|
now,
|
|
position,
|
|
resolution.targetActorId,
|
|
targetPosition,
|
|
);
|
|
} else if (selectedAttack && !cannotAttack) {
|
|
const attack = selectedAttack!;
|
|
const resolvedEnemyCast = enemyActiveCast;
|
|
enemyActiveCast = null;
|
|
const effect = attack.effect ?? {
|
|
kind: "damage" as const,
|
|
multiplier: attack.damageMultiplier,
|
|
};
|
|
const eligibleActors = [...eligible].sort();
|
|
const randomActor = eligibleActors.length
|
|
? eligibleActors[Math.floor(
|
|
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
|
)]
|
|
: resolution.targetActorId;
|
|
const telegraphPlacement = resolvedEnemyCast
|
|
? {
|
|
sourcePosition: resolvedEnemyCast.sourcePosition,
|
|
targetPosition: resolvedEnemyCast.targetPosition,
|
|
sourceYaw: resolvedEnemyCast.sourceYaw,
|
|
}
|
|
: {
|
|
sourcePosition: position,
|
|
targetPosition,
|
|
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
|
};
|
|
const unvalidatedTargets = attack.target === "nearby-party"
|
|
? eligibleActors.filter((actorId) => {
|
|
const candidate = actorPosition(runtimeState, actorId);
|
|
if (!candidate) return false;
|
|
if (attack.telegraph) {
|
|
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
|
|
}
|
|
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
|
const targetDx = candidate[0] - position[0];
|
|
const targetDy = candidate[1] - position[1];
|
|
const targetDz = candidate[2] - position[2];
|
|
return targetDx * targetDx + targetDy * targetDy + targetDz * targetDz
|
|
<= radius * radius;
|
|
})
|
|
: attack.target === "random-party"
|
|
? [randomActor]
|
|
: attack.target === "primary"
|
|
? [resolution.targetActorId]
|
|
: [];
|
|
const enemySpatial = combatSpatialActor(id);
|
|
const targets = unvalidatedTargets.filter((actorId) => {
|
|
const candidate = actorPosition(runtimeState, actorId);
|
|
if (!candidate || !combatHasLineOfSight(position, candidate)) return false;
|
|
return attack.target === "nearby-party"
|
|
|| !enemySpatial
|
|
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
|
|
});
|
|
const presentationTargetId = targets[0] ?? resolution.targetActorId;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"release",
|
|
now,
|
|
position,
|
|
presentationTargetId,
|
|
actorPosition(runtimeState, presentationTargetId),
|
|
);
|
|
if (effect.kind === "damage") {
|
|
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
|
|
for (const actorId of targets) {
|
|
incomingHits.push({
|
|
actorId,
|
|
amount: attackDamage,
|
|
mob,
|
|
attack,
|
|
attackRevision,
|
|
});
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.amount
|
|
: 1;
|
|
const friendlyRange = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.range
|
|
: attack.range;
|
|
const friendly = Object.values(mobs)
|
|
.filter((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - requiredMissingHealth) {
|
|
return false;
|
|
}
|
|
const candidatePosition = getMobPosition(candidate.id);
|
|
if (!candidatePosition) return false;
|
|
const friendlyDx = candidatePosition[0] - position[0];
|
|
const friendlyDy = candidatePosition[1] - position[1];
|
|
const friendlyDz = candidatePosition[2] - position[2];
|
|
return friendlyDx * friendlyDx + friendlyDy * friendlyDy + friendlyDz * friendlyDz
|
|
<= friendlyRange * friendlyRange
|
|
&& combatHasLineOfSight(position, candidatePosition);
|
|
})
|
|
.sort((left, right) => (
|
|
(left.health / Math.max(1, left.maxHealth))
|
|
- (right.health / Math.max(1, right.maxHealth))
|
|
|| left.id.localeCompare(right.id)
|
|
))[0];
|
|
if (friendly) {
|
|
enemyHealing.push({
|
|
targetId: friendly.id,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * effect.multiplier)),
|
|
});
|
|
}
|
|
} else if (effect.kind === "aura") {
|
|
const endsAt = effect.durationMs > 0 ? now + effect.durationMs : 0;
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => aura.id !== effect.aura),
|
|
{
|
|
id: effect.aura,
|
|
sourceAbilityId: attack.id,
|
|
endsAt,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
},
|
|
];
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy:${id}:${effect.aura}`,
|
|
name: `${attack.name}: ${effect.aura}`,
|
|
disposition: "buff",
|
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
|
dispelCategory: effect.aura === "enrage" ? "enrage" : "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: id,
|
|
now,
|
|
}).auras];
|
|
} else if (effect.kind === "target-aura") {
|
|
for (const actorId of targets) {
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
|
|
name: `${attack.name}: ${effect.aura}`,
|
|
disposition: "debuff",
|
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
|
dispelCategory: "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "control") {
|
|
for (const actorId of targets) {
|
|
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
|
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
|
|
if (actorId === PLAYER_AGGRO_ID) playerControl = control;
|
|
else memberControls.push({ memberId: actorId, ...control });
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
|
name: `${attack.name}: ${effect.mechanic}`,
|
|
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
|
|
disposition: "debuff",
|
|
durationMs: effect.durationMs,
|
|
dispelCategory: "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "transform") {
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => !aura.id.startsWith("form:")),
|
|
{
|
|
id: `form:${effect.form}`,
|
|
sourceAbilityId: attack.id,
|
|
endsAt: 0,
|
|
},
|
|
];
|
|
} else if (effect.kind === "call-for-help") {
|
|
for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) {
|
|
const helper = mobs[helperId];
|
|
if (!helper || helper.dead) continue;
|
|
mobs[helperId] = {
|
|
...helper,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
threatByActor: seedActorThreat(helper.threatByActor, resolution.targetActorId),
|
|
targetActorId: helper.targetActorId ?? resolution.targetActorId,
|
|
homePosition: helper.homePosition ?? getMobPosition(helperId),
|
|
abilityReadyAt: Object.keys(helper.abilityReadyAt).length
|
|
? helper.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(helper, now),
|
|
};
|
|
}
|
|
} else if (effect.kind === "summon") {
|
|
const castNumber = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
for (let summonIndex = 0; summonIndex < effect.count; summonIndex += 1) {
|
|
const summonId = `${id}:${attack.id}:summon:${castNumber}:${summonIndex + 1}`;
|
|
if (mobs[summonId]) continue;
|
|
const angle = summonIndex * 2.399963229728653;
|
|
const summonPosition: CombatPosition = [
|
|
position[0] + Math.cos(angle) * 2.2,
|
|
position[1],
|
|
position[2] + Math.sin(angle) * 2.2,
|
|
];
|
|
setMobPosition(summonId, summonPosition);
|
|
mobs[summonId] = summonedMobCombatState(
|
|
summonId,
|
|
mob,
|
|
effect.creatureEntry,
|
|
resolution.targetActorId,
|
|
summonPosition,
|
|
);
|
|
}
|
|
} else if (effect.kind === "encounter-event") {
|
|
if (effect.eventId === "wailing-caverns:naralex-start") {
|
|
useWailingEncounterStore.getState().start(now);
|
|
}
|
|
}
|
|
attackRevision += 1;
|
|
abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
attackAnimation = attack.animation;
|
|
nextAttackIndex = (selectedAttackIndex + 1) % mob.attacks.length;
|
|
lastAttackId = attack.id;
|
|
const slowAttack = activeStatuses.find((status) => status.kind === "attack-speed")?.magnitude ?? 0;
|
|
const cooldownRange = attack.repeatCooldownMs
|
|
?? [attack.cooldownMs, attack.cooldownMs] as const;
|
|
abilityReadyAt[attack.id] = attack.maximumCasts
|
|
&& abilityCastCounts[attack.id] >= attack.maximumCasts
|
|
? Number.POSITIVE_INFINITY
|
|
: now + Math.round(
|
|
deterministicEnemyCooldown(id, attack.id, attackRevision, cooldownRange) * (1 + slowAttack),
|
|
);
|
|
nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
}
|
|
if (enemyActiveCast) nextAttackAt = enemyActiveCast.completesAt;
|
|
if (
|
|
phase !== mob.combatPhase
|
|
|| nextAttackAt !== mob.nextAttackAt
|
|
|| attackRevision !== mob.attackRevision
|
|
|| attackAnimation !== mob.attackAnimation
|
|
|| nextAttackIndex !== mob.nextAttackIndex
|
|
|| lastAttackId !== mob.lastAttackId
|
|
|| activeStatuses.length !== mob.statuses.length
|
|
|| activeAuras.length !== mob.auras.length
|
|
|| Object.entries(abilityReadyAt).some(([abilityId, readyAt]) => (
|
|
mob.abilityReadyAt[abilityId] !== readyAt
|
|
))
|
|
|| Object.entries(abilityCastCounts).some(([abilityId, count]) => (
|
|
mob.abilityCastCounts[abilityId] !== count
|
|
))
|
|
|| enemyActiveCast !== mob.activeCast
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| Object.entries(schoolLockedUntil).some(([school, lockedUntil]) => (
|
|
mob.schoolLockedUntil[school as EnemyDamageSchool] !== lockedUntil
|
|
))
|
|
|| threatByActor !== mob.threatByActor
|
|
|| resolution.targetActorId !== mob.targetActorId
|
|
|| resolution.forcedTarget !== mob.forcedTarget
|
|
) {
|
|
mobs[id] = {
|
|
...mob,
|
|
combatPhase: phase,
|
|
nextAttackAt,
|
|
abilityReadyAt,
|
|
abilityCastCounts,
|
|
activeCast: enemyActiveCast,
|
|
schoolLockedUntil,
|
|
attackRevision,
|
|
attackAnimation,
|
|
nextAttackIndex,
|
|
lastAttackId,
|
|
statuses: activeStatuses,
|
|
auras: activeAuras,
|
|
threatByActor,
|
|
targetActorId: resolution.targetActorId,
|
|
forcedTarget: resolution.forcedTarget,
|
|
homePosition: home,
|
|
};
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
for (const healing of enemyHealing) {
|
|
const target = mobs[healing.targetId];
|
|
if (!target || target.dead) continue;
|
|
mobs[healing.targetId] = {
|
|
...target,
|
|
health: Math.min(target.maxHealth, target.health + healing.amount),
|
|
};
|
|
changed = true;
|
|
}
|
|
if (changed || playerControl) {
|
|
set({
|
|
mobs,
|
|
auras: namedAuras,
|
|
...(playerControl
|
|
? {
|
|
controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt),
|
|
controlMechanic: playerControl.mechanic,
|
|
schoolLockedUntil: state.activeCast && playerControl.mechanic === "silence"
|
|
? {
|
|
...state.schoolLockedUntil,
|
|
[state.activeCast.school]: Math.max(state.schoolLockedUntil[state.activeCast.school] ?? 0, playerControl.endsAt),
|
|
}
|
|
: state.schoolLockedUntil,
|
|
activeCast: null,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
let appliedDamage = 0;
|
|
for (const hit of incomingHits) {
|
|
const impactOrigin = getMobPosition(hit.mob.id);
|
|
const impactTarget = actorPosition(get(), hit.actorId);
|
|
if (impactOrigin && impactTarget) {
|
|
emitEnemyAttackPresentation(
|
|
hit.mob,
|
|
hit.attack,
|
|
"impact",
|
|
now,
|
|
impactOrigin,
|
|
hit.actorId,
|
|
impactTarget,
|
|
);
|
|
}
|
|
const defendingAuras = aurasForEntity(get().auras, hit.actorId);
|
|
const reflectChance = enemyAttackKind(hit.attack) === "spell"
|
|
? Math.min(1, defendingAuras.reduce((chance, aura) => Math.max(chance, aura.definition.reflectSpellChance ?? 0), 0))
|
|
: 0;
|
|
if (reflectChance > 0 && stableCombatUnit(`${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:reflect`) < reflectChance) {
|
|
get().damageMob(
|
|
hit.mob.id,
|
|
hit.amount,
|
|
now,
|
|
playerThreatSource(get(), `reflect:${hit.attack.id}`),
|
|
hit.attack.school,
|
|
);
|
|
continue;
|
|
}
|
|
if (hit.actorId === PLAYER_AGGRO_ID) {
|
|
const current = get();
|
|
const defender = applyAuraStats(
|
|
playerCharacterStats(
|
|
current.classId,
|
|
current.level,
|
|
current.gearStats,
|
|
current.raceId,
|
|
current.secondaryClassId,
|
|
current.talentModifiers,
|
|
),
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
);
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(current.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(current.health, namedAbsorb.remainingDamage - absorbed);
|
|
const health = current.health - healthDamage;
|
|
const rage = gainRomRage(
|
|
current.classId,
|
|
current.secondaryClassId,
|
|
healthDamage > 0 && (
|
|
hasRomWarriorRagePool(current.classId, current.secondaryClassId)
|
|
|| (!hasRomRagePool(current.classId, current.secondaryClassId) && current.maxResourcePools.rage !== undefined)
|
|
)
|
|
? ROM_RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
current.resource,
|
|
current.maxResource,
|
|
{ ...current.resourcePools },
|
|
{ ...current.maxResourcePools },
|
|
);
|
|
const avoidTrigger = combatResult.dodged
|
|
? "dodge"
|
|
: combatResult.parried
|
|
? "parry"
|
|
: combatResult.blocked
|
|
? "block"
|
|
: null;
|
|
const procEvent: ProcEvent | null = avoidTrigger
|
|
? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id }
|
|
: healthDamage > 0
|
|
? {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: hit.mob.id,
|
|
abilityId: hit.attack.id,
|
|
amount: healthDamage,
|
|
school: hit.attack.school,
|
|
attackKind: enemyAttackKind(hit.attack),
|
|
}
|
|
: null;
|
|
const procProgression = progressionFromState(current);
|
|
procProgression.health = health;
|
|
const procWork: MutableCastWork = {
|
|
classId: current.classId,
|
|
abilities: current.abilities,
|
|
mobs: { ...get().mobs },
|
|
effects: [...current.effects],
|
|
auras: [...namedAbsorb.auras],
|
|
summons: [...current.summons],
|
|
areaEffects: [...current.areaEffects],
|
|
combatEvents: [...current.combatEvents],
|
|
actors: { ...current.actors },
|
|
cooldowns: { ...current.cooldowns },
|
|
health,
|
|
maxHealth: current.maxHealth,
|
|
shield: current.shield - absorbed,
|
|
shieldExpiresAt: current.shieldExpiresAt,
|
|
resource: rage.resource,
|
|
maxResource: current.maxResource,
|
|
resourcePools: { ...rage.resourcePools },
|
|
maxResourcePools: { ...current.maxResourcePools },
|
|
lastResourceSpentAt: { ...current.lastResourceSpentAt },
|
|
comboPoints: current.comboPoints,
|
|
comboTargetId: current.comboTargetId,
|
|
deathKnightRunes: current.deathKnightRunes,
|
|
progression: procProgression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: current.talentRanks,
|
|
talentModifiers: current.talentModifiers,
|
|
gearStats: current.gearStats,
|
|
raceId: current.raceId,
|
|
secondaryClassId: current.secondaryClassId,
|
|
};
|
|
if (healthDamage > 0) {
|
|
procWork.auras = procWork.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("stealth")
|
|
));
|
|
}
|
|
if (procEvent) evaluateProcsInWork(procWork, procEvent, now);
|
|
recordCombatEventInWork(procWork, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: combatResult.rawDamage,
|
|
effectiveAmount: healthDamage,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed + absorbed,
|
|
critical: combatResult.critical,
|
|
outcome: combatResult.outcome,
|
|
});
|
|
set({
|
|
auras: procWork.auras,
|
|
mobs: procWork.mobs,
|
|
shield: procWork.shield,
|
|
health: procWork.health,
|
|
resource: procWork.resource,
|
|
resourcePools: procWork.resourcePools,
|
|
cooldowns: procWork.cooldowns,
|
|
combatEvents: procWork.combatEvents,
|
|
actors: procWork.actors,
|
|
lastCombatResult: combatResult,
|
|
activeCast: procWork.health <= 0
|
|
? null
|
|
: healthDamage > 0
|
|
? applyWrathCastPushback(current.activeCast, now, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`)
|
|
: current.activeCast,
|
|
autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId,
|
|
nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound")
|
|
: current.playerAnimationEvent,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
procWork.health <= 0
|
|
? "You have been defeated."
|
|
: `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)}.`,
|
|
{ amount: healthDamage, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
appliedDamage += healthDamage;
|
|
} else {
|
|
const partyState = usePartyStore.getState();
|
|
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
|
|
if (summon) {
|
|
const current = get();
|
|
const adjusted = resolveAuraDamageValue(
|
|
hit.amount,
|
|
aurasForEntity(current.auras, summon.id),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const absorbed = absorbAuraDamage(current.auras, {
|
|
targetId: summon.id,
|
|
amount: adjusted,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
set({
|
|
auras: absorbed.auras,
|
|
summons: current.summons.flatMap((candidate) => candidate.id !== summon.id
|
|
? [candidate]
|
|
: candidate.health - applied > 0
|
|
? [{ ...candidate, health: candidate.health - applied }]
|
|
: []),
|
|
combatEvents: recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: summon.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: hit.amount,
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical: false,
|
|
outcome: applied > 0 ? "hit" : "absorb",
|
|
}),
|
|
});
|
|
appliedDamage += applied;
|
|
continue;
|
|
}
|
|
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
|
|
if (onlineActor) {
|
|
const current = get();
|
|
const adjusted = resolveAuraDamageValue(
|
|
hit.amount,
|
|
aurasForEntity(current.auras, onlineActor.id),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const absorbed = absorbAuraDamage(current.auras, {
|
|
targetId: onlineActor.id,
|
|
amount: adjusted,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
const nextActor: CombatActorState = {
|
|
...onlineActor,
|
|
health: onlineActor.health - damaged,
|
|
alive: onlineActor.health - damaged > 0,
|
|
cast: onlineActor.health - damaged > 0 ? onlineActor.cast : null,
|
|
};
|
|
set({
|
|
actors: { ...current.actors, [nextActor.id]: nextActor },
|
|
auras: absorbed.auras,
|
|
combatEvents: recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: onlineActor.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: hit.amount,
|
|
effectiveAmount: damaged,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical: false,
|
|
outcome: damaged > 0 ? "hit" : "absorb",
|
|
}),
|
|
});
|
|
appliedDamage += damaged;
|
|
continue;
|
|
}
|
|
const storedMember = partyState.members.find((candidate) => candidate.id === hit.actorId);
|
|
if (!storedMember) continue;
|
|
const currentBeforeHit = get();
|
|
const authoritativeActor = companionActorFromMember(
|
|
storedMember,
|
|
entityAbsorbAmount(currentBeforeHit.auras, storedMember.id),
|
|
currentBeforeHit.actors[storedMember.id],
|
|
);
|
|
const member = memberFromCompanionActor(storedMember, authoritativeActor);
|
|
if (member.health <= 0) continue;
|
|
const defender = applyAuraStats(
|
|
deriveCharacterStats(
|
|
member.classId,
|
|
member.level,
|
|
member.gearStats,
|
|
member.raceId,
|
|
),
|
|
aurasForEntity(get().auras, member.id),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
hit.amount,
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
);
|
|
const currentAuras = get().auras;
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(currentAuras, hit.actorId),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(currentAuras, {
|
|
targetId: hit.actorId,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const redirectAura = aurasForEntity(namedAbsorb.auras, hit.actorId)
|
|
.find((aura) => (aura.definition.redirectDamagePercent ?? 0) > 0);
|
|
const redirectFraction = Math.min(1, Math.max(0, redirectAura?.definition.redirectDamagePercent ?? 0));
|
|
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
|
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
|
: 0;
|
|
const damaged = Math.min(
|
|
authoritativeActor.health,
|
|
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
|
);
|
|
let nextCast = authoritativeActor.cast;
|
|
if (damaged > 0 && nextCast && nextCast.pushbackHits < 2) {
|
|
const castingAbility = abilityById(nextCast.abilityId);
|
|
const resistance = castingAbility ? abilityPushbackResistance(castingAbility) : 0;
|
|
if (stableCombatUnit(`${member.id}:${hit.attack.id}:${hit.attackRevision}:pushback`) >= resistance) {
|
|
nextCast = {
|
|
...nextCast,
|
|
pushbackHits: nextCast.pushbackHits + 1,
|
|
completesAt: nextCast.mode === "channel"
|
|
? Math.max(now, nextCast.completesAt - nextCast.originalDurationMs * 0.25)
|
|
: nextCast.completesAt + 500,
|
|
};
|
|
}
|
|
}
|
|
const nextActor: CombatActorState = {
|
|
...authoritativeActor,
|
|
health: authoritativeActor.health - damaged,
|
|
alive: authoritativeActor.health - damaged > 0,
|
|
cast: authoritativeActor.health - damaged > 0 ? nextCast : null,
|
|
};
|
|
mirrorCompanionActor(nextActor);
|
|
if (redirected > 0) {
|
|
const player = get();
|
|
const transferred = Math.min(player.health, redirected);
|
|
set({ health: player.health - transferred, activeCast: player.health - transferred <= 0 ? null : player.activeCast });
|
|
appliedDamage += transferred;
|
|
}
|
|
appliedDamage += damaged;
|
|
set({
|
|
actors: { ...get().actors, [nextActor.id]: nextActor },
|
|
auras: namedAbsorb.auras,
|
|
combatEvents: recordCombatEvent(get().combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: member.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: combatResult.rawDamage,
|
|
effectiveAmount: damaged,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed,
|
|
critical: combatResult.critical,
|
|
outcome: combatResult.outcome,
|
|
}),
|
|
lastCombatResult: combatResult,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
`${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)} against ${member.name}.`,
|
|
{ amount: damaged, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
}
|
|
}
|
|
for (const control of memberControls) {
|
|
const current = get();
|
|
const summon = current.summons.find((candidate) => candidate.id === control.memberId);
|
|
if (summon) {
|
|
const nextSummon = {
|
|
...summon,
|
|
controlledUntil: Math.max(summon.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
schoolLockedUntil: control.mechanic === "silence" && summon.cast
|
|
? {
|
|
...summon.schoolLockedUntil,
|
|
[summon.cast.school]: Math.max(summon.schoolLockedUntil[summon.cast.school] ?? 0, control.endsAt),
|
|
}
|
|
: summon.schoolLockedUntil,
|
|
};
|
|
set({
|
|
summons: current.summons.map((candidate) => candidate.id === summon.id ? nextSummon : candidate),
|
|
actors: {
|
|
...current.actors,
|
|
[summon.id]: combatActorFromSummon(nextSummon, current.level, current.auras),
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
const partyState = usePartyStore.getState();
|
|
const storedMember = partyState.members.find((member) => member.id === control.memberId);
|
|
if (!storedMember) {
|
|
const onlineActor = onlinePlayerRuntimeActor(control.memberId);
|
|
if (onlineActor) {
|
|
set({
|
|
actors: {
|
|
...current.actors,
|
|
[onlineActor.id]: {
|
|
...onlineActor,
|
|
controlledUntil: Math.max(onlineActor.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const actor = companionActorFromMember(
|
|
storedMember,
|
|
entityAbsorbAmount(current.auras, storedMember.id),
|
|
current.actors[storedMember.id],
|
|
);
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
controlledUntil: Math.max(actor.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
schoolLockedUntil: control.mechanic === "silence" && actor.cast
|
|
? {
|
|
...actor.schoolLockedUntil,
|
|
[actor.cast.school]: Math.max(actor.schoolLockedUntil[actor.cast.school] ?? 0, control.endsAt),
|
|
}
|
|
: actor.schoolLockedUntil,
|
|
};
|
|
set({ actors: { ...current.actors, [nextActor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
partyState.updateMemberCombat(control.memberId, {
|
|
controlMechanic: control.mechanic,
|
|
});
|
|
}
|
|
return appliedDamage;
|
|
},
|
|
|
|
tick: (deltaSeconds, now = combatNow()) => {
|
|
const partyHotTicks: Array<{
|
|
memberId: string;
|
|
amount: number;
|
|
sourceActorId: AggroActorId;
|
|
sourceAbilityId: string;
|
|
}> = [];
|
|
set((state) => {
|
|
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
|
const resourceProfile = resourceProfileForClass(state.classId);
|
|
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
|
|
const baseRegeneration = applyTalentModifierValue(
|
|
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
|
|
? 0
|
|
: resourceProfile.regenerationPerSecond
|
|
+ state.gearStats.mp5 / 5
|
|
+ state.gearStats.spirit * 0.02,
|
|
state.talentModifiers,
|
|
"resource-regeneration-percent",
|
|
);
|
|
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
|
const regeneration = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
|
playerAuras,
|
|
"power",
|
|
"regeneration",
|
|
);
|
|
const activePool = advanceCombatResourcePool(normalizeActorResourcePool(
|
|
resourceProfile.type,
|
|
state.resource,
|
|
state.maxResource,
|
|
regeneration,
|
|
state.lastResourceSpentAt[resourceProfile.type] ?? 0,
|
|
), {
|
|
inCombat,
|
|
actorLevel: state.level,
|
|
now,
|
|
deltaSeconds: delta,
|
|
manaSpiritRegenerationPerSecond: state.gearStats.spirit * 0.02,
|
|
});
|
|
let resource = activePool.current;
|
|
const resourcePools: Partial<Record<ResourceType, number>> = { ...state.resourcePools };
|
|
const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId);
|
|
const passiveRates: Partial<Record<ResourceType, number>> = {
|
|
mana: 4,
|
|
rage: 0,
|
|
energy: 10,
|
|
focus: 6,
|
|
"runic-power": 0,
|
|
};
|
|
for (const [type, maximum] of Object.entries(state.maxResourcePools) as [ResourceType, number][]) {
|
|
if (type === resourceProfile.type) continue;
|
|
resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
|
|
type,
|
|
resourcePools[type] ?? 0,
|
|
maximum,
|
|
passiveRates[type] ?? 0,
|
|
state.lastResourceSpentAt[type] ?? 0,
|
|
), {
|
|
inCombat,
|
|
actorLevel: state.level,
|
|
now,
|
|
deltaSeconds: delta,
|
|
manaSpiritRegenerationPerSecond: 0,
|
|
}).current;
|
|
}
|
|
resourcePools[resourceProfile.type] = resource;
|
|
let shield = state.shield;
|
|
let shieldExpiresAt = state.shieldExpiresAt;
|
|
if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) {
|
|
shield = 0;
|
|
shieldExpiresAt = 0;
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [],
|
|
auras: [...expireAuras(state.auras, now).active],
|
|
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
|
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: Math.min(
|
|
state.maxHealth,
|
|
state.health + resolveAuraStatValue(
|
|
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
|
playerAuras,
|
|
"health-regeneration",
|
|
) * delta,
|
|
),
|
|
maxHealth: state.maxHealth,
|
|
shield,
|
|
shieldExpiresAt,
|
|
resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
|
|
let autoAttackTargetId = state.autoAttackTargetId;
|
|
let nextAutoAttackAt = state.nextAutoAttackAt;
|
|
let autoAttackEvent: CharacterCombatAnimationEvent | null = null;
|
|
const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null;
|
|
if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
} else {
|
|
const autoAttackTargetPosition = getMobPosition(autoAttackTargetId!);
|
|
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
|
|
const canAutoAttack = (
|
|
!state.activeCast
|
|
&& state.controlledUntil <= now
|
|
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
|
|
&& Boolean(autoAttackTargetPosition)
|
|
&& combatHasLineOfSight(state.playerPosition, autoAttackTargetPosition ?? state.playerPosition)
|
|
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
|
|
);
|
|
if (canAutoAttack) {
|
|
const profile = playerAutoAttackProfile(state);
|
|
if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
ROM_CHAMPION_RAGE_PER_SECOND * profile.hasteMultiplier * delta,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (now >= nextAutoAttackAt) {
|
|
const offHandId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.["off-hand"];
|
|
const isDualWielding = Boolean(
|
|
offHandId && state.inventory.some((item) => item.instanceId === offHandId && item.weaponType),
|
|
);
|
|
const autoAttackSchool = state.gearStats.meleeWeapon?.damageSchool ?? "physical";
|
|
const result = resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
autoAttackTargetId!,
|
|
profile.damage,
|
|
now,
|
|
playerThreatSource(state),
|
|
"melee",
|
|
autoAttackSchool,
|
|
`${PLAYER_AGGRO_ID}:auto:${autoAttackTargetId}:${now}`,
|
|
undefined,
|
|
false,
|
|
{ isAutoAttack: true, isDualWielding },
|
|
);
|
|
if (result.amount > 0 && (
|
|
hasNativeRomWarriorRage
|
|
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && work.maxResourcePools.rage !== undefined)
|
|
)) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
ROM_RAGE_PER_AUTO_ATTACK,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
|
|
if (result.amount > 0) {
|
|
const targetPosition = getMobPosition(autoAttackTargetId!);
|
|
emitCombatPresentation({
|
|
occurredAt: now,
|
|
phase: "release",
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: autoAttackTargetId!,
|
|
abilityId: "player-auto-attack",
|
|
name: "Auto Attack",
|
|
source: "fallback",
|
|
school: autoAttackSchool,
|
|
delivery: "melee",
|
|
origin: state.playerPosition,
|
|
targetPosition: targetPosition ?? state.playerPosition,
|
|
});
|
|
}
|
|
nextAutoAttackAt = now + profile.intervalMs;
|
|
if (result.killed) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const effect of state.effects) {
|
|
let remainingTicks = effect.remainingTicks;
|
|
let nextTickAt = effect.nextTickAt;
|
|
while (remainingTicks > 0 && nextTickAt <= now) {
|
|
const ability = abilityById(effect.sourceAbilityId);
|
|
const baseAmount = effect.fixedAmount ?? (ability
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
progression.level,
|
|
state.gearStats,
|
|
state.classId,
|
|
ability,
|
|
)
|
|
: Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level))));
|
|
const amount = ability && effect.fixedAmount === undefined
|
|
? talentAdjustedAmount(
|
|
baseAmount,
|
|
state.talentModifiers,
|
|
effect.kind === "dot" ? "damage-percent" : "healing-percent",
|
|
ability,
|
|
true,
|
|
)
|
|
: baseAmount;
|
|
if (effect.kind === "dot" && effect.targetId) {
|
|
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
effect.targetId,
|
|
amount,
|
|
nextTickAt,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
dotAttackKind,
|
|
effect.damageSchool ?? "physical",
|
|
`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}`,
|
|
ability ?? undefined,
|
|
true,
|
|
{
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
},
|
|
);
|
|
} else if (effect.kind === "hot") {
|
|
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const modifiedHealing = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(work.auras, effect.sourceActorId),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, hotTargetId),
|
|
"taken",
|
|
));
|
|
if (effect.targetId) {
|
|
const target = work.actors[effect.targetId]
|
|
?? ensureCompanionActorInWork(work, effect.targetId)
|
|
?? (work.summons.some((summon) => summon.id === effect.targetId)
|
|
? combatActorFromSummon(work.summons.find((summon) => summon.id === effect.targetId)!, progression.level, work.auras)
|
|
: null);
|
|
if (target?.alive) partyHotTicks.push({
|
|
memberId: target.id,
|
|
amount: modifiedHealing,
|
|
sourceActorId: effect.sourceActorId,
|
|
sourceAbilityId: effect.sourceAbilityId,
|
|
});
|
|
} else {
|
|
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(
|
|
work,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
evaluateProcsInWork(work, {
|
|
trigger: "periodic",
|
|
subjectId: effect.sourceActorId,
|
|
otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: effect.sourceAbilityId,
|
|
amount,
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}, nextTickAt);
|
|
if (ability) {
|
|
const sourceActorId = effect.sourceActorId;
|
|
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
|
|
const origin = sourceActorId === PLAYER_AGGRO_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
|
|
const targetPosition = effect.targetId
|
|
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
|
|
: state.playerPosition;
|
|
emitCombatPresentation({
|
|
occurredAt: nextTickAt,
|
|
phase: "tick",
|
|
actorGroup,
|
|
sourceActorId,
|
|
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery: presentationDeliveryForAbility(ability),
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
});
|
|
}
|
|
remainingTicks -= 1;
|
|
nextTickAt += effect.intervalMs;
|
|
}
|
|
const targetAlive = effect.kind === "dot"
|
|
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
|
: !effect.targetId || usePartyStore.getState().members.some(
|
|
(member) => member.id === effect.targetId && member.health > 0,
|
|
);
|
|
if (remainingTicks > 0 && targetAlive) {
|
|
work.effects.push(
|
|
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
|
? effect
|
|
: { ...effect, remainingTicks, nextTickAt },
|
|
);
|
|
}
|
|
}
|
|
|
|
work.areaEffects = work.areaEffects.map((area) => {
|
|
let nextTickAt = area.nextTickAt;
|
|
const catalogAbility = abilityById(area.sourceAbilityId);
|
|
const ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level) : null;
|
|
while (ability && nextTickAt <= now && nextTickAt < area.expiresAt) {
|
|
const resolvedFixedKinds = new Set<"dot" | "hot">();
|
|
for (const periodic of ability.effects) {
|
|
if (periodic.kind !== "dot" && periodic.kind !== "hot") continue;
|
|
const fixedAmount = periodic.kind === "dot" ? area.fixedDamagePerTick : area.fixedHealingPerTick;
|
|
if (fixedAmount !== undefined && resolvedFixedKinds.has(periodic.kind)) continue;
|
|
if (fixedAmount !== undefined) resolvedFixedKinds.add(periodic.kind);
|
|
const amount = fixedAmount ?? talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(periodic, state.level, state.gearStats, state.classId, ability),
|
|
state.talentModifiers,
|
|
periodic.kind === "dot" ? "damage-percent" : "healing-percent",
|
|
ability,
|
|
true,
|
|
);
|
|
if (periodic.kind === "dot") {
|
|
for (const id of nearbyMobRuntimeIds(area.position, area.radius)) {
|
|
const position = getMobPosition(id);
|
|
if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue;
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
nextTickAt,
|
|
threatSourceForActor(state, area.sourceActorId, ability.id, true),
|
|
abilityAttackKind(state.classId, ability),
|
|
abilityDamageSchool(state.classId, ability),
|
|
`${area.id}:${id}:${nextTickAt}`,
|
|
ability,
|
|
true,
|
|
{ canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
|
|
);
|
|
}
|
|
} else {
|
|
const partyMembers = usePartyStore.getState().members;
|
|
for (const friendlyId of [PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id)]) {
|
|
const position = friendlyId === PLAYER_AURA_ENTITY_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(friendlyId);
|
|
if (!position
|
|
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
|
|| !combatHasLineOfSight(area.position, position)) continue;
|
|
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
|
const healed = member
|
|
? healCompanionActorInWork(work, friendlyId, amount)
|
|
: Math.min(amount, work.maxHealth - work.health);
|
|
if (!member) {
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
}
|
|
work.totalHealing += healed;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: nextTickAt,
|
|
kind: "healing",
|
|
sourceActorId: area.sourceActorId,
|
|
targetActorId: friendlyId,
|
|
abilityId: ability.id,
|
|
rawAmount: amount,
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, amount - healed),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
nextTickAt += area.tickIntervalMs;
|
|
}
|
|
return nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt };
|
|
});
|
|
|
|
work.summons = work.summons.map((summon) => {
|
|
if (
|
|
summon.health <= 0
|
|
|| summon.controlledUntil > now
|
|
|| summon.nextActionAt > now
|
|
|| summon.globalCooldownEndsAt > now
|
|
|| summon.cast
|
|
|| summon.attackDamage <= 0
|
|
) return summon;
|
|
const summonAbility = abilityById(summon.sourceAbilityId);
|
|
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const injured = [
|
|
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
|
|
...partyMembers.map((member) => ({
|
|
id: member.id,
|
|
health: member.health,
|
|
maxHealth: member.maxHealth,
|
|
position: getPartyRuntimePosition(member.id),
|
|
})),
|
|
].filter((candidate) => candidate.health > 0
|
|
&& candidate.health < candidate.maxHealth
|
|
&& candidate.position
|
|
&& Math.sqrt(distanceSquared(summon.position, candidate.position)) <= 30
|
|
&& combatHasLineOfSight(summon.position, candidate.position))
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
|
|
const target = injured[0];
|
|
if (target) {
|
|
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
|
? Math.min(summon.attackDamage, work.maxHealth - work.health)
|
|
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
|
if (target.id === PLAYER_AURA_ENTITY_ID) {
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
}
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: summon.id,
|
|
targetActorId: target.id,
|
|
abilityId: summon.sourceAbilityId,
|
|
rawAmount: summon.attackDamage,
|
|
effectiveAmount: healed,
|
|
overheal: summon.attackDamage - healed,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
}
|
|
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
|
}
|
|
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead
|
|
? summon.targetId
|
|
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
|
|
? state.selectedTargetId
|
|
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
|
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
|
const targetPosition = getMobPosition(targetId);
|
|
let position = summon.position;
|
|
if (targetPosition) {
|
|
const dx = targetPosition[0] - position[0];
|
|
const dz = targetPosition[2] - position[2];
|
|
const length = Math.hypot(dx, dz);
|
|
if (summon.kind !== "totem" && length > 4) {
|
|
const step = Math.min(length - 3, 4 * delta);
|
|
const requested: CombatPosition = [position[0] + dx / length * step, position[1], position[2] + dz / length * step];
|
|
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
|
}
|
|
if (
|
|
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
|
&& combatHasLineOfSight(position, targetPosition)
|
|
) {
|
|
damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
targetId,
|
|
summon.attackDamage,
|
|
now,
|
|
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
|
|
"physical",
|
|
);
|
|
}
|
|
}
|
|
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
|
});
|
|
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const statuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const auras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
const corpseExpired = mob.dead
|
|
&& !mob.despawned
|
|
&& mob.corpseDespawnAt !== null
|
|
&& now >= mob.corpseDespawnAt;
|
|
if (
|
|
statuses.length !== mob.statuses.length
|
|
|| auras.length !== mob.auras.length
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| corpseExpired
|
|
) {
|
|
work.mobs[id] = {
|
|
...mob,
|
|
statuses,
|
|
auras,
|
|
schoolLockedUntil,
|
|
activeCast: mob.dead ? null : mob.activeCast,
|
|
despawned: mob.despawned || corpseExpired,
|
|
};
|
|
}
|
|
}
|
|
|
|
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
|
|
for (const [actorId, actor] of Object.entries(work.actors)) {
|
|
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) {
|
|
delete work.actors[actorId];
|
|
}
|
|
}
|
|
for (const summon of work.summons) {
|
|
work.actors[summon.id] = combatActorFromSummon(summon, progression.level, work.auras);
|
|
}
|
|
|
|
const levelled = progression.levelsGained > 0;
|
|
if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
return {
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
lastResourceSpentAt: work.lastResourceSpentAt,
|
|
deathKnightRunes: work.deathKnightRunes,
|
|
talentModifiers: work.talentModifiers,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
health: progression.levelsGained > 0 ? progression.health : work.health,
|
|
maxHealth: progression.maxHealth,
|
|
controlledUntil: state.controlledUntil > now ? state.controlledUntil : 0,
|
|
controlMechanic: state.controlledUntil > now ? state.controlMechanic : null,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
areaEffects: work.areaEffects,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
cooldowns: work.cooldowns,
|
|
schoolLockedUntil: Object.fromEntries(
|
|
Object.entries(state.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
),
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId,
|
|
selectedFriendlyActorId: state.selectedFriendlyActorId && !work.actors[state.selectedFriendlyActorId]
|
|
? null
|
|
: state.selectedFriendlyActorId,
|
|
autoAttackTargetId,
|
|
nextAutoAttackAt,
|
|
playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent,
|
|
comboPoints: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? 0 : work.comboPoints,
|
|
comboTargetId: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? null : work.comboTargetId,
|
|
feedback: levelled
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: work.combatResults.length > 0
|
|
&& work.combatResults.at(-1)?.outcome !== "hit"
|
|
? nextFeedback("damage", `Attack ${combatResultDescription(work.combatResults.at(-1)!)}.`, {
|
|
amount: work.combatResults.at(-1)!.finalDamage,
|
|
})
|
|
: state.feedback,
|
|
};
|
|
});
|
|
|
|
for (const tick of partyHotTicks) {
|
|
const healed = get().healActor(tick.memberId, tick.amount, {
|
|
sourceActorId: tick.sourceActorId,
|
|
abilityId: tick.sourceAbilityId,
|
|
amountAlreadyModified: true,
|
|
now,
|
|
}).effectiveAmount;
|
|
if (healed > 0) {
|
|
get().addHealingThreat(
|
|
threatSourceForActor(get(), tick.sourceActorId, tick.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
|
|
const cast = get().activeCast;
|
|
if (cast?.mode === "channel") {
|
|
const catalogAbility = abilityById(cast.abilityId);
|
|
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
|
? undefined
|
|
: get().talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
const ability = catalogAbility
|
|
? abilityAtLevel(catalogAbility, get().level, learnedTalentRank)
|
|
: null;
|
|
let ticksCompleted = cast.ticksCompleted;
|
|
let nextTickAt = cast.nextTickAt;
|
|
while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now) {
|
|
const target = cast.targetId ? get().mobs[cast.targetId] : null;
|
|
const friendlyTarget = cast.targetId
|
|
? usePartyStore.getState().members.find((member) => member.id === cast.targetId)
|
|
: null;
|
|
const targetPosition = cast.targetId
|
|
? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId)
|
|
: get().playerPosition;
|
|
const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target));
|
|
const distance = cast.targetId
|
|
? channelHostile
|
|
? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin))
|
|
: Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin))
|
|
: 0;
|
|
const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0;
|
|
const maximumRange = ability ? talentAdjustedRange(ability.range.max, ability, get().talentModifiers) : 0;
|
|
const targetUnavailable = channelHostile
|
|
? !target || target.dead || !targetPosition
|
|
: (ability.target === "friendly" || ability.target === "any") && cast.targetId
|
|
? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition
|
|
: false;
|
|
if (targetUnavailable || distance < minimumRange || (cast.targetId !== null && distance > maximumRange)) {
|
|
get().cancelCast((target?.dead || friendlyTarget?.health === 0)
|
|
? "Channel ended: the target was defeated."
|
|
: "Channel interrupted: target unavailable.");
|
|
return;
|
|
}
|
|
const damageAmount = ability.effects.reduce(
|
|
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
) * (effect.kind === "dot" ? effect.ticks : 1)
|
|
: 0),
|
|
0,
|
|
) / cast.totalTicks;
|
|
const healingAmount = ability.effects.reduce(
|
|
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
) * (effect.kind === "hot" ? effect.ticks : 1)
|
|
: 0),
|
|
0,
|
|
) / cast.totalTicks;
|
|
if (damageAmount > 0) {
|
|
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
|
const channelTargets = ability.radius === undefined
|
|
? (cast.targetId && channelHostile ? [cast.targetId] : [])
|
|
: nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(ability.radius, ability, get().talentModifiers))
|
|
.filter((id) => !get().mobs[id]?.dead);
|
|
for (const channelTargetId of channelTargets) get().damageMob(
|
|
channelTargetId,
|
|
talentAdjustedAmount(
|
|
damageAmount,
|
|
get().talentModifiers,
|
|
"damage-percent",
|
|
ability,
|
|
),
|
|
nextTickAt,
|
|
playerThreatSource(get(), ability.id, true),
|
|
abilityDamageSchool(get().classId, ability),
|
|
{
|
|
attackKind: abilityAttackKind(get().classId, ability),
|
|
seed: `${PLAYER_AGGRO_ID}:${ability.id}:${channelTargetId}:${nextTickAt}:channel:${ticksCompleted}`,
|
|
abilityId: ability.id,
|
|
periodic: true,
|
|
rules: {
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
if (healingAmount > 0) {
|
|
const perTargetHealing = talentAdjustedAmount(
|
|
healingAmount,
|
|
get().talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
let healed = 0;
|
|
if (!channelHostile && (ability.target === "friendly" || ability.target === "any") && cast.targetId) {
|
|
const targetMember = usePartyStore.getState().members.find((member) => member.id === cast.targetId);
|
|
if (targetMember) {
|
|
const current = get();
|
|
const actor = companionActorFromMember(
|
|
targetMember,
|
|
entityAbsorbAmount(current.auras, targetMember.id),
|
|
current.actors[targetMember.id],
|
|
);
|
|
const effective = actor.alive
|
|
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
|
|
: 0;
|
|
if (effective > 0) {
|
|
const nextActor = { ...actor, health: actor.health + effective };
|
|
set({ actors: { ...current.actors, [actor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
healed += effective;
|
|
}
|
|
}
|
|
} else {
|
|
healed += get().healPlayer(perTargetHealing);
|
|
if (ability.radius !== undefined) {
|
|
for (const member of usePartyStore.getState().members) {
|
|
const current = get();
|
|
const actor = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(current.auras, member.id),
|
|
current.actors[member.id],
|
|
);
|
|
const effective = actor.alive
|
|
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
|
|
: 0;
|
|
if (effective <= 0) continue;
|
|
const nextActor = { ...actor, health: actor.health + effective };
|
|
set({ actors: { ...current.actors, [actor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
healed += effective;
|
|
}
|
|
}
|
|
}
|
|
if (healed > 0) get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
|
|
}
|
|
const drainedResource = ability.effects.reduce((total, effect) => (
|
|
effect.kind === "resource-drain" && !effect.burn ? total + effect.amount : total
|
|
), 0) / cast.totalTicks;
|
|
if (drainedResource > 0) {
|
|
const activeResource = resourceProfileForClass(get().classId).type;
|
|
const maximum = get().maxResourcePools[activeResource] ?? get().maxResource;
|
|
const amount = ability.effects.some((effect) => effect.kind === "resource-drain" && effect.percentage)
|
|
? maximum * drainedResource / 100
|
|
: drainedResource;
|
|
const pools = { ...get().resourcePools };
|
|
pools[activeResource] = clamp((pools[activeResource] ?? get().resource) + amount, 0, maximum);
|
|
set({ resource: pools[activeResource]!, resourcePools: pools });
|
|
}
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"tick",
|
|
nextTickAt,
|
|
get().playerPosition,
|
|
cast.targetId,
|
|
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
|
|
);
|
|
ticksCompleted += 1;
|
|
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
|
|
}
|
|
if (get().activeCast?.id !== cast.id) return;
|
|
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
|
|
if (ability) get().castAbility(cast.abilityId, cast.origin, now, cast);
|
|
else set({ activeCast: null });
|
|
} else if (ticksCompleted !== cast.ticksCompleted) {
|
|
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
|
|
}
|
|
return;
|
|
}
|
|
if (cast && now >= cast.completesAt) {
|
|
set({ activeCast: null });
|
|
get().castAbility(cast.abilityId, cast.origin, now, cast);
|
|
}
|
|
},
|
|
|
|
resetEncounter: () => set((state) => {
|
|
resetCombatPresentationSession();
|
|
for (const mob of Object.values(state.mobs)) {
|
|
if (mob.summonedBy) removeMobPosition(mob.id);
|
|
}
|
|
const mobs = Object.fromEntries(Object.entries(state.mobs)
|
|
.filter(([, mob]) => !mob.summonedBy)
|
|
.map(([id, mob]) => [id, {
|
|
...mob,
|
|
health: mob.maxHealth,
|
|
dead: false,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle" as const,
|
|
homePosition: null,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
nextWoundAnimationAt: 0,
|
|
}]));
|
|
return {
|
|
health: state.maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
|
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
|
type,
|
|
type === resourceProfileForClass(state.classId).type
|
|
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource)
|
|
: 0,
|
|
])),
|
|
lastResourceSpentAt: {},
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [],
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs,
|
|
selectedTargetId: null,
|
|
selectedFriendlyActorId: null,
|
|
effects: [],
|
|
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
|
summons: [],
|
|
areaEffects: [],
|
|
pendingGroundTarget: null,
|
|
combatEvents: [],
|
|
actors: {},
|
|
lastCombatResult: null,
|
|
feedback: nextFeedback("success", "The encounter has been reset."),
|
|
};
|
|
}),
|
|
|
|
allocateTalent: (treeId) => {
|
|
const state = get();
|
|
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
|
|
if (!result.ok) {
|
|
const message = result.reason === "no-points"
|
|
? "No talent points are available."
|
|
: result.reason === "ability-essence"
|
|
? "Not enough Ability Essence."
|
|
: result.reason === "talent-essence"
|
|
? "Not enough Talent Essence."
|
|
: result.reason === "point-gate"
|
|
? "Invest the required Ability and Talent Essence in this tab first."
|
|
: result.reason === "level-locked"
|
|
? "That node requires a higher level."
|
|
: result.reason === "max-rank"
|
|
? "That talent is already at maximum rank."
|
|
: result.reason === "tier-locked"
|
|
? "Spend five points per earlier tier in this tree first."
|
|
: result.reason === "prerequisite"
|
|
? "The prerequisite talent must be completed first."
|
|
: "That talent is unavailable.";
|
|
set({ feedback: nextFeedback("error", message) });
|
|
return false;
|
|
}
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
talentRanks: result.ranks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
|
|
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
resetTalents: () => set((state) => {
|
|
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
return {
|
|
talentRanks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
|
|
feedback: nextFeedback("talent", "Talent points reset."),
|
|
};
|
|
}),
|
|
|
|
setActionBinding: (layer, control, abilityId) => {
|
|
const state = get();
|
|
if (abilityId !== null && !state.abilities.some((ability) => (
|
|
ability.id === abilityId && !ability.passive
|
|
))) return false;
|
|
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
|
|
return true;
|
|
},
|
|
resetActionBindingLayer: (layer) => set((state) => ({
|
|
actionBindings: restoreActionBindingLayer(
|
|
state.actionBindings,
|
|
layer,
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
resetActionBindings: () => set((state) => ({
|
|
actionBindings: defaultActionBindings(
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
|
|
updateSettings: (patch) => set((state) => {
|
|
const settings = normalizedSettings(state.settings, patch);
|
|
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
|
}),
|
|
resetSettings: () => set((state) => (
|
|
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
|
? state
|
|
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
|
)),
|
|
}));
|
|
|
|
export function selectedMob(state: CombatState): MobCombatState | null {
|
|
return state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null;
|
|
}
|
|
|
|
export function cooldownRemainingMs(
|
|
state: Pick<CombatState, "cooldowns">,
|
|
abilityId: string,
|
|
now = combatNow(),
|
|
): number {
|
|
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
|
}
|
|
|
|
export function globalCooldownRemainingMs(
|
|
state: Pick<CombatState, "globalCooldownEndsAt">,
|
|
now = combatNow(),
|
|
): number {
|
|
return Math.max(0, state.globalCooldownEndsAt - now);
|
|
}
|
|
|
|
let collectingBossLoot = false;
|
|
useCombatStore.subscribe((state) => {
|
|
if (collectingBossLoot) return;
|
|
if (useGameStore.getState().gameMode === "manastorm") return;
|
|
const lootableBosses = Object.values(state.mobs).filter((mob) => (
|
|
mob.boss
|
|
&& mob.dead
|
|
&& mob.hasLoot
|
|
&& !mob.looted
|
|
&& !mob.despawned
|
|
));
|
|
if (!lootableBosses.length) return;
|
|
collectingBossLoot = true;
|
|
try {
|
|
for (const boss of lootableBosses) {
|
|
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
|
|
}
|
|
} finally {
|
|
collectingBossLoot = false;
|
|
}
|
|
});
|
|
|
|
export function combatPercent(current: number, maximum: number): number {
|
|
if (maximum <= 0) return 0;
|
|
return clamp((current / maximum) * 100, 0, 100);
|
|
}
|