forked from phenom/healer-man
10296 lines
416 KiB
TypeScript
10296 lines
416 KiB
TypeScript
import { create } from "zustand";
|
|
import { permanentBuffEffects } from "./partyBuffPolicy";
|
|
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
|
|
import { combatNow } from "./combatClock";
|
|
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
|
import { useGameStore } from "./store";
|
|
import {
|
|
baseClassFor,
|
|
isRomClassId,
|
|
type ClassId,
|
|
type RaceId,
|
|
} from "../app/characterCatalog";
|
|
import {
|
|
abilitiesForClass,
|
|
abilitiesForCharacter,
|
|
abilitiesUnlockedBetweenLevels,
|
|
abilityAtLevel,
|
|
abilityById,
|
|
abilityRankForLevel,
|
|
abilityTalentNodeId,
|
|
defaultActionBarForClass,
|
|
defaultActionBarForCharacter,
|
|
isAbilityUnlocked,
|
|
learnedAbilityForBuild,
|
|
learnedAbilityRank,
|
|
resourceProfileForClass,
|
|
triggeredAbilityBySpellId,
|
|
type AbilityAmountScaling,
|
|
type AbilityDamageResource,
|
|
type AbilityDefinition,
|
|
type AbilityEffect,
|
|
type MobStatusKind,
|
|
type ResourceType,
|
|
} from "./abilityCatalog";
|
|
import {
|
|
abilityPushbackResistance,
|
|
abilityRequiresOutOfCombat,
|
|
canAbilityCastWhileMoving,
|
|
runeCostForAbility,
|
|
targetingRulesForAbility,
|
|
} from "./abilityRules";
|
|
import {
|
|
combatHasLineOfSight,
|
|
combatSpatialActor,
|
|
isPositionInFacingArc,
|
|
isSourceBehindTarget,
|
|
projectCombatGroundPoint,
|
|
} from "./combatSpatial";
|
|
import {
|
|
appendCombatEvent,
|
|
type CombatEventInput,
|
|
type StructuredCombatEvent,
|
|
} from "./combatEvents";
|
|
import {
|
|
createDeathKnightRunes,
|
|
normalizeActorResourcePool,
|
|
type CombatActorState,
|
|
type DeathKnightRuneState,
|
|
} from "./combatActors";
|
|
import {
|
|
companionActorFromMember,
|
|
companionActorWithMemberRuntime,
|
|
memberFromCompanionActor,
|
|
} from "./combatActorAuthority";
|
|
import {
|
|
activateDeathKnightRunes,
|
|
advanceCombatResourcePool,
|
|
spendDeathKnightRunes,
|
|
} from "./combatResources";
|
|
import { cooldownResetCandidates } from "./cooldownReset";
|
|
import { resolveHealing, type HealingResolution } from "./combatHealing";
|
|
import {
|
|
awardExperience,
|
|
healerManExperienceAward,
|
|
normalizeProgression,
|
|
playerCombatPower,
|
|
xpRequiredForNextLevel,
|
|
} from "./progression";
|
|
import {
|
|
runtimeAllocateTalentPoint as allocateTalentPoint,
|
|
runtimeNormalizeTalentRanks as normalizeTalentRanks,
|
|
runtimeResetTalentRanks as resetTalentRanks,
|
|
runtimeTalentNodeById as talentNodeById,
|
|
runtimeTalentPointsForLevel as talentPointsForLevel,
|
|
type RuntimeTalentRanks as TalentRanks,
|
|
} from "./talentRuntimeCatalog";
|
|
import {
|
|
applyTalentModifierValue,
|
|
parseTalentModifiers,
|
|
talentModifiersForRanks,
|
|
type TalentModifier,
|
|
type TalentModifierContext,
|
|
} from "./talentModifiers";
|
|
import {
|
|
getMobPosition,
|
|
getMobRuntimeTransform,
|
|
mobRuntimeDistanceSquared,
|
|
nearbyMobRuntimeIds,
|
|
removeMobPosition,
|
|
setMobPosition,
|
|
type CombatPosition,
|
|
} from "./mobRuntimeRegistry";
|
|
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
|
import {
|
|
getPartyRuntimePosition,
|
|
partyRuntimeDistanceSquared,
|
|
updatePartyRuntimePosition,
|
|
} from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import {
|
|
actionBindingId,
|
|
defaultActionBindings,
|
|
normalizeActionBindings,
|
|
resetActionBindingLayer as restoreActionBindingLayer,
|
|
withActionBinding,
|
|
type ActionBindingControl,
|
|
type ActionBindingLayer,
|
|
type ActionBindings,
|
|
} from "./actionBindings";
|
|
import type {
|
|
CharacterCombatAnimationEvent,
|
|
CharacterCombatAnimationKind,
|
|
} from "./combatAnimation";
|
|
import {
|
|
absorbDamage as absorbAuraDamage,
|
|
applyAura as applyCombatAura,
|
|
dispelAuras as dispelCombatAuras,
|
|
evaluateAuraProcs,
|
|
expireAuras,
|
|
resolveDamageValue as resolveAuraDamageValue,
|
|
resolveHealingValue as resolveAuraHealingValue,
|
|
resolveResourceValue as resolveAuraResourceValue,
|
|
resolveStatValue as resolveAuraStatValue,
|
|
type ActiveAura,
|
|
type AuraDefinition,
|
|
type DispelCategory,
|
|
type ProcEvent,
|
|
type TriggeredAuraProc,
|
|
} from "./combatAuras";
|
|
import { rollBossLoot } from "./lootCatalog";
|
|
import { bossCardForSource } from "./bossCardCatalog";
|
|
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
|
|
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
|
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
|
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
|
import {
|
|
PLAYER_EQUIPMENT_OWNER_ID,
|
|
activeEquipmentStatsForClass,
|
|
equipInventoryItem,
|
|
normalizeEquipment,
|
|
unequipInventorySlot,
|
|
type EquipmentAssignments,
|
|
type EquipmentSlot,
|
|
} from "./equipment";
|
|
import {
|
|
resolvedEquipmentLoadout,
|
|
resolvedEquipmentStats,
|
|
type HeirloomContext,
|
|
} from "./heirloomEquipment";
|
|
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics";
|
|
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset, romMageWarlockCriticalEffects } from "./romMageWarlockOverrides";
|
|
import { romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
|
|
import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies";
|
|
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
|
|
import { specializationById } from "./specializationCatalog";
|
|
import {
|
|
EMPTY_ITEM_STATS,
|
|
itemStatSummary,
|
|
type DamageSchool,
|
|
type ItemStats,
|
|
} from "./itemStats";
|
|
import {
|
|
baseResourcesFor,
|
|
damageAfterEquipmentMitigation,
|
|
deriveCharacterStats,
|
|
equipmentAttackPower,
|
|
hastedDuration,
|
|
healthFromTotalStamina,
|
|
ratingPercent,
|
|
type CharacterStats,
|
|
type WotlkAttackKind,
|
|
} from "./wotlkStats";
|
|
import { healingRecipientIds } from "./healingAbilityRules";
|
|
import {
|
|
ROGUE_DEADLY_POISON_AURA,
|
|
ROGUE_DEADLY_POISON_COATING_TAG,
|
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
|
ROGUE_ENVENOM_BUFF_TAG,
|
|
ROGUE_INSTANT_POISON_TAG,
|
|
hasActiveAuraTag,
|
|
matchingAuraStacks,
|
|
rogueDeadlyPoisonTickDamage,
|
|
rogueEnvenomPreservesDeadlyPoison,
|
|
rogueInstantPoisonDamage,
|
|
roguePoisonApplicationChances,
|
|
} from "./roguePoison";
|
|
import {
|
|
baseMissChancePercent,
|
|
combatAttackerFromCharacterStats,
|
|
combatDefenderFromCharacterStats,
|
|
resolveCombatResult,
|
|
type CombatAttackerStats,
|
|
type CombatDefenderStats,
|
|
type CombatResult,
|
|
type CombatResultRules,
|
|
} from "./combatResults";
|
|
import {
|
|
DEFAULT_ENEMY_ATTACK,
|
|
enemyControlPreventsActions,
|
|
maximumEnemyAttackRange,
|
|
normalizeEnemyAttacks,
|
|
type EnemyAttackAnimation,
|
|
type EnemyAttackDefinition,
|
|
type EnemyControlMechanic,
|
|
type EnemyDamageSchool,
|
|
type EnemyMeleeAutoCriticalProc,
|
|
} from "./enemyAttacks";
|
|
import {
|
|
creatureRuntimeBaseStats,
|
|
creatureTemplateReference,
|
|
type AzerothCoreMechanic,
|
|
type AzerothCoreSchool,
|
|
} from "./azerothCoreReference";
|
|
import {
|
|
dispatchDungeonEncounterSignal,
|
|
dungeonEncounterStateMatches,
|
|
} from "./dungeonEncounterRuntime";
|
|
import {
|
|
PLAYER_AGGRO_ID,
|
|
addActorThreat,
|
|
pruneThreatTable,
|
|
resolveAggroTarget,
|
|
seedActorThreat,
|
|
tauntThreat,
|
|
threatFromDamage,
|
|
threatFromHealing,
|
|
type AggroActorId,
|
|
type ForcedTarget,
|
|
type ThreatSource,
|
|
type ThreatTable,
|
|
} from "./aggro";
|
|
import { distanceSquared, planarDistanceSquared } from "./mobAi";
|
|
import {
|
|
onlinePlayerRuntimeActor,
|
|
onlinePlayerRuntimeActors,
|
|
} from "./onlineSessionRuntime";
|
|
import {
|
|
abilityIsPrecombatPreparation,
|
|
precombatPreparationEffects,
|
|
rotationProfileFor,
|
|
} from "./combatRotation";
|
|
import {
|
|
emitCombatPresentation,
|
|
presentationDeliveryForAbility,
|
|
presentationSchoolForAbility,
|
|
presentationSourceForAbility,
|
|
presentationSourceForSpellId,
|
|
resetCombatPresentationSession,
|
|
type CombatPresentationPhase,
|
|
} from "./combatPresentation";
|
|
|
|
/** Stable target id used by named player auras and HUD consumers. */
|
|
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
|
|
const WEAKENED_SOUL_AURA: AuraDefinition = {
|
|
id: "wow335:priest:weakened-soul", name: "Weakened Soul", disposition: "debuff",
|
|
durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh", dispelCategory: "magic",
|
|
};
|
|
|
|
function hasWeakenedSoul(auras: readonly ActiveAura[], targetId: string, now: number): boolean {
|
|
return auras.some((aura) => aura.targetId === targetId
|
|
&& aura.definition.id === WEAKENED_SOUL_AURA.id
|
|
&& (aura.expiresAt === null || aura.expiresAt > now));
|
|
}
|
|
|
|
function consumableEnrage(aura: ActiveAura, targetId: string, now: number): boolean {
|
|
return aura.targetId === targetId && aura.definition.disposition === "buff"
|
|
&& aura.definition.dispelCategory === "enrage"
|
|
&& !aura.definition.name.startsWith("Enraged Regeneration")
|
|
&& (aura.expiresAt === null || aura.expiresAt > now);
|
|
}
|
|
import {
|
|
DEFAULT_THREAT_METER_POSITION,
|
|
normalizeHudPosition,
|
|
type NormalizedHudPosition,
|
|
} from "./hudLayout";
|
|
import {
|
|
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
|
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
|
normalizeGameplayFrameRateLimit,
|
|
normalizePreviewFrameRateLimit,
|
|
type GameplayFrameRateLimit,
|
|
type PreviewFrameRateLimit,
|
|
} from "./frameRateSettings";
|
|
|
|
export type MinimapRotation = "north-up" | "player-up";
|
|
|
|
export interface GameplaySettings {
|
|
readonly showMobHealthBars: boolean;
|
|
readonly showMobAggroRanges: boolean;
|
|
readonly showTargetFrame: boolean;
|
|
readonly showThreatMeter: boolean;
|
|
readonly threatMeterPosition: NormalizedHudPosition;
|
|
readonly showFloatingCombatText: boolean;
|
|
readonly autoTargetNearest: boolean;
|
|
readonly enableScreenShake: boolean;
|
|
readonly masterVolume: number;
|
|
readonly uiScale: number;
|
|
readonly minimapRotation: MinimapRotation;
|
|
readonly gameplayFrameRate: GameplayFrameRateLimit;
|
|
readonly previewFrameRate: PreviewFrameRateLimit;
|
|
}
|
|
|
|
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
|
showMobHealthBars: true,
|
|
showMobAggroRanges: false,
|
|
showTargetFrame: true,
|
|
showThreatMeter: false,
|
|
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
|
|
showFloatingCombatText: true,
|
|
autoTargetNearest: true,
|
|
enableScreenShake: true,
|
|
masterVolume: 0,
|
|
uiScale: 1,
|
|
minimapRotation: "player-up",
|
|
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
|
|
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
|
|
});
|
|
|
|
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
|
export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
|
|
/** Empty corpses remain visible long enough to read the death, then clean themselves up. */
|
|
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
|
|
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
|
|
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
|
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
|
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
|
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
|
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
|
|
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
|
|
|
|
export interface MobStatus {
|
|
readonly kind: MobStatusKind;
|
|
readonly sourceAbilityId: string;
|
|
readonly sourceActorId?: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
readonly breakOnDamage?: boolean;
|
|
}
|
|
|
|
export interface MobAura {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
readonly lifeStealPercent?: number;
|
|
/** Persistent driver for a content-authored main-hand auto-attack proc. */
|
|
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
|
|
/** A charged modifier consumed by a successful main-hand auto attack. */
|
|
readonly nextMeleeCriticalChancePercent?: number;
|
|
readonly charges?: number;
|
|
}
|
|
|
|
export interface MobMagicResistances {
|
|
readonly arcane: number;
|
|
readonly fire: number;
|
|
readonly frost: number;
|
|
readonly nature: number;
|
|
readonly shadow: number;
|
|
}
|
|
|
|
/** Public enemy cast state used by combat logic now and cast-bar UI later. */
|
|
export interface EnemyActiveCast {
|
|
readonly attackId: string;
|
|
readonly name: string;
|
|
readonly spellId?: number;
|
|
readonly school: EnemyDamageSchool;
|
|
readonly interruptible: boolean;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly sourcePosition: CombatPosition;
|
|
readonly targetPosition: CombatPosition;
|
|
readonly sourceYaw: number;
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
}
|
|
|
|
export interface DiminishingReturnState {
|
|
readonly applications: number;
|
|
readonly resetAt: number;
|
|
}
|
|
|
|
export interface MobCombatState {
|
|
/** When the hostile actor is a player, preserve its actual defense and resources. */
|
|
readonly characterDefender?: CombatDefenderStats;
|
|
readonly characterShield?: number;
|
|
readonly characterResources?: Readonly<Partial<Record<ResourceType, number>>>;
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly serverEntry: number | null;
|
|
readonly creatureFamily: CombatCreatureFamily;
|
|
readonly summonedBy?: string;
|
|
readonly spawnGroupId?: string;
|
|
readonly boss: boolean;
|
|
readonly lootSourceId: string | null;
|
|
readonly level: number;
|
|
readonly armor: number;
|
|
readonly maxMana: number;
|
|
readonly attackPower: number;
|
|
readonly mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities: readonly AzerothCoreSchool[];
|
|
readonly resistances: MobMagicResistances;
|
|
readonly health: number;
|
|
readonly maxHealth: number;
|
|
readonly healthMultiplier: number;
|
|
readonly damageMultiplier: number;
|
|
readonly bonusLootChance: number;
|
|
readonly dead: boolean;
|
|
readonly hasLoot: boolean;
|
|
readonly pendingLoot: readonly InventoryItem[];
|
|
readonly bossCardDrop?: BossCardDrop;
|
|
readonly looted: boolean;
|
|
readonly despawned: boolean;
|
|
readonly diedAt: number | null;
|
|
readonly corpseDespawnAt: number | null;
|
|
readonly xpReward: number;
|
|
readonly experienceGranted: boolean;
|
|
readonly statuses: readonly MobStatus[];
|
|
readonly controlDiminishingReturns?: Readonly<Partial<Record<MobStatusKind, DiminishingReturnState>>>;
|
|
readonly tauntDiminishingReturns?: DiminishingReturnState;
|
|
readonly auras: readonly MobAura[];
|
|
readonly threatByActor: ThreatTable;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly forcedTarget: ForcedTarget | null;
|
|
readonly lastDamageAt: number;
|
|
readonly engaged: boolean;
|
|
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
|
readonly navigationEvadeUntil?: number;
|
|
readonly homePosition: CombatPosition | null;
|
|
readonly nextAttackAt: number;
|
|
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
|
readonly abilityCastCounts: Readonly<Record<string, number>>;
|
|
readonly activeCast: EnemyActiveCast | null;
|
|
readonly schoolLockedUntil: Readonly<Partial<Record<EnemyDamageSchool, number>>>;
|
|
readonly attackRange: number;
|
|
readonly attackDamage: number;
|
|
readonly swingMs: number;
|
|
readonly aggroRange: number;
|
|
readonly leashRange: number;
|
|
readonly moveSpeed: number;
|
|
readonly attackRevision: number;
|
|
readonly attackAnimation: EnemyAttackAnimation;
|
|
readonly attacks: readonly EnemyAttackDefinition[];
|
|
readonly nextAttackIndex: number;
|
|
readonly lastAttackId: string | null;
|
|
readonly woundRevision: number;
|
|
readonly nextWoundAnimationAt: number;
|
|
readonly deathRevision: number;
|
|
}
|
|
|
|
export interface ActiveCast {
|
|
readonly id: number;
|
|
readonly abilityId: string;
|
|
readonly targetId: string | null;
|
|
readonly mode: "cast" | "channel";
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
readonly originalDurationMs: number;
|
|
readonly pushbackHits: number;
|
|
readonly school: DamageSchool;
|
|
readonly canCastWhileMoving: boolean;
|
|
readonly origin: CombatPosition;
|
|
readonly groundPosition: CombatPosition | null;
|
|
readonly totalTicks: number;
|
|
readonly ticksCompleted: number;
|
|
readonly nextTickAt: number;
|
|
}
|
|
|
|
export interface TimedCombatEffect extends AbilityDamageResource {
|
|
readonly id: string;
|
|
readonly kind: "dot" | "hot";
|
|
readonly sourceAbilityId: string;
|
|
readonly sourceActorId: AggroActorId;
|
|
readonly targetId: string | null;
|
|
readonly coefficient: number;
|
|
/** Pre-scaled amount for non-player actors whose equipment is not the player loadout. */
|
|
readonly fixedAmount?: number;
|
|
readonly basePoints?: number;
|
|
readonly dieSides?: number;
|
|
readonly pointsPerLevel?: number;
|
|
readonly coefficientPerLevel?: number;
|
|
readonly bonusPowerCoefficient?: number;
|
|
readonly attackPowerCoefficient?: number;
|
|
readonly spiritCoefficient?: number;
|
|
readonly sourceLevel?: number;
|
|
readonly maxScalingLevel?: number;
|
|
readonly remainingTicks: number;
|
|
readonly intervalMs: number;
|
|
readonly nextTickAt: number;
|
|
readonly damageSchool?: DamageSchool;
|
|
readonly spellPenetration?: number;
|
|
readonly tag?: string;
|
|
}
|
|
|
|
export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot";
|
|
|
|
export interface CombatFeedback {
|
|
readonly id: number;
|
|
readonly kind: CombatFeedbackKind;
|
|
readonly message: string;
|
|
readonly abilityId?: string;
|
|
readonly targetId?: string;
|
|
readonly amount?: number;
|
|
}
|
|
|
|
export interface BossLootReward {
|
|
readonly id: number;
|
|
readonly bossId: string;
|
|
readonly bossName: string;
|
|
readonly items: readonly InventoryItem[];
|
|
readonly cards?: BossCardDrop;
|
|
readonly acquiredAt: number;
|
|
}
|
|
|
|
export type CastFailureReason =
|
|
| "unknown-ability"
|
|
| "wrong-class"
|
|
| "not-learned"
|
|
| "passive"
|
|
| "unsupported"
|
|
| "level-locked"
|
|
| "player-dead"
|
|
| "player-controlled"
|
|
| "already-casting"
|
|
| "global-cooldown"
|
|
| "cooldown"
|
|
| "not-enough-resource"
|
|
| "not-enough-runes"
|
|
| "condition-not-met"
|
|
| "no-target"
|
|
| "target-dead"
|
|
| "target-unavailable"
|
|
| "line-of-sight"
|
|
| "not-facing"
|
|
| "must-be-behind"
|
|
| "invalid-destination"
|
|
| "school-locked"
|
|
| "too-close"
|
|
| "out-of-range";
|
|
|
|
export interface CastRequest {
|
|
readonly origin?: CombatPosition;
|
|
readonly now?: number;
|
|
readonly targetActorId?: string | null;
|
|
readonly groundPosition?: CombatPosition;
|
|
readonly completingCast?: ActiveCast;
|
|
}
|
|
|
|
export interface CastResult {
|
|
readonly ok: boolean;
|
|
readonly abilityId?: string;
|
|
readonly reason?: CastFailureReason;
|
|
readonly affectedMobIds: readonly string[];
|
|
readonly damage: number;
|
|
readonly healing: number;
|
|
readonly levelsGained: number;
|
|
}
|
|
|
|
export interface ActorHealingRequest {
|
|
readonly sourceActorId: string;
|
|
readonly abilityId?: string;
|
|
readonly school?: DamageSchool;
|
|
readonly criticalChance?: number;
|
|
readonly criticalMultiplier?: number;
|
|
readonly seed?: string;
|
|
readonly now?: number;
|
|
readonly amountAlreadyModified?: boolean;
|
|
}
|
|
|
|
export interface CharacterCombatSeed {
|
|
readonly classId: ClassId;
|
|
readonly specializationId?: string | null;
|
|
readonly role?: PartyRole;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly raceId?: RaceId;
|
|
readonly level?: number;
|
|
readonly xp?: number;
|
|
/** Backward-compatible profile field accepted by the integration bridge. */
|
|
readonly experience?: number;
|
|
readonly talentRanks?: TalentRanks;
|
|
readonly actionBindings?: unknown;
|
|
readonly inventory?: unknown;
|
|
readonly bossCards?: unknown;
|
|
readonly equipment?: unknown;
|
|
readonly settings?: Partial<GameplaySettings>;
|
|
}
|
|
|
|
export interface MobRegistration {
|
|
readonly name: string;
|
|
readonly serverEntry?: number;
|
|
readonly creatureFamily?: CombatCreatureFamily;
|
|
readonly boss?: boolean;
|
|
readonly level?: number;
|
|
readonly resistances?: Partial<MobMagicResistances>;
|
|
readonly maxHealth?: number;
|
|
readonly xpReward?: number;
|
|
readonly healthMultiplier?: number;
|
|
readonly damageMultiplier?: number;
|
|
readonly bonusLootChance?: number;
|
|
readonly moveSpeed?: number;
|
|
readonly aggroRange?: number;
|
|
readonly leashRange?: number;
|
|
readonly attacks?: readonly EnemyAttackDefinition[];
|
|
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities?: readonly AzerothCoreSchool[];
|
|
readonly hasLoot?: boolean;
|
|
/** Stable SQL boss key, independent from this spawned mob's runtime id. */
|
|
readonly lootSourceId?: string;
|
|
}
|
|
|
|
export type TargetCycleDirection = "next" | "previous" | 1 | -1;
|
|
|
|
export interface DamageResult {
|
|
readonly amount: number;
|
|
readonly killed: boolean;
|
|
readonly xpAwarded: number;
|
|
readonly levelsGained: number;
|
|
readonly combatResult?: CombatResult;
|
|
}
|
|
|
|
export interface RoguePoisonProcResult {
|
|
readonly instantProcs: number;
|
|
readonly deadlyApplications: number;
|
|
readonly deadlyStacks: number;
|
|
readonly damage: number;
|
|
}
|
|
|
|
interface PlayerDamageRollContext {
|
|
readonly attackKind: WotlkAttackKind;
|
|
readonly seed: string;
|
|
readonly abilityId?: string;
|
|
readonly periodic?: boolean;
|
|
readonly rules?: CombatResultRules;
|
|
readonly criticalChanceBonusPercent?: number;
|
|
}
|
|
|
|
export interface CombatSummon {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly creatureId: number;
|
|
readonly targetId: string | null;
|
|
readonly expiresAt: number;
|
|
readonly kind: "pet" | "totem" | "summon";
|
|
readonly name: string;
|
|
readonly ownerActorId: AggroActorId;
|
|
readonly health: number;
|
|
readonly maxHealth: number;
|
|
readonly position: CombatPosition;
|
|
readonly nextActionAt: number;
|
|
readonly pulseIntervalMs: number;
|
|
readonly attackDamage: number;
|
|
readonly abilityIds: readonly string[];
|
|
readonly resources: CombatActorState["resources"];
|
|
readonly cooldowns: CombatActorState["cooldowns"];
|
|
readonly globalCooldownEndsAt: number;
|
|
readonly cast: CombatActorState["cast"];
|
|
readonly controlledUntil: number;
|
|
readonly schoolLockedUntil: CombatActorState["schoolLockedUntil"];
|
|
readonly exclusiveGroup?: string;
|
|
}
|
|
|
|
export interface CombatAreaEffect {
|
|
readonly id: string;
|
|
readonly sourceActorId: AggroActorId;
|
|
readonly sourceAbilityId: string;
|
|
readonly position: CombatPosition;
|
|
readonly radius: number;
|
|
readonly createdAt: number;
|
|
readonly expiresAt: number;
|
|
readonly tickIntervalMs: number;
|
|
readonly nextTickAt: number;
|
|
readonly fixedDamagePerTick?: number;
|
|
readonly fixedHealingPerTick?: number;
|
|
}
|
|
|
|
export interface PendingGroundTarget {
|
|
readonly abilityId: string;
|
|
readonly maximumRange: number;
|
|
readonly radius: number;
|
|
readonly position: CombatPosition;
|
|
readonly valid: boolean;
|
|
}
|
|
|
|
export interface CombatState {
|
|
classId: ClassId;
|
|
specializationId: string | null;
|
|
gearRole: PartyRole;
|
|
secondaryClassId: ClassId | null;
|
|
raceId: RaceId;
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
controlledUntil: number;
|
|
controlMechanic: EnemyControlMechanic | null;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
maxResourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
|
resourceName: string;
|
|
comboPoints: number;
|
|
comboTargetId: string | null;
|
|
deathKnightRunes: readonly DeathKnightRuneState[];
|
|
abilities: readonly AbilityDefinition[];
|
|
cooldowns: Readonly<Record<string, number>>;
|
|
globalCooldownEndsAt: number;
|
|
activeCast: ActiveCast | null;
|
|
schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
|
playerAnimationEvent: CharacterCombatAnimationEvent | null;
|
|
autoAttackTargetId: string | null;
|
|
nextAutoAttackAt: number;
|
|
mobs: Readonly<Record<string, MobCombatState>>;
|
|
selectedTargetId: string | null;
|
|
selectedFriendlyActorId: string | null;
|
|
effects: readonly TimedCombatEffect[];
|
|
/** Named buffs/debuffs for the player, mobs, and party members. */
|
|
auras: readonly ActiveAura[];
|
|
summons: readonly CombatSummon[];
|
|
areaEffects: readonly CombatAreaEffect[];
|
|
pendingGroundTarget: PendingGroundTarget | null;
|
|
actors: Readonly<Record<string, CombatActorState>>;
|
|
combatEvents: readonly StructuredCombatEvent[];
|
|
feedback: CombatFeedback | null;
|
|
/** Most recent discrete attack result, retained for proc and combat-log consumers. */
|
|
lastCombatResult: CombatResult | null;
|
|
bossLootRewards: readonly BossLootReward[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
actionBindings: ActionBindings;
|
|
inventory: readonly InventoryItem[];
|
|
bossCards: BossCardCollection;
|
|
collectSharedBossCards: (drop: BossCardDrop, bossName: string, now: number) => void;
|
|
equipment: EquipmentAssignments;
|
|
gearStats: ItemStats;
|
|
settings: GameplaySettings;
|
|
playerPosition: CombatPosition;
|
|
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
|
refreshResolvedGear: () => void;
|
|
registerMob: (id: string, registration: MobRegistration) => void;
|
|
removeMob: (id: string) => void;
|
|
lootMob: (id: string, now?: number) => boolean;
|
|
dismissBossLootReward: (id?: number) => void;
|
|
equipItem: (instanceId: string, ownerId?: string) => boolean;
|
|
unequipItem: (ownerId: string, slot: EquipmentSlot) => boolean;
|
|
selectMob: (id: string) => boolean;
|
|
selectNearestTarget: (origin?: CombatPosition, maxRange?: number) => string | null;
|
|
cycleTarget: (direction?: TargetCycleDirection, origin?: CombatPosition, maxRange?: number) => string | null;
|
|
clearTarget: () => void;
|
|
selectFriendlyActor: (id: string | null) => boolean;
|
|
setPlayerPosition: (position: CombatPosition) => void;
|
|
castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
|
|
beginGroundTarget: (abilityId: string) => boolean;
|
|
moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void;
|
|
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
|
cancelGroundTarget: () => void;
|
|
synchronizeActors: () => void;
|
|
/** Grants learned long-term class stat buffs to the full party without setup casts. */
|
|
applyDefaultPartyBuffs: (now?: number) => void;
|
|
/** Applies specialization maintenance buffs and permanent pets before the first pull. */
|
|
applyPrecombatPreparation: (now?: number) => void;
|
|
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
|
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
|
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
|
castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, origin?: CombatPosition, now?: number) => CastResult;
|
|
damageMob: (
|
|
id: string,
|
|
amount: number,
|
|
now?: number,
|
|
source?: ThreatSource,
|
|
school?: DamageSchool,
|
|
rollContext?: PlayerDamageRollContext,
|
|
) => DamageResult;
|
|
consumeActorAuraTag: (actorId: string, tag: string) => void;
|
|
consumeActorEnrage: (actorId: string, now?: number) => boolean;
|
|
triggerChimeraShot: (sourceActorId: string, targetId: string, abilityId: string, now?: number) => number;
|
|
triggerRogueWeaponPoisons: (
|
|
sourceActorId: AggroActorId,
|
|
targetId: string,
|
|
sourceAbilityId: string,
|
|
hitCount?: number,
|
|
now?: number,
|
|
) => RoguePoisonProcResult;
|
|
consumeRogueDeadlyPoison: (
|
|
sourceActorId: AggroActorId,
|
|
targetId: string,
|
|
maximumDoses: number,
|
|
now?: number,
|
|
) => number;
|
|
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
|
|
applyActorThreatEffect: (
|
|
actorId: AggroActorId,
|
|
effect: Extract<AbilityEffect, { readonly kind: "threat" }>,
|
|
redirectTargetActorId?: AggroActorId | null,
|
|
now?: number,
|
|
) => void;
|
|
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
|
interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean;
|
|
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string) => boolean;
|
|
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
|
|
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
|
|
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
|
|
restoreActorResource: (id: string, resource: ResourceType, amount: number, percentage?: boolean, sourceActorId?: string, abilityId?: string, now?: number) => number;
|
|
shieldActor: (id: string, amount: number, durationMs: number, sourceActorId: string, ability: AbilityDefinition, now?: number) => number;
|
|
applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "apply-aura" }>, now?: number) => boolean;
|
|
applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>, now?: number) => boolean;
|
|
summonActor: (ownerActorId: string, ability: AbilityDefinition, effect: Extract<AbilityEffect, { kind: "summon" }>, position: CombatPosition, targetId?: string | null, now?: number) => string | null;
|
|
createActorAreaEffect: (sourceActorId: string, ability: AbilityDefinition, position: CombatPosition, now?: number) => string | null;
|
|
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
|
damageSummon: (id: string, amount: number) => number;
|
|
healPlayer: (amount: number) => number;
|
|
revivePlayer: (percentMaxHealth?: number) => number;
|
|
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
|
|
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
|
|
cancelCast: (message?: string) => boolean;
|
|
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
|
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
|
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
|
tick: (deltaSeconds: number, now?: number) => void;
|
|
resetEncounter: (options?: {
|
|
readonly preserveDefeated?: boolean;
|
|
readonly preserveDefeatedIds?: readonly string[];
|
|
}) => void;
|
|
allocateTalent: (treeId: string) => boolean;
|
|
resetTalents: () => void;
|
|
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
|
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
|
|
resetActionBindings: () => void;
|
|
updateSettings: (patch: Partial<GameplaySettings>) => void;
|
|
resetSettings: () => void;
|
|
}
|
|
|
|
interface MutableProgression {
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
levelsGained: number;
|
|
}
|
|
|
|
interface MutableCastWork {
|
|
classId: ClassId;
|
|
abilities: readonly AbilityDefinition[];
|
|
mobs: Record<string, MobCombatState>;
|
|
effects: TimedCombatEffect[];
|
|
auras: ActiveAura[];
|
|
summons: CombatSummon[];
|
|
areaEffects: CombatAreaEffect[];
|
|
combatEvents: StructuredCombatEvent[];
|
|
actors: Record<string, CombatActorState>;
|
|
cooldowns: Record<string, number>;
|
|
health: number;
|
|
maxHealth: number;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Partial<Record<ResourceType, number>>;
|
|
maxResourcePools: Partial<Record<ResourceType, number>>;
|
|
lastResourceSpentAt: Partial<Record<ResourceType, number>>;
|
|
comboPoints: number;
|
|
comboTargetId: string | null;
|
|
deathKnightRunes: readonly DeathKnightRuneState[];
|
|
progression: MutableProgression;
|
|
totalDamage: number;
|
|
totalHealing: number;
|
|
combatResults: CombatResult[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
gearStats: ItemStats;
|
|
raceId: RaceId;
|
|
secondaryClassId: ClassId | null;
|
|
/** Active synchronous spell/source/target keys used to break proc cycles. */
|
|
triggeredSpellStack?: string[];
|
|
triggeredProcKeys?: Set<string>;
|
|
}
|
|
|
|
let feedbackSequence = 0;
|
|
let bossLootRewardSequence = 0;
|
|
let effectSequence = 0;
|
|
let castSequence = 0;
|
|
let animationSequence = 0;
|
|
let summonSequence = 0;
|
|
let areaEffectSequence = 0;
|
|
let combatEventSequence = 0;
|
|
|
|
function initialSummonCombatRuntime(): Pick<CombatSummon,
|
|
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
|
> {
|
|
return {
|
|
abilityIds: [],
|
|
resources: {},
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
cast: null,
|
|
controlledUntil: 0,
|
|
schoolLockedUntil: {},
|
|
};
|
|
}
|
|
|
|
function combatActorFromSummon(
|
|
summon: CombatSummon,
|
|
level: number,
|
|
auras: readonly ActiveAura[],
|
|
): CombatActorState {
|
|
return {
|
|
id: summon.id,
|
|
threatActorId: summon.id,
|
|
kind: summon.kind,
|
|
ownerId: summon.ownerActorId,
|
|
level,
|
|
health: summon.health,
|
|
maxHealth: summon.maxHealth,
|
|
absorb: entityAbsorbAmount(auras, summon.id),
|
|
resources: summon.resources,
|
|
cooldowns: summon.cooldowns,
|
|
globalCooldownEndsAt: summon.globalCooldownEndsAt,
|
|
cast: summon.cast,
|
|
controlledUntil: summon.controlledUntil,
|
|
schoolLockedUntil: summon.schoolLockedUntil,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilityIds: summon.abilityIds,
|
|
alive: summon.health > 0,
|
|
};
|
|
}
|
|
|
|
function recordCombatEvent(
|
|
events: readonly StructuredCombatEvent[],
|
|
input: CombatEventInput,
|
|
): readonly StructuredCombatEvent[] {
|
|
combatEventSequence += 1;
|
|
return appendCombatEvent(events, input, combatEventSequence);
|
|
}
|
|
|
|
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
|
|
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
|
|
}
|
|
|
|
function nextFeedback(
|
|
kind: CombatFeedbackKind,
|
|
message: string,
|
|
details: Omit<CombatFeedback, "id" | "kind" | "message"> = {},
|
|
): CombatFeedback {
|
|
feedbackSequence += 1;
|
|
return { id: feedbackSequence, kind, message, ...details };
|
|
}
|
|
|
|
function nextAnimationEvent(
|
|
kind: CharacterCombatAnimationKind,
|
|
abilityId?: string,
|
|
): CharacterCombatAnimationEvent {
|
|
animationSequence += 1;
|
|
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
|
|
}
|
|
|
|
function emitPlayerAbilityPresentation(
|
|
ability: AbilityDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
instantCast = false,
|
|
): void {
|
|
const delivery = presentationDeliveryForAbility(ability);
|
|
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery,
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
|
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
|
...(instantCast ? { instantCast: true } : {}),
|
|
});
|
|
}
|
|
|
|
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
|
const ability = abilityById(abilityId);
|
|
if (!ability) return;
|
|
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
|
}
|
|
|
|
function emitPlayerAbilityResolutionPresentation(
|
|
ability: AbilityDefinition,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
instantCast: boolean,
|
|
): void {
|
|
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
|
occurredAt,
|
|
origin,
|
|
targetId,
|
|
targetPosition,
|
|
instantCast,
|
|
);
|
|
}
|
|
|
|
function emitEnemyAttackPresentation(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetActorId: AggroActorId | null,
|
|
targetPosition: CombatPosition | null,
|
|
instantCast = false,
|
|
): void {
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "enemy",
|
|
sourceActorId: mob.id,
|
|
...(targetActorId ? { targetActorId } : {}),
|
|
abilityId: attack.id,
|
|
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
|
|
name: attack.name,
|
|
source: presentationSourceForSpellId(attack.spellId),
|
|
school: attack.school,
|
|
delivery: attack.delivery,
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(attack.radius === undefined ? {} : { radius: attack.radius }),
|
|
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
|
|
...(instantCast ? { instantCast: true } : {}),
|
|
});
|
|
}
|
|
|
|
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
|
|
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
const ROM_RAGE_CLASSES = new Set<ClassId>(["rom-warrior", "rom-champion"]);
|
|
const RAGE_PER_AUTO_ATTACK = 6;
|
|
const RAGE_PER_HIT_TAKEN = 7;
|
|
const ROM_CHAMPION_RAGE_PER_SECOND = 6;
|
|
const PLAYER_AUTO_ATTACK_RANGE = 5;
|
|
|
|
function hasRomClassPool(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
resourceClassId: ClassId,
|
|
): boolean {
|
|
return classId === resourceClassId || secondaryClassId === resourceClassId;
|
|
}
|
|
|
|
function hasRomRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return ROM_RAGE_CLASSES.has(classId)
|
|
|| Boolean(secondaryClassId && ROM_RAGE_CLASSES.has(secondaryClassId));
|
|
}
|
|
|
|
function hasRomWarriorRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-warrior");
|
|
}
|
|
|
|
function hasRomChampionRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-champion");
|
|
}
|
|
|
|
function gainRomRage(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
amount: number,
|
|
resource: number,
|
|
maxResource: number,
|
|
resourcePools: Partial<Record<ResourceType, number>>,
|
|
maxResourcePools: Partial<Record<ResourceType, number>>,
|
|
): { resource: number; resourcePools: Partial<Record<ResourceType, number>> } {
|
|
const activeType = resourceProfileForClass(classId).type;
|
|
const hasRage = hasRomRagePool(classId, secondaryClassId)
|
|
|| activeType === "rage"
|
|
|| maxResourcePools.rage !== undefined;
|
|
if (!hasRage || amount <= 0) return { resource, resourcePools };
|
|
const maximum = maxResourcePools.rage ?? (activeType === "rage" ? maxResource : 100);
|
|
const rage = clamp((resourcePools.rage ?? (activeType === "rage" ? resource : 0)) + amount, 0, maximum);
|
|
return {
|
|
resource: activeType === "rage" ? rage : resource,
|
|
resourcePools: { ...resourcePools, rage },
|
|
};
|
|
}
|
|
|
|
function playerAutoAttackProfile(
|
|
state: Pick<CombatState, "classId" | "secondaryClassId" | "raceId" | "level" | "gearStats">,
|
|
): { damage: number; intervalMs: number; hasteMultiplier: number } {
|
|
const stats = deriveCharacterStats(
|
|
state.classId,
|
|
state.level,
|
|
state.gearStats,
|
|
state.raceId,
|
|
{},
|
|
state.secondaryClassId,
|
|
);
|
|
const weapon = state.gearStats.meleeWeapon;
|
|
const speedMs = weapon?.speedMs ?? 2_400;
|
|
const speedSeconds = speedMs / 1_000;
|
|
const weaponDamage = weapon
|
|
? (weapon.damageMin + weapon.damageMax) / 2
|
|
: playerCombatPower(state.level) * 0.35;
|
|
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
|
|
return {
|
|
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
|
|
intervalMs,
|
|
hasteMultiplier: speedMs / intervalMs,
|
|
};
|
|
}
|
|
|
|
function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext {
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase();
|
|
const tags = new Set<string>();
|
|
tags.add(ability.castTimeMs > 0 ? "cast-time" : "instant");
|
|
if (periodic) tags.add("periodic");
|
|
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot")) tags.add("damage");
|
|
if (ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield")) tags.add("healing");
|
|
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
|
|
if (searchable.includes(school)) tags.add(school);
|
|
}
|
|
return { abilityName: ability.name, tags: [...tags] };
|
|
}
|
|
|
|
function playerThreatSource(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
const ability = abilityId ? abilityById(abilityId) : null;
|
|
const threatScale = ability
|
|
? applyTalentModifierValue(
|
|
1,
|
|
state.talentModifiers,
|
|
"threat-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: 1;
|
|
return {
|
|
actorId: PLAYER_AGGRO_ID,
|
|
role: usePartyStore.getState().playerRole,
|
|
...(abilityId ? { abilityId } : {}),
|
|
threatScale,
|
|
};
|
|
}
|
|
|
|
function threatSourceForActor(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
actorId: AggroActorId,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
if (actorId === PLAYER_AGGRO_ID) return playerThreatSource(state, abilityId, periodic);
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
|
return {
|
|
actorId,
|
|
role: member?.role ?? "damage",
|
|
...(abilityId ? { abilityId } : {}),
|
|
};
|
|
}
|
|
|
|
function actorPosition(state: Pick<CombatState, "playerPosition" | "summons">, actorId: AggroActorId): CombatPosition | null {
|
|
if (actorId === PLAYER_AGGRO_ID) return state.playerPosition;
|
|
return getPartyRuntimePosition(actorId)
|
|
?? state.summons.find((summon) => summon.id === actorId && summon.health > 0)?.position
|
|
?? null;
|
|
}
|
|
|
|
function eligibleAggroActorIds(
|
|
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
|
|
mob: Pick<MobCombatState, "homePosition" | "leashRange">,
|
|
): Set<AggroActorId> {
|
|
const eligible = new Set<AggroActorId>();
|
|
const home = mob.homePosition;
|
|
const withinLeash = (position: CombatPosition | null) => {
|
|
if (!position) return false;
|
|
if (!home) return true;
|
|
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
|
|
};
|
|
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health > 0 && withinLeash(getPartyRuntimePosition(member.id))) eligible.add(member.id);
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) {
|
|
if (actor.health > 0 && withinLeash(getPartyRuntimePosition(actor.id))) eligible.add(actor.id);
|
|
}
|
|
for (const summon of state.summons) {
|
|
if (summon.health > 0 && withinLeash(summon.position)) eligible.add(summon.id);
|
|
}
|
|
return eligible;
|
|
}
|
|
|
|
function addHealingThreatInWork(
|
|
work: MutableCastWork,
|
|
source: ThreatSource,
|
|
effectiveHealing: number,
|
|
): void {
|
|
const engagedIds = Object.values(work.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
|
|
* (source.threatScale ?? 1)
|
|
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat");
|
|
if (perMob <= 0) return;
|
|
for (const id of engagedIds) {
|
|
const mob = work.mobs[id];
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
}
|
|
|
|
function talentAdjustedAmount(
|
|
baseValue: number,
|
|
modifiers: readonly TalentModifier[],
|
|
kind: "damage-percent" | "healing-percent",
|
|
ability: AbilityDefinition,
|
|
periodic = false,
|
|
): number {
|
|
const context = abilityTalentContext(ability, periodic);
|
|
const scalar = applyTalentModifierValue(
|
|
baseValue,
|
|
modifiers,
|
|
kind,
|
|
context,
|
|
);
|
|
return Math.max(0, Math.round(scalar));
|
|
}
|
|
|
|
function talentAdjustedMaxHealth(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const staminaModifiers = playerModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "stamina"
|
|
));
|
|
const staminaPercent = applyTalentModifierValue(100, staminaModifiers, "attribute-percent") - 100;
|
|
const maxHealthPercent = applyTalentModifierValue(100, playerModifiers, "max-health-percent") - 100;
|
|
return Math.max(1, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
stamina: staminaPercent,
|
|
maxHealth: maxHealthPercent,
|
|
}, secondaryClassId).health);
|
|
}
|
|
|
|
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
|
if (ability.source === "runewaker") {
|
|
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
|
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
|
|
return "spell";
|
|
}
|
|
}
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
const healing = ability.effects.some((effect) => (
|
|
effect.kind === "heal"
|
|
|| effect.kind === "hot"
|
|
|| effect.kind === "shield"
|
|
|| effect.kind === "resurrection"
|
|
));
|
|
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
|
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
|
if (healing && ability.target !== "hostile") return "spell";
|
|
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
|
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
|
return "spell";
|
|
}
|
|
|
|
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
|
if (ability.source === "runewaker") {
|
|
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
|
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school;
|
|
}
|
|
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
|
|
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
|
|
if (/\b(?:frost|ice|chill|blizzard)\b/.test(searchable)) return "frost";
|
|
if (/\b(?:shadow|curse|corruption|drain|haunt)\b/.test(searchable)) return "shadow";
|
|
if (/\b(?:arcane|starfire|moonfire)\b/.test(searchable)) return "arcane";
|
|
if (/\b(?:nature|lightning|wrath|insect|earth|storm)\b/.test(searchable)) return "nature";
|
|
if (classId === "paladin" || classId === "priest") return "holy";
|
|
if (classId === "warlock") return "shadow";
|
|
if (classId === "shaman" || classId === "druid") return "nature";
|
|
return "arcane";
|
|
}
|
|
|
|
export function equipmentAdjustedCombatAmount(
|
|
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
classId: ClassId,
|
|
ability: AbilityDefinition,
|
|
): number {
|
|
const kind = typeof scaling !== "number" && "school" in scaling && scaling.school && scaling.school !== "physical"
|
|
? "spell" : abilityAttackKind(classId, ability);
|
|
const attackPower = equipmentAttackPower(classId, gearStats, kind);
|
|
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
|
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
|
const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : 0;
|
|
const equipmentPower = kind === "spell"
|
|
? attackPower
|
|
: attackPower / 14 * weaponSpeed + weaponDamage * 0.35;
|
|
const sourceLevel = Math.max(1, typeof scaling === "number" ? level : scaling.sourceLevel ?? level);
|
|
const scalingLevel = Math.min(level, typeof scaling === "number" ? level : scaling.maxScalingLevel || level);
|
|
const coefficient = typeof scaling === "number"
|
|
? scaling
|
|
: scaling.coefficient + Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.coefficientPerLevel ?? 0);
|
|
if (typeof scaling === "number" || scaling.basePoints === undefined) {
|
|
return Math.max(
|
|
0,
|
|
Math.round(coefficient * (playerCombatPower(level) + equipmentPower)),
|
|
);
|
|
}
|
|
const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0);
|
|
const averageRoll = Math.abs(scaling.basePoints)
|
|
+ Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2
|
|
+ levelGrowth;
|
|
// Starter-kit spells are granted early, but their raw DBC values were tuned
|
|
// for their original client level. Scale that base down to the player's
|
|
// current combat budget; once the source level is reached, use the DBC value.
|
|
const earlyLevelScale = level < sourceLevel
|
|
? playerCombatPower(level) / playerCombatPower(sourceLevel)
|
|
: 1;
|
|
const bonusPower = equipmentPower * Math.max(
|
|
0,
|
|
scaling.bonusPowerCoefficient ?? coefficient,
|
|
);
|
|
const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient
|
|
? deriveCharacterStats(classId, level, gearStats) : null;
|
|
const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0)
|
|
+ (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0);
|
|
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower));
|
|
}
|
|
|
|
function playerCharacterStats(
|
|
classId: ClassId,
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
raceId: RaceId,
|
|
secondaryClassId: ClassId | null,
|
|
modifiers: readonly TalentModifier[],
|
|
): CharacterStats {
|
|
const attributePercent = (attribute: "agility" | "intellect" | "spirit" | "stamina" | "strength") => (
|
|
applyTalentModifierValue(
|
|
100,
|
|
modifiers.filter((modifier) => modifier.kind !== "attribute-percent" || modifier.attribute === attribute),
|
|
"attribute-percent",
|
|
) - 100
|
|
);
|
|
const armorPercent = applyTalentModifierValue(100, modifiers, "armor-percent") - 100;
|
|
const stats = deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
agility: attributePercent("agility"),
|
|
intellect: attributePercent("intellect"),
|
|
spirit: attributePercent("spirit"),
|
|
stamina: attributePercent("stamina"),
|
|
strength: attributePercent("strength"),
|
|
}, secondaryClassId);
|
|
return armorPercent === 0
|
|
? stats
|
|
: Object.freeze({
|
|
...stats,
|
|
armor: Math.max(0, Math.round(stats.armor * (1 + armorPercent / 100))),
|
|
});
|
|
}
|
|
|
|
function npcCombatDefender(mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[]): CombatDefenderStats {
|
|
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
|
|
const resistance = (value: number, school: DamageSchool): number => Math.max(0,
|
|
resolveAuraStatValue(resolveAuraStatValue(value, auras, `resistance:${school}`), auras, "magical-defense"),
|
|
);
|
|
return {
|
|
level: mob.level,
|
|
armor: Math.max(0, resolveAuraStatValue(mob.armor, auras, "armor")),
|
|
dodgePercent: 5 + levelAdvantage * 0.5,
|
|
parryPercent: 5 + levelAdvantage * 0.5,
|
|
blockPercent: 0,
|
|
blockValue: 0,
|
|
resistances: {
|
|
arcane: resistance(mob.resistances.arcane, "arcane"),
|
|
fire: resistance(mob.resistances.fire, "fire"),
|
|
frost: resistance(mob.resistances.frost, "frost"),
|
|
nature: resistance(mob.resistances.nature, "nature"),
|
|
shadow: resistance(mob.resistances.shadow, "shadow"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function stableCombatRoll(seed: string) {
|
|
return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`);
|
|
}
|
|
|
|
function resolvePlayerDamageResult(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
sourceActorId: string,
|
|
mob: MobCombatState,
|
|
amount: number,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
criticalChanceBonusPercent = 0,
|
|
): CombatResult {
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const baseStats = sourceMember
|
|
? deriveCharacterStats(sourceMember.classId, sourceMember.level, sourceMember.gearStats, sourceMember.raceId)
|
|
: playerCharacterStats(
|
|
classId,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
work.talentModifiers,
|
|
);
|
|
const stats = applyAuraStats(baseStats, aurasForEntity(work.auras, sourceActorId));
|
|
const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind);
|
|
const nextCritical = !periodic && Boolean(ability && aurasForEntity(work.auras, sourceActorId).some((aura) => (
|
|
aura.definition.utilityTags?.includes("next-offensive-critical")
|
|
)));
|
|
const premeditation = nextCritical && aurasForEntity(work.auras, sourceActorId).some((aura) => (
|
|
aura.definition.utilityTags?.includes("rom-premeditation")
|
|
));
|
|
const criticalChanceFromTalents = sourceMember ? 0 : ability
|
|
? applyTalentModifierValue(
|
|
0,
|
|
work.talentModifiers,
|
|
"critical-chance-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent");
|
|
const attacker: CombatAttackerStats = {
|
|
...baseAttacker,
|
|
criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents
|
|
+ criticalChanceBonusPercent + (nextCritical ? 100 : 0),
|
|
criticalDamageMultiplier: (sourceMember ? 2 : applyTalentModifierValue(
|
|
2,
|
|
work.talentModifiers,
|
|
"critical-damage-percent",
|
|
ability ? abilityTalentContext(ability, periodic) : {},
|
|
)) * (premeditation ? 1.4 : 1),
|
|
};
|
|
const immune = mob.schoolImmunities.includes(school);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: school,
|
|
attacker,
|
|
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id)),
|
|
rules: immune
|
|
? {
|
|
...rules,
|
|
immune: true,
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
resistanceMode: "binary",
|
|
}
|
|
: rules,
|
|
roll: stableCombatRoll(seed),
|
|
});
|
|
}
|
|
|
|
function combatResultDescription(result: CombatResult): string {
|
|
if (result.outcome === "miss") return "missed";
|
|
if (result.outcome === "dodge") return "was dodged";
|
|
if (result.outcome === "parry") return "was parried";
|
|
if (result.outcome === "resist") return "was resisted";
|
|
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
|
|
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`;
|
|
if (result.partiallyResisted) return `hit for ${result.finalDamage} (${result.resistedAmount} resisted)`;
|
|
return `hit for ${result.finalDamage}`;
|
|
}
|
|
|
|
function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
|
|
if (attack.delivery === "melee") return "melee";
|
|
if (attack.delivery === "projectile" && attack.school === "physical") return "ranged";
|
|
return "spell";
|
|
}
|
|
|
|
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
|
|
return attack.id === DEFAULT_ENEMY_ATTACK.id
|
|
&& attack.delivery === "melee"
|
|
&& attack.animation === "attack"
|
|
&& (attack.castTimeMs ?? 0) === 0;
|
|
}
|
|
|
|
function enemyMeleeCriticalCharge(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
now: number,
|
|
): MobAura | undefined {
|
|
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
|
|
return mob.auras.find((aura) => (
|
|
(aura.endsAt <= 0 || aura.endsAt > now)
|
|
&& (aura.charges ?? 0) > 0
|
|
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
|
|
));
|
|
}
|
|
|
|
function advanceEnemyMeleeAutoProcAuras(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
now: number,
|
|
attackRevision: number,
|
|
landed: boolean,
|
|
consumedAuraId?: string,
|
|
): readonly MobAura[] {
|
|
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
|
|
|
|
let changed = false;
|
|
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
|
|
if (aura.id !== consumedAuraId) return [aura];
|
|
changed = true;
|
|
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
|
|
return charges > 0 ? [{ ...aura, charges }] : [];
|
|
});
|
|
|
|
for (const driver of mob.auras) {
|
|
const proc = driver.meleeAutoCriticalProc;
|
|
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
|
|
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
|
|
if (
|
|
chance <= 0
|
|
|| stableCombatUnit(
|
|
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
|
|
) >= chance
|
|
) {
|
|
continue;
|
|
}
|
|
const triggeredAura = proc.triggeredAura.trim();
|
|
const criticalChancePercent = Math.max(
|
|
0,
|
|
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
|
|
);
|
|
const charges = Math.max(1, Math.round(
|
|
Number.isFinite(proc.charges) ? proc.charges : 1,
|
|
));
|
|
if (!triggeredAura || criticalChancePercent <= 0) continue;
|
|
nextAuras = [
|
|
...nextAuras.filter((aura) => aura.id !== triggeredAura),
|
|
{
|
|
id: triggeredAura,
|
|
sourceAbilityId: driver.sourceAbilityId,
|
|
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
|
|
nextMeleeCriticalChancePercent: criticalChancePercent,
|
|
charges,
|
|
},
|
|
];
|
|
changed = true;
|
|
}
|
|
|
|
return changed ? nextAuras : mob.auras;
|
|
}
|
|
|
|
function resolveEnemyDamageResult(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
defender: CharacterStats,
|
|
amount: number,
|
|
actorId: AggroActorId,
|
|
attackRevision: number,
|
|
now: number,
|
|
bonusCriticalChancePercent = 0,
|
|
defenderAuras: readonly ActiveAura[] = [],
|
|
): CombatResult {
|
|
const attackKind = enemyAttackKind(attack);
|
|
const incomingModifier = (stat: string) => defenderAuras.reduce((total, aura) => total
|
|
+ (aura.definition.modifiers ?? []).reduce((sum, modifier) => modifier.kind === "stat"
|
|
&& modifier.stat === stat && modifier.operation === "flat"
|
|
? sum + modifier.value * (modifier.perStack === false ? 1 : aura.stacks)
|
|
: sum, 0), 0);
|
|
const incomingHit = incomingModifier(`incoming-${attackKind}-hit`);
|
|
const incomingCrit = incomingModifier("incoming-critical-strike")
|
|
+ incomingModifier(`incoming-${attackKind}-critical-strike`);
|
|
const defenderStats = combatDefenderFromCharacterStats(defender);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: attack.school,
|
|
attacker: {
|
|
level: mob.level,
|
|
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
|
|
},
|
|
defender: {
|
|
...defenderStats,
|
|
criticalAvoidancePercent: (defenderStats.criticalAvoidancePercent ?? 0) - incomingCrit,
|
|
},
|
|
rules: {
|
|
isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
|
|
canDodge: attackKind === "melee",
|
|
canParry: attackKind === "melee",
|
|
canBlock: attackKind !== "spell",
|
|
baseMissChancePercent: baseMissChancePercent(attackKind, mob.level, defender.level) - incomingHit,
|
|
},
|
|
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
|
|
});
|
|
}
|
|
|
|
function talentAdjustedMaxResource(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const primaryResourceModifiers = playerModifiers.filter((modifier) => {
|
|
if (modifier.kind !== "max-resource-percent") return true;
|
|
const scope = modifier.scope.toLowerCase();
|
|
const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/.test(scope);
|
|
if (!mentionsSpecificResource) return true;
|
|
if (profile.type === "mana") return /\bmana\b/.test(scope);
|
|
if (profile.type === "rage") return /\b(?:rage|felfury|deathfire)\b/.test(scope);
|
|
if (profile.type === "energy") return /\b(?:energy|focus)\b/.test(scope);
|
|
return /\brunic power\b/.test(scope);
|
|
});
|
|
const maxResourcePercent = applyTalentModifierValue(
|
|
100,
|
|
primaryResourceModifiers,
|
|
"max-resource-percent",
|
|
) - 100;
|
|
if (profile.type !== "mana") {
|
|
return Math.max(0, Math.round(profile.maximum * (1 + maxResourcePercent / 100)));
|
|
}
|
|
const intellectModifiers = primaryResourceModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "intellect"
|
|
));
|
|
const intellectPercent = applyTalentModifierValue(100, intellectModifiers, "attribute-percent") - 100;
|
|
return Math.max(0, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
intellect: intellectPercent,
|
|
maxMana: maxResourcePercent,
|
|
}, secondaryClassId).mana);
|
|
}
|
|
|
|
const AUTOMATIC_CLASS_PASSIVE_PREFIX = "class-passive:";
|
|
|
|
function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
|
|
const statedLevel = ability.description.match(/\bLevel\s+(\d+)\s+Passive\b/i);
|
|
return Math.max(ability.unlockLevel, statedLevel ? Number(statedLevel[1]) : 1);
|
|
}
|
|
|
|
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
|
|
if (!ability.passive) return false;
|
|
if (ability.source !== "coa-tree") return true;
|
|
const nodeId = abilityTalentNodeId(ability);
|
|
const node = nodeId ? talentNodeById(nodeId) : null;
|
|
return Boolean(
|
|
node?.isPassive
|
|
&& (node.abilityEssenceCost ?? 0) === 0
|
|
&& (node.talentEssenceCost ?? 0) === 0,
|
|
);
|
|
}
|
|
|
|
function automaticClassPassiveModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
): readonly TalentModifier[] {
|
|
const modifiers: TalentModifier[] = [];
|
|
const seenNames = new Set<string>();
|
|
for (const definition of abilitiesForClass(classId)) {
|
|
const ability = abilityAtLevel(definition, level);
|
|
if (
|
|
!isAutomaticClassPassive(ability)
|
|
|| level < automaticClassPassiveUnlockLevel(ability)
|
|
|| seenNames.has(ability.name.toLowerCase())
|
|
) {
|
|
continue;
|
|
}
|
|
seenNames.add(ability.name.toLowerCase());
|
|
modifiers.push(...parseTalentModifiers(ability.description).map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: `${AUTOMATIC_CLASS_PASSIVE_PREFIX}${ability.id}`,
|
|
sourceTalentName: ability.name,
|
|
rank: abilityRankForLevel(ability, level)?.rank ?? 1,
|
|
description: ability.description,
|
|
})));
|
|
}
|
|
return modifiers;
|
|
}
|
|
|
|
function characterStatModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
ranks: TalentRanks,
|
|
): readonly TalentModifier[] {
|
|
return [
|
|
...automaticClassPassiveModifiers(classId, level),
|
|
...talentModifiersForRanks(ranks, classId),
|
|
];
|
|
}
|
|
|
|
function initialResourceForMaximum(classId: ClassId, maximum: number): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
return Math.round(maximum * (profile.maximum > 0 ? profile.initial / profile.maximum : 0));
|
|
}
|
|
|
|
function defaultResourceMaximum(type: ResourceType): number {
|
|
if (type === "nature-power") return 10;
|
|
if (type === "psi") return 6;
|
|
return 100;
|
|
}
|
|
|
|
function initialResourcePools(
|
|
classId: ClassId,
|
|
abilities: readonly AbilityDefinition[],
|
|
activeMaximum: number,
|
|
manaMaximum = 100,
|
|
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
|
|
const activeProfile = resourceProfileForClass(classId);
|
|
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
|
|
for (const ability of abilities) for (const cost of ability.costs ?? [ability.cost]) resourceTypes.add(cost.resource);
|
|
const maximums: Partial<Record<ResourceType, number>> = {};
|
|
const pools: Partial<Record<ResourceType, number>> = {};
|
|
for (const type of resourceTypes) {
|
|
if (type === "health") continue;
|
|
const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type);
|
|
maximums[type] = maximum;
|
|
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
|
? 0
|
|
: maximum;
|
|
}
|
|
return { pools, maximums };
|
|
}
|
|
|
|
export function resolvedAbilityCosts(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras" | "level">>,
|
|
): readonly { resource: ResourceType; amount: number }[] {
|
|
const aggregated = new Map<ResourceType, number>();
|
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
|
const maximum = cost.percentageBasis === "base-mana"
|
|
? baseResourcesFor(state.classId, state.level ?? ability.unlockLevel).mana
|
|
: cost.resource === "health"
|
|
? state.maxHealth
|
|
: cost.resource === activeResourceType
|
|
? state.maxResource
|
|
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
|
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
|
|
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
|
|
baseAmount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID);
|
|
const resourceAdjusted = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(rawAmount, playerAuras, cost.resource, "cost"),
|
|
playerAuras,
|
|
"power",
|
|
"cost",
|
|
);
|
|
const amount = Math.max(0, Math.round(resourceAdjusted));
|
|
aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount);
|
|
}
|
|
return [...aggregated].map(([resource, amount]) => ({ resource, amount }));
|
|
}
|
|
|
|
function talentAdjustedResourceCost(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
ability.cost.amount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedCastTime(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.castTimeMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.castTimeMs, modifiers, "cast-time-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cast-time-percent", context);
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", context);
|
|
duration = hastedDuration(
|
|
duration,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
);
|
|
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
|
|
if (ability.cooldownMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.cooldownMs, modifiers, "cooldown-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cooldown-percent", context);
|
|
return Math.max(0, Math.round(duration));
|
|
}
|
|
|
|
function talentAdjustedGcd(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.gcdMs <= 0) return 0;
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
|
|
const duration = hastedDuration(
|
|
ability.gcdMs,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
1_000,
|
|
);
|
|
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedDuration(
|
|
durationMs: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
durationMs, modifiers, "duration-percent", abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedRange(
|
|
range: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability)));
|
|
}
|
|
|
|
function talentAdjustedIncomingDamage(amount: number, modifiers: readonly TalentModifier[]): number {
|
|
return Math.max(0, Math.round(
|
|
applyTalentModifierValue(amount, modifiers, "damage-taken-percent"),
|
|
));
|
|
}
|
|
|
|
/** Renderer-independent player defenses for hostile-player combat adapters. */
|
|
export function characterCombatDefender(state: CombatState): CombatDefenderStats {
|
|
const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats,
|
|
state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID));
|
|
const defender = combatDefenderFromCharacterStats(stats);
|
|
const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000;
|
|
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
|
|
}
|
|
|
|
function normalizedSettings(
|
|
current: GameplaySettings,
|
|
patch: Partial<GameplaySettings>,
|
|
): GameplaySettings {
|
|
return {
|
|
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
|
|
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
|
|
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
|
|
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
|
|
threatMeterPosition: normalizeHudPosition(
|
|
patch.threatMeterPosition ?? current.threatMeterPosition,
|
|
),
|
|
showFloatingCombatText: patch.showFloatingCombatText ?? current.showFloatingCombatText,
|
|
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
|
|
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
|
|
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
|
|
uiScale: clamp(Number.isFinite(patch.uiScale) ? patch.uiScale! : current.uiScale, 0.75, 1.25),
|
|
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
|
? patch.minimapRotation
|
|
: current.minimapRotation,
|
|
gameplayFrameRate: normalizeGameplayFrameRateLimit(
|
|
patch.gameplayFrameRate,
|
|
current.gameplayFrameRate,
|
|
),
|
|
previewFrameRate: normalizePreviewFrameRateLimit(
|
|
patch.previewFrameRate,
|
|
current.previewFrameRate,
|
|
),
|
|
};
|
|
}
|
|
|
|
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
|
|
return left.showMobHealthBars === right.showMobHealthBars
|
|
&& left.showMobAggroRanges === right.showMobAggroRanges
|
|
&& left.showTargetFrame === right.showTargetFrame
|
|
&& left.showThreatMeter === right.showThreatMeter
|
|
&& left.threatMeterPosition.x === right.threatMeterPosition.x
|
|
&& left.threatMeterPosition.y === right.threatMeterPosition.y
|
|
&& left.showFloatingCombatText === right.showFloatingCombatText
|
|
&& left.autoTargetNearest === right.autoTargetNearest
|
|
&& left.enableScreenShake === right.enableScreenShake
|
|
&& left.masterVolume === right.masterVolume
|
|
&& left.uiScale === right.uiScale
|
|
&& left.minimapRotation === right.minimapRotation
|
|
&& left.gameplayFrameRate === right.gameplayFrameRate
|
|
&& left.previewFrameRate === right.previewFrameRate;
|
|
}
|
|
|
|
function defaultMobStats(
|
|
playerLevel: number,
|
|
boss: boolean,
|
|
serverEntry?: number,
|
|
): {
|
|
maxHealth: number;
|
|
xpReward: number;
|
|
armor: number;
|
|
maxMana: number;
|
|
attackPower: number;
|
|
attackDamage: number;
|
|
swingMs: number;
|
|
mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
schoolImmunities: readonly AzerothCoreSchool[];
|
|
} {
|
|
const server = serverEntry ? creatureRuntimeBaseStats(serverEntry, playerLevel) : null;
|
|
const legacy = boss
|
|
? { maxHealth: 260 + playerLevel * 65, xpReward: 110 + playerLevel * 35 }
|
|
: { maxHealth: 72 + playerLevel * 22, xpReward: 28 + playerLevel * 7 };
|
|
return {
|
|
maxHealth: server?.health ?? legacy.maxHealth,
|
|
xpReward: legacy.xpReward,
|
|
armor: server?.armor ?? 0,
|
|
maxMana: server?.mana ?? 0,
|
|
attackPower: server?.attackPower ?? 0,
|
|
attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2),
|
|
swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900),
|
|
mechanicImmunities: server?.mechanicImmunities ?? [],
|
|
schoolImmunities: server?.schoolImmunities ?? [],
|
|
};
|
|
}
|
|
|
|
function stableCombatUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function applyWrathCastPushback(
|
|
cast: ActiveCast | null,
|
|
now: number,
|
|
seed: string,
|
|
): ActiveCast | null {
|
|
if (!cast || cast.pushbackHits >= 2) return cast;
|
|
const ability = abilityById(cast.abilityId);
|
|
const resistance = ability ? abilityPushbackResistance(ability) : 0;
|
|
if (resistance >= 1 || stableCombatUnit(`${seed}:pushback`) < resistance) return cast;
|
|
const pushbackHits = cast.pushbackHits + 1;
|
|
if (cast.mode === "channel") {
|
|
const lostTicks = Math.max(1, Math.ceil(cast.totalTicks * 0.25));
|
|
const totalTicks = Math.max(cast.ticksCompleted, cast.totalTicks - lostTicks);
|
|
return {
|
|
...cast,
|
|
pushbackHits,
|
|
totalTicks,
|
|
completesAt: Math.max(now, cast.completesAt - cast.originalDurationMs * 0.25),
|
|
};
|
|
}
|
|
return { ...cast, pushbackHits, completesAt: cast.completesAt + 500 };
|
|
}
|
|
|
|
export function deterministicEnemyCooldown(
|
|
runtimeId: string,
|
|
abilityId: string,
|
|
activation: number,
|
|
range: readonly [number, number],
|
|
): number {
|
|
const minimum = Math.max(0, Math.round(range[0]));
|
|
const maximum = Math.max(minimum, Math.round(range[1]));
|
|
if (maximum === minimum) return minimum;
|
|
return minimum + Math.floor(
|
|
stableCombatUnit(`${runtimeId}:${abilityId}:${activation}`) * (maximum - minimum + 1),
|
|
);
|
|
}
|
|
|
|
function enemyAbilityConditionMet(
|
|
mob: MobCombatState,
|
|
mobs: Readonly<Record<string, MobCombatState>>,
|
|
attack: EnemyAttackDefinition,
|
|
): boolean {
|
|
const condition = attack.condition;
|
|
if (!condition || condition.kind === "in-combat") return true;
|
|
if (condition.kind === "self-health-percent") {
|
|
const percent = mob.maxHealth > 0 ? mob.health / mob.maxHealth * 100 : 0;
|
|
return percent >= condition.minimum && percent <= condition.maximum;
|
|
}
|
|
if (condition.kind === "encounter-state") {
|
|
return dungeonEncounterStateMatches(condition.encounterId, condition.states);
|
|
}
|
|
const origin = getMobPosition(mob.id);
|
|
if (!origin) return false;
|
|
return Object.values(mobs).some((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - condition.amount) return false;
|
|
const position = getMobPosition(candidate.id);
|
|
if (!position) return false;
|
|
const dx = position[0] - origin[0];
|
|
const dy = position[1] - origin[1];
|
|
const dz = position[2] - origin[2];
|
|
return dx * dx + dy * dy + dz * dz <= condition.range * condition.range;
|
|
});
|
|
}
|
|
|
|
function enemyAbilityNeedsHostileRange(attack: EnemyAttackDefinition): boolean {
|
|
return attack.target === "primary"
|
|
|| attack.target === "random-party"
|
|
|| attack.target === "nearby-party";
|
|
}
|
|
|
|
function initialEnemyAbilityReadyAt(
|
|
mob: MobCombatState,
|
|
now: number,
|
|
): Readonly<Record<string, number>> {
|
|
return Object.fromEntries(mob.attacks.map((attack) => {
|
|
const range = attack.initialCooldownMs ?? [450, 450] as const;
|
|
return [
|
|
attack.id,
|
|
now + deterministicEnemyCooldown(mob.id, attack.id, mob.attackRevision, range),
|
|
];
|
|
}));
|
|
}
|
|
|
|
function summonedMobCombatState(
|
|
id: string,
|
|
summoner: MobCombatState,
|
|
creatureEntry: number,
|
|
targetActorId: AggroActorId,
|
|
position: CombatPosition,
|
|
spawnGroupId?: string,
|
|
): MobCombatState {
|
|
const stats = defaultMobStats(summoner.level, false, creatureEntry);
|
|
const template = creatureTemplateReference(creatureEntry);
|
|
const attack: EnemyAttackDefinition = normalizeEnemyAttacks([{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
cooldownMs: stats.swingMs,
|
|
repeatCooldownMs: [stats.swingMs, stats.swingMs],
|
|
initialCooldownMs: [450, 450],
|
|
}])[0];
|
|
return {
|
|
id,
|
|
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
|
|
serverEntry: creatureEntry,
|
|
creatureFamily: romCreatureFamilyForServerEntry(creatureEntry),
|
|
summonedBy: summoner.id,
|
|
...(spawnGroupId ? { spawnGroupId } : {}),
|
|
boss: false,
|
|
lootSourceId: null,
|
|
level: summoner.level,
|
|
armor: stats.armor,
|
|
maxMana: stats.maxMana,
|
|
attackPower: stats.attackPower,
|
|
mechanicImmunities: stats.mechanicImmunities,
|
|
schoolImmunities: stats.schoolImmunities,
|
|
resistances: { arcane: 0, fire: 0, frost: 0, nature: 0, shadow: 0 },
|
|
health: stats.maxHealth,
|
|
maxHealth: stats.maxHealth,
|
|
healthMultiplier: 1,
|
|
damageMultiplier: 1,
|
|
bonusLootChance: 0,
|
|
dead: false,
|
|
hasLoot: false,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward: 0,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: seedActorThreat({}, targetActorId),
|
|
targetActorId,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: position,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange: attack.range,
|
|
attackDamage: stats.attackDamage,
|
|
swingMs: stats.swingMs,
|
|
aggroRange: 0,
|
|
leashRange: summoner.leashRange,
|
|
moveSpeed: summoner.moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks: [attack],
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
}
|
|
|
|
function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosition {
|
|
return origin ?? state.playerPosition;
|
|
}
|
|
|
|
function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveAura[] {
|
|
return auras.filter((aura) => aura.targetId === entityId);
|
|
}
|
|
|
|
function hasControlImmunity(
|
|
auras: readonly ActiveAura[],
|
|
entityId: string,
|
|
mechanic: EnemyControlMechanic,
|
|
): boolean {
|
|
return aurasForEntity(auras, entityId).some((aura) => (
|
|
aura.definition.controlImmunities?.includes(mechanic)
|
|
));
|
|
}
|
|
|
|
function auraDefinitionForAbility(
|
|
definition: AuraDefinition,
|
|
ability: AbilityDefinition,
|
|
durationMs = definition.durationMs,
|
|
): AuraDefinition {
|
|
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
|
|
return {
|
|
...definition,
|
|
description: definition.description ?? ability.description,
|
|
icon: definition.icon ?? ability.icon,
|
|
durationMs,
|
|
...(defaultPartyBuffFamily && !definition.exclusiveGroup ? {
|
|
uniqueBySource: false,
|
|
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
|
|
} : {}),
|
|
};
|
|
}
|
|
|
|
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
|
const stat = (value: number, ...names: string[]) => names.reduce(
|
|
(current, name) => resolveAuraStatValue(current, auras, name),
|
|
value,
|
|
);
|
|
const primary = (value: number, name: string, alias?: string) => stat(
|
|
value,
|
|
name,
|
|
...(alias ? [alias] : []),
|
|
"all-primary",
|
|
"all-primary-attributes",
|
|
);
|
|
const resistance = (value: number, school: string) => stat(
|
|
value,
|
|
`resistance:${school}`,
|
|
"resistance:all",
|
|
"all-resistance",
|
|
"magical-defense",
|
|
);
|
|
const stamina = primary(base.stamina, "stamina");
|
|
const spirit = primary(base.spirit, "spirit", "wisdom");
|
|
const spiritCritRating = spirit * resolveAuraStatValue(0, auras, "spirit-to-spell-crit-rating");
|
|
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
|
|
return {
|
|
...base,
|
|
strength: primary(base.strength, "strength"),
|
|
agility: primary(base.agility, "agility", "dexterity"),
|
|
stamina,
|
|
intellect: primary(base.intellect, "intellect"),
|
|
spirit,
|
|
health: stat(base.health + staminaHealth, "maximum-health"),
|
|
mana: stat(base.mana, "maximum-mana"),
|
|
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
|
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
|
spellPower: stat(base.spellPower, "spell-power", "magical-attack"),
|
|
armor: stat(base.armor, "armor", "physical-defense"),
|
|
blockValue: stat(base.blockValue, "block-value"),
|
|
meleeCritPercent: stat(base.meleeCritPercent, "critical-strike", "physical-critical-strike"),
|
|
rangedCritPercent: stat(base.rangedCritPercent, "critical-strike", "physical-critical-strike"),
|
|
spellCritPercent: stat(
|
|
base.spellCritPercent + ratingPercent(spiritCritRating, "spellCrit", base.level, "mage"),
|
|
"spell-critical-strike", "magical-critical-strike",
|
|
),
|
|
meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"),
|
|
rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit"),
|
|
spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"),
|
|
meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"),
|
|
rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"),
|
|
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
|
dodgePercent: stat(base.dodgePercent, "dodge"),
|
|
parryPercent: stat(base.parryPercent, "parry"),
|
|
blockPercent: stat(base.blockPercent, "block", "block-chance"),
|
|
expertise: stat(base.expertise, "expertise"),
|
|
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
|
fireResistance: resistance(base.fireResistance, "fire"),
|
|
frostResistance: resistance(base.frostResistance, "frost"),
|
|
natureResistance: resistance(base.natureResistance, "nature"),
|
|
shadowResistance: resistance(base.shadowResistance, "shadow"),
|
|
};
|
|
}
|
|
|
|
function consumeInnerFireCharges(
|
|
auras: readonly ActiveAura[], targetId: string, landed: boolean, attackKind: WotlkAttackKind,
|
|
): ActiveAura[] {
|
|
if (!landed || (attackKind !== "melee" && attackKind !== "ranged")) return [...auras];
|
|
return auras.flatMap((aura) => {
|
|
if (aura.targetId !== targetId || !aura.definition.name.startsWith("Inner Fire (DBC ")) return [aura];
|
|
return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : [];
|
|
});
|
|
}
|
|
|
|
function auraMaximumHealthBonus(
|
|
base: CharacterStats,
|
|
auras: readonly ActiveAura[],
|
|
targetId: string,
|
|
): number {
|
|
return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health);
|
|
}
|
|
|
|
function auraAdjustedPlayerMaxHealth(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats,
|
|
raceId: RaceId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
auras: readonly ActiveAura[],
|
|
): number {
|
|
const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers);
|
|
return Math.max(
|
|
1,
|
|
talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId)
|
|
+ auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
}
|
|
|
|
const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map<string, string>([
|
|
["power word: fortitude", "fortitude"],
|
|
["prayer of fortitude", "fortitude"],
|
|
["divine spirit", "spirit"],
|
|
["prayer of spirit", "spirit"],
|
|
["shadow protection", "shadow-protection"],
|
|
["prayer of shadow protection", "shadow-protection"],
|
|
["arcane intellect", "arcane-intellect"],
|
|
["arcane brilliance", "arcane-intellect"],
|
|
["mark of the wild", "mark-of-the-wild"],
|
|
["gift of the wild", "mark-of-the-wild"],
|
|
]));
|
|
|
|
function defaultPartyBuffPotency(
|
|
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[],
|
|
): number {
|
|
return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce(
|
|
(subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1),
|
|
0,
|
|
), 0);
|
|
}
|
|
|
|
function syncPassiveAuras(
|
|
current: readonly ActiveAura[],
|
|
abilities: readonly AbilityDefinition[],
|
|
level: number,
|
|
talentRanks: TalentRanks,
|
|
now: number,
|
|
): ActiveAura[] {
|
|
const definitions = abilities.flatMap((catalogAbility) => {
|
|
if (!catalogAbility.passive) return [];
|
|
const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks);
|
|
if (!ability) return [];
|
|
return ability.effects
|
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
|
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
|
});
|
|
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
|
let next = current.filter((aura) => (
|
|
aura.sourceId !== PLAYER_AURA_ENTITY_ID
|
|
|| !abilities.some((ability) => ability.passive && aura.definition.id.includes(`:${ability.dbcSpellId}:`))
|
|
|| passiveIds.has(aura.definition.id)
|
|
));
|
|
for (const definition of definitions) {
|
|
if (next.some((aura) => aura.definition.id === definition.id && aura.targetId === PLAYER_AURA_ENTITY_ID)) continue;
|
|
next = [...applyCombatAura(next, {
|
|
definition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function executeTriggeredAuraProcsInWork(
|
|
work: MutableCastWork,
|
|
triggeredProcs: readonly TriggeredAuraProc[],
|
|
now: number,
|
|
): void {
|
|
for (const triggered of triggeredProcs) {
|
|
const action = triggered.action;
|
|
if (action.kind === "custom" && action.id.startsWith("rom-")) {
|
|
const triggeringAbility = triggered.event.abilityId
|
|
? work.abilities.find((ability) => ability.id === triggered.event.abilityId)
|
|
?? abilityById(triggered.event.abilityId)
|
|
: null;
|
|
const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId
|
|
&& aura.definition.utilityTags?.includes("rom-poisonous"));
|
|
const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous);
|
|
if (spec) {
|
|
const recipient = spec.recipient === "caster"
|
|
? triggered.sourceId : triggered.event.otherId ?? triggered.targetId;
|
|
if (spec.kind === "aura" && recipient) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: spec.aura, sourceId: triggered.sourceId, targetId: recipient, now,
|
|
}).auras];
|
|
} else if (spec.kind === "resource" && recipient === PLAYER_AURA_ENTITY_ID) {
|
|
const maximum = work.maxResourcePools[spec.resource] ?? defaultResourceMaximum(spec.resource);
|
|
work.resourcePools[spec.resource] = clamp(
|
|
(work.resourcePools[spec.resource] ?? 0) + spec.amount, 0, maximum,
|
|
);
|
|
} else if (spec.kind === "resource" && recipient && work.actors[recipient]) {
|
|
const actor = work.actors[recipient];
|
|
const pool = actor.resources[spec.resource];
|
|
if (pool) {
|
|
const next = { ...actor, resources: {
|
|
...actor.resources,
|
|
[spec.resource]: { ...pool, current: clamp(pool.current + spec.amount, 0, pool.maximum) },
|
|
} };
|
|
work.actors[recipient] = next;
|
|
mirrorCompanionActor(next);
|
|
}
|
|
} else if (spec.kind === "dot" && recipient && work.mobs[recipient]) {
|
|
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
|
|
&& effect.sourceActorId === triggered.sourceId
|
|
&& effect.targetId === recipient && effect.tag === spec.effect.tag));
|
|
work.effects.push(timedEffect(
|
|
"dot", triggeringAbility?.id ?? triggered.procId,
|
|
triggered.sourceId, recipient, spec.effect,
|
|
spec.effect.ticks, spec.effect.intervalMs, now,
|
|
spec.effect.tag, spec.effect.school ?? "nature",
|
|
work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
const actionTarget = action.kind === "custom"
|
|
? null
|
|
: action.target === "aura-source"
|
|
? triggered.sourceId
|
|
: action.target === "aura-target"
|
|
? triggered.targetId
|
|
: triggered.event.otherId ?? null;
|
|
if (action.kind === "resource" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
const resource = action.resource as ResourceType;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
|
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
work.totalHealing += healPlayerInWork(work, action.amount);
|
|
} else if (action.kind === "heal" && actionTarget && !work.mobs[actionTarget]) {
|
|
work.totalHealing += healCompanionActorInWork(work, actionTarget, Math.max(0, action.amount));
|
|
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
|
damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
actionTarget,
|
|
Math.max(0, action.amount),
|
|
now,
|
|
playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true),
|
|
action.school,
|
|
);
|
|
} else if (action.kind === "apply-aura" && actionTarget) {
|
|
const definition = work.auras.find((aura) => aura.definition.id === action.auraId)?.definition;
|
|
if (definition) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition,
|
|
sourceId: triggered.sourceId,
|
|
targetId: actionTarget,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (
|
|
action.kind === "custom"
|
|
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
|
&& typeof action.data?.spellId === "number"
|
|
) {
|
|
const spellId = action.data.spellId;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
|
|
const triggeredSpellStack = work.triggeredSpellStack ?? [];
|
|
if (
|
|
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|
|
|| triggeredSpellStack.includes(triggerKey)
|
|
) continue;
|
|
triggeredSpellStack.push(triggerKey);
|
|
work.triggeredSpellStack = triggeredSpellStack;
|
|
try {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
spellId,
|
|
triggered.sourceId,
|
|
targetId,
|
|
now,
|
|
0,
|
|
true,
|
|
);
|
|
} finally {
|
|
triggeredSpellStack.pop();
|
|
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
|
|
}
|
|
} else if (
|
|
action.kind === "custom"
|
|
&& action.id === "classic-status"
|
|
&& typeof action.data?.status === "string"
|
|
) {
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const mob = work.mobs[targetId];
|
|
if (mob) {
|
|
work.mobs[targetId] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: action.data.status as MobStatusKind,
|
|
durationMs: typeof action.data.durationMs === "number" ? action.data.durationMs : 3_000,
|
|
...(typeof action.data.magnitude === "number" ? { magnitude: action.data.magnitude } : {}),
|
|
}, `proc:${triggered.procId}`, now);
|
|
}
|
|
} else if (action.kind === "custom" && action.id === "rom-shield-discipline-reflect") {
|
|
const attackerId = triggered.event.otherId;
|
|
if (triggered.event.attackKind === "spell" && attackerId && work.mobs[attackerId]) {
|
|
damageMobInWork(work.classId, work, attackerId, 100, now, {
|
|
actorId: triggered.sourceId,
|
|
role: "tank",
|
|
abilityId: "rom-knight-general-7",
|
|
}, "holy");
|
|
}
|
|
} else if (action.kind === "custom" && action.id === "rom-frost-arrow") {
|
|
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
const mob = work.mobs[targetId];
|
|
if (mob && triggeringAbility?.name === "Wind Arrow") {
|
|
work.mobs[targetId] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "slow",
|
|
durationMs: 6_000,
|
|
magnitude: 0.4,
|
|
}, `proc:${triggered.procId}`, now);
|
|
}
|
|
} else if (action.kind === "custom" && action.id === "rom-entling-offering") {
|
|
const triggeringAbility = triggered.event.abilityId ? abilityById(triggered.event.abilityId) : null;
|
|
const targetId = triggered.event.otherId ?? triggered.targetId;
|
|
if (work.mobs[targetId] && triggeringAbility?.name === "Shot") {
|
|
damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
targetId,
|
|
Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
|
|
now,
|
|
playerThreatSource({ talentModifiers: work.talentModifiers }, "rom-entling-offering", true),
|
|
"physical",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function evaluateProcsInWork(
|
|
work: MutableCastWork,
|
|
event: ProcEvent,
|
|
now: number,
|
|
): readonly TriggeredAuraProc[] {
|
|
const root = work.triggeredProcKeys === undefined;
|
|
const triggeredKeys = work.triggeredProcKeys ?? new Set<string>();
|
|
work.triggeredProcKeys = triggeredKeys;
|
|
try {
|
|
const result = evaluateAuraProcs(
|
|
work.auras,
|
|
event,
|
|
now,
|
|
({ aura, proc }) => triggeredKeys.has(`${aura.instanceId}:${proc.id}`)
|
|
? 1
|
|
: stableCombatUnit(
|
|
`${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`,
|
|
),
|
|
);
|
|
// A proc can activate once for the originating event. Its damage must not
|
|
// reactivate it through a different branch of the same triggered chain.
|
|
for (const proc of result.triggered) triggeredKeys.add(`${proc.auraInstanceId}:${proc.procId}`);
|
|
work.auras = [...result.auras];
|
|
executeTriggeredAuraProcsInWork(work, result.triggered, now);
|
|
return result.triggered;
|
|
} finally {
|
|
if (root) work.triggeredProcKeys = undefined;
|
|
}
|
|
}
|
|
|
|
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
|
|
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
|
|
});
|
|
}
|
|
|
|
function castFailureMessage(reason: CastFailureReason, resourceName: string, unlockLevel?: number): string {
|
|
switch (reason) {
|
|
case "unknown-ability": return "That ability is unavailable.";
|
|
case "wrong-class": return "Your class cannot use that ability.";
|
|
case "not-learned": return "Learn that ability in the Conquest talent tree first.";
|
|
case "passive": return "Passive abilities activate automatically and cannot be cast.";
|
|
case "unsupported": return "That spell has no executable combat effect.";
|
|
case "level-locked": return `That ability unlocks at level ${unlockLevel ?? "a higher level"}.`;
|
|
case "player-dead": return "You cannot act while defeated.";
|
|
case "player-controlled": return "You cannot act while under an enemy control effect.";
|
|
case "already-casting": return "Another spell is already being cast.";
|
|
case "global-cooldown": return "Your abilities are not ready yet.";
|
|
case "cooldown": return "That ability is still recharging.";
|
|
case "not-enough-resource": return `Not enough ${resourceName}.`;
|
|
case "not-enough-runes": return "The required Death Knight runes are still recharging.";
|
|
case "condition-not-met": return "That ability's required RuneWaker state is not active.";
|
|
case "no-target": return "Select an enemy target.";
|
|
case "target-dead": return "That target is already defeated.";
|
|
case "target-unavailable": return "That target is not currently visible.";
|
|
case "line-of-sight": return "That target is not in line of sight.";
|
|
case "not-facing": return "You must face that target.";
|
|
case "must-be-behind": return "You must be behind that target.";
|
|
case "invalid-destination": return "That ground destination is invalid.";
|
|
case "school-locked": return "That spell school is locked out.";
|
|
case "too-close": return "That target is too close.";
|
|
case "out-of-range": return "That target is out of range.";
|
|
}
|
|
}
|
|
|
|
function levelUpMessage(classId: ClassId, previousLevel: number, currentLevel: number): string {
|
|
const parts = [`Level ${currentLevel}!`];
|
|
const unlocked = abilitiesUnlockedBetweenLevels(classId, previousLevel, currentLevel);
|
|
if (unlocked.length === 1) {
|
|
parts.push(`New ability unlocked: ${unlocked[0].name}.`);
|
|
} else if (unlocked.length > 1) {
|
|
parts.push(`New abilities unlocked: ${unlocked.map((ability) => ability.name).join(", ")}.`);
|
|
}
|
|
if (talentPointsForLevel(currentLevel) > talentPointsForLevel(previousLevel)) {
|
|
parts.push("A new talent point is available.");
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function progressionFromState(state: CombatState): MutableProgression {
|
|
return {
|
|
level: state.level,
|
|
xp: state.xp,
|
|
xpToNext: state.xpToNext,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
levelsGained: 0,
|
|
};
|
|
}
|
|
|
|
function grantMobExperience(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
xpReward: number,
|
|
): number {
|
|
const { progression } = work;
|
|
const scaledAward = healerManExperienceAward(xpReward, progression.level);
|
|
const result = awardExperience({ level: progression.level, xp: progression.xp }, scaledAward);
|
|
progression.level = result.level;
|
|
progression.xp = result.xp;
|
|
progression.xpToNext = result.xpToNext;
|
|
progression.levelsGained += result.levelsGained;
|
|
if (result.levelsGained > 0) {
|
|
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
|
|
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
|
progression.maxHealth = auraAdjustedPlayerMaxHealth(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
work.auras,
|
|
);
|
|
// A lingering DoT may grant experience after death, but leveling is not resurrection.
|
|
progression.health = work.health > 0 ? progression.maxHealth : 0;
|
|
work.health = progression.health;
|
|
work.maxHealth = progression.maxHealth;
|
|
work.maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
);
|
|
}
|
|
return scaledAward;
|
|
}
|
|
|
|
function mobGroupId(id: string): string {
|
|
if (id.startsWith("manastorm:guardian-spawn:")) {
|
|
const memberSeparator = id.lastIndexOf(":");
|
|
return memberSeparator > 0 ? id.slice(0, memberSeparator) : id;
|
|
}
|
|
// A Manastorm boss shares an aura with guardians, not their aggro group.
|
|
// Keeping its full runtime id lets players choose to pull it early without
|
|
// every guardian hit dragging the boss across the stage.
|
|
if (id.startsWith("manastorm:spawn:")) return id;
|
|
const separator = id.indexOf(":");
|
|
return separator > 0 ? id.slice(0, separator) : id;
|
|
}
|
|
|
|
function engageMobGroup(
|
|
mobs: Record<string, MobCombatState>,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): boolean {
|
|
const groupId = mobGroupId(sourceId);
|
|
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
|
|
mobGroupId(id) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
let changed = false;
|
|
for (const [id, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
|
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
|
const threatByActor = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, sourceActorId);
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor,
|
|
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(id))
|
|
: (getMobPosition(id) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function engageMobGroupInWork(
|
|
work: MutableCastWork,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): void {
|
|
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
|
|
}
|
|
|
|
function nearestAggroActorId(
|
|
state: Pick<CombatState, "health" | "playerPosition" | "summons">,
|
|
mobPosition: CombatPosition,
|
|
aggroRange: number,
|
|
): AggroActorId | null {
|
|
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
|
|
let nearestId: AggroActorId | null = null;
|
|
let nearestDistanceSquared = aggroRange * aggroRange;
|
|
if (state.health > 0) {
|
|
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
|
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
|
nearestId = PLAYER_AGGRO_ID;
|
|
nearestDistanceSquared = playerDistanceSquared;
|
|
}
|
|
}
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health <= 0) continue;
|
|
const position = getPartyRuntimePosition(member.id);
|
|
if (!position) continue;
|
|
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
|
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
|
nearestId = member.id;
|
|
nearestDistanceSquared = memberDistanceSquared;
|
|
}
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) {
|
|
if (actor.health <= 0) continue;
|
|
const position = getPartyRuntimePosition(actor.id);
|
|
if (!position) continue;
|
|
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
|
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
|
nearestId = actor.id;
|
|
nearestDistanceSquared = actorDistanceSquared;
|
|
}
|
|
}
|
|
for (const summon of state.summons) {
|
|
if (summon.health <= 0) continue;
|
|
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
|
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
|
nearestId = summon.id;
|
|
nearestDistanceSquared = summonDistanceSquared;
|
|
}
|
|
}
|
|
return nearestId;
|
|
}
|
|
|
|
function mobResistanceForSchool(
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
): number {
|
|
if (school === "arcane") return mob.resistances.arcane;
|
|
if (school === "fire") return mob.resistances.fire;
|
|
if (school === "frost") return mob.resistances.frost;
|
|
if (school === "nature") return mob.resistances.nature;
|
|
if (school === "shadow") return mob.resistances.shadow;
|
|
return 0;
|
|
}
|
|
|
|
function damageAfterMobResistance(
|
|
amount: number,
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
spellPenetration: number,
|
|
attackerLevel: number,
|
|
): number {
|
|
if (mob.schoolImmunities.includes(school)) return 0;
|
|
if (school === "physical" || school === "holy") return amount;
|
|
const resistance = Math.max(0, mobResistanceForSchool(mob, school) - Math.max(0, spellPenetration));
|
|
const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75;
|
|
return amount * (1 - Math.min(0.75, resisted));
|
|
}
|
|
|
|
function damageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
school: DamageSchool = "physical",
|
|
spellPenetration = 0,
|
|
combatResult?: CombatResult,
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
}
|
|
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
|
amount,
|
|
mob,
|
|
school,
|
|
spellPenetration,
|
|
work.progression.level,
|
|
);
|
|
const mitigated = resolveAuraDamageValue(
|
|
mitigatedBeforeAuras,
|
|
aurasForEntity(work.auras, id),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const auraAbsorb = absorbAuraDamage(work.auras, {
|
|
targetId: id,
|
|
amount: mitigated,
|
|
school,
|
|
now,
|
|
});
|
|
work.auras = [...auraAbsorb.auras];
|
|
const characterAbsorbed = Math.min(mob.characterShield ?? 0, auraAbsorb.remainingDamage);
|
|
const remainingDamage = Math.max(0, auraAbsorb.remainingDamage - characterAbsorbed);
|
|
const adjustedCombatResult = combatResult
|
|
? { ...combatResult, finalDamage: Math.max(0, Math.round(remainingDamage)) }
|
|
: undefined;
|
|
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
|
|
const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage)));
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: source.actorId,
|
|
targetActorId: id,
|
|
...(source.abilityId ? { abilityId: source.abilityId } : {}),
|
|
school,
|
|
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: auraAbsorb.absorbed + characterAbsorbed,
|
|
critical: combatResult?.critical ?? false,
|
|
outcome: combatResult?.outcome ?? "hit",
|
|
});
|
|
const thorns = mob.auras.find((aura) => aura.id === "thorns");
|
|
if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) {
|
|
const reflected = Math.min(
|
|
work.health,
|
|
Math.max(1, Math.round(applied * (thorns.magnitude ?? 0.08))),
|
|
);
|
|
work.health -= reflected;
|
|
work.progression.health = work.health;
|
|
}
|
|
const health = mob.health - applied;
|
|
const killed = health <= 0;
|
|
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
|
const testing = useGameStore.getState().gmTestMode;
|
|
const canAward = killed && !mob.experienceGranted && !testing;
|
|
const beforeLevel = work.progression.level;
|
|
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
|
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
|
now,
|
|
bonusItemChance: mob.bonusLootChance,
|
|
})
|
|
: mob.pendingLoot;
|
|
const card = killed && mob.boss && !testing
|
|
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
|
|
: null;
|
|
const bossCardDrop = card ? {
|
|
bossKey: card.key,
|
|
spawnId: mob.id,
|
|
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
|
|
rarities: rollBossCards(),
|
|
} satisfies BossCardDrop : mob.bossCardDrop;
|
|
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
|
|
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
|
|
engageMobGroupInWork(work, id, now, source.actorId);
|
|
const engagedMob = work.mobs[id] ?? mob;
|
|
const threatByActor = killed
|
|
? {}
|
|
: addActorThreat(
|
|
engagedMob.threatByActor,
|
|
source.actorId,
|
|
threatFromDamage(applied, source.role, source.threatScale)
|
|
* resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"),
|
|
);
|
|
work.mobs[id] = {
|
|
...engagedMob,
|
|
...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}),
|
|
health,
|
|
dead: killed,
|
|
hasLoot,
|
|
pendingLoot,
|
|
bossCardDrop,
|
|
looted: killed ? false : mob.looted,
|
|
despawned: false,
|
|
diedAt: killed ? now : mob.diedAt,
|
|
corpseDespawnAt: killed
|
|
? (hasLoot ? null : now + EMPTY_CORPSE_DESPAWN_MS)
|
|
: mob.corpseDespawnAt,
|
|
experienceGranted: mob.experienceGranted || killed,
|
|
lastDamageAt: now,
|
|
statuses: killed ? [] : applied > 0
|
|
? mob.statuses.filter((status) => !(status.breakOnDamage
|
|
?? ["fear", "sleep", "blind", "confuse"].includes(status.kind)))
|
|
: mob.statuses,
|
|
threatByActor,
|
|
targetActorId: killed ? null : (engagedMob.targetActorId ?? source.actorId),
|
|
forcedTarget: killed ? null : engagedMob.forcedTarget,
|
|
engaged: !killed,
|
|
combatPhase: killed ? "idle" : "chasing",
|
|
homePosition: engagedMob.homePosition ?? getMobPosition(id),
|
|
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
|
|
activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast
|
|
? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) }
|
|
: engagedMob.activeCast,
|
|
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
|
|
nextWoundAnimationAt: canPlayWoundAnimation
|
|
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
|
|
: mob.nextWoundAnimationAt,
|
|
deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision,
|
|
};
|
|
// Pulling a member links the whole authored pack even when the opening hit
|
|
// kills that member outright. The dead source remains inert because the
|
|
// group helper deliberately skips dead mobs.
|
|
work.totalDamage += applied;
|
|
if (applied > 0) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "damage-taken",
|
|
subjectId: id,
|
|
otherId: source.actorId,
|
|
abilityId: source.abilityId,
|
|
amount: applied,
|
|
school,
|
|
}, now);
|
|
}
|
|
return {
|
|
amount: applied,
|
|
killed,
|
|
xpAwarded,
|
|
levelsGained: work.progression.level - beforeLevel,
|
|
...(adjustedCombatResult ? { combatResult: adjustedCombatResult } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveAndDamageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
criticalChanceBonusPercent = 0,
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
}
|
|
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
|
const combustion = !periodic && attackKind === "spell" && school === "fire"
|
|
? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("fire-crit-stacking"))
|
|
: undefined;
|
|
const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, {
|
|
direction: "dealt",
|
|
school,
|
|
attackKind,
|
|
});
|
|
const combatResult = resolvePlayerDamageResult(
|
|
classId,
|
|
work,
|
|
source.actorId,
|
|
mob,
|
|
modifiedAmount,
|
|
attackKind,
|
|
school,
|
|
seed,
|
|
ability,
|
|
periodic,
|
|
rules,
|
|
criticalChanceBonusPercent + (combustion ? Math.max(0, combustion.stacks - 1) : 0),
|
|
);
|
|
const result = damageMobInWork(
|
|
classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
source,
|
|
school,
|
|
work.gearStats.spellPenetration,
|
|
combatResult,
|
|
);
|
|
if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) {
|
|
const passiveTags = new Set(aurasForEntity(work.auras, source.actorId)
|
|
.flatMap((aura) => aura.definition.utilityTags ?? []));
|
|
for (const rider of romMageWarlockCriticalEffects(ability, passiveTags)) {
|
|
if (rider.kind !== "apply-aura") continue;
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: auraDefinitionForAbility(rider.aura, ability),
|
|
sourceId: source.actorId,
|
|
targetId: id,
|
|
now,
|
|
}).auras];
|
|
}
|
|
}
|
|
if (combustion && combatResult.landed && result.amount > 0) {
|
|
const criticalHits = (combustion.combustionCriticalHits ?? 0) + Number(combatResult.critical);
|
|
work.auras = work.auras.flatMap((aura) => aura.instanceId !== combustion.instanceId
|
|
? [aura]
|
|
: criticalHits >= 3
|
|
? []
|
|
: [{
|
|
...aura,
|
|
stacks: Math.min(aura.definition.maxStacks ?? 100, aura.stacks + 1),
|
|
combustionCriticalHits: criticalHits,
|
|
}]);
|
|
}
|
|
const trigger = combatResult.outcome === "crit"
|
|
? "crit"
|
|
: combatResult.outcome === "dodge"
|
|
? "dodge"
|
|
: combatResult.outcome === "parry"
|
|
? "parry"
|
|
: combatResult.outcome === "block"
|
|
? "block"
|
|
: combatResult.landed
|
|
? "hit"
|
|
: null;
|
|
if (trigger) {
|
|
evaluateProcsInWork(work, {
|
|
trigger,
|
|
subjectId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? id : source.actorId,
|
|
otherId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? source.actorId : id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
if (
|
|
combatResult.landed
|
|
&& result.amount > 0
|
|
&& attackKind === "melee"
|
|
&& !periodic
|
|
&& !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "")
|
|
) {
|
|
const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding) ? 2 : 1;
|
|
applyRogueWeaponPoisonsInWork(
|
|
work,
|
|
source.actorId,
|
|
id,
|
|
ability?.id ?? source.abilityId ?? "melee-hit",
|
|
hitCount,
|
|
now,
|
|
);
|
|
}
|
|
if (result.killed) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "kill",
|
|
subjectId: source.actorId,
|
|
otherId: id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function applyStatus(
|
|
mob: MobCombatState,
|
|
effect: Extract<AbilityEffect, { kind: "status" }>,
|
|
abilityId: string,
|
|
now: number,
|
|
sourceActorId?: string,
|
|
): MobCombatState {
|
|
if (mob.dead) return mob;
|
|
if (mob.mechanicImmunities.includes(effect.status as AzerothCoreMechanic)) return mob;
|
|
const baseDurationMs = mob.boss && effect.status === "stun"
|
|
? Math.min(3000, effect.durationMs)
|
|
: effect.durationMs;
|
|
const diminishingStatuses = new Set<MobStatusKind>([
|
|
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
|
|
]);
|
|
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
|
|
const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
|
|
? previousDiminishing.applications
|
|
: 0;
|
|
const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
|
|
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
|
|
: 1;
|
|
const durationMs = Math.round(baseDurationMs * multiplier);
|
|
const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
|
|
? {
|
|
...mob.controlDiminishingReturns,
|
|
[effect.status]: {
|
|
applications: Math.min(3, applications + 1),
|
|
resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000,
|
|
},
|
|
}
|
|
: mob.controlDiminishingReturns;
|
|
if (effect.status === "interrupt") {
|
|
const cast = mob.activeCast;
|
|
if (!cast?.interruptible) return mob;
|
|
return {
|
|
...mob,
|
|
activeCast: null,
|
|
nextAttackAt: now,
|
|
schoolLockedUntil: {
|
|
...mob.schoolLockedUntil,
|
|
[cast.school]: Math.max(mob.schoolLockedUntil[cast.school] ?? 0, now + durationMs),
|
|
},
|
|
};
|
|
}
|
|
if (durationMs <= 0) return { ...mob, controlDiminishingReturns };
|
|
const status: MobStatus = {
|
|
kind: effect.status,
|
|
sourceAbilityId: abilityId,
|
|
...(sourceActorId ? { sourceActorId } : {}),
|
|
endsAt: now + durationMs,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
...(effect.breakOnDamage === undefined ? {} : { breakOnDamage: effect.breakOnDamage }),
|
|
};
|
|
const incapacitated = [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(effect.status);
|
|
const cancelsActiveCast = Boolean(mob.activeCast && (
|
|
incapacitated
|
|
|| (effect.status === "silence" && mob.activeCast!.school !== "physical")
|
|
|| (effect.status === "disarm" && mob.activeCast!.school === "physical")
|
|
));
|
|
return {
|
|
...mob,
|
|
...(cancelsActiveCast
|
|
? { activeCast: null, nextAttackAt: now }
|
|
: {}),
|
|
controlDiminishingReturns,
|
|
statuses: [...mob.statuses.filter((entry) => entry.kind !== effect.status), status],
|
|
};
|
|
}
|
|
|
|
function executeTriggeredSpellInWork(
|
|
work: MutableCastWork,
|
|
spellId: number,
|
|
sourceId: string,
|
|
targetId: string | null,
|
|
now: number,
|
|
depth: number,
|
|
allowCoaHidden = false,
|
|
): void {
|
|
if (depth > 1) return;
|
|
const catalogAbility = work.abilities.find((candidate) => (
|
|
candidate.dbcSpellId === spellId
|
|
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
|
|
)) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden);
|
|
if (!catalogAbility) return;
|
|
const ability = abilityAtLevel(catalogAbility, work.progression.level);
|
|
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
|
|
const attackKind = abilityAttackKind(work.classId, ability);
|
|
const school = abilityDamageSchool(work.classId, ability);
|
|
const mobTargets = targetId && work.mobs[targetId] && !work.mobs[targetId].dead ? [targetId] : [];
|
|
for (const effect of ability.effects) {
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const mobId of mobTargets) {
|
|
const effectSchool = effect.school ?? school;
|
|
const result = resolveAndDamageMobInWork(
|
|
work.classId,
|
|
work,
|
|
mobId,
|
|
amount,
|
|
now,
|
|
source,
|
|
effectSchool === "physical" ? attackKind : "spell",
|
|
effectSchool,
|
|
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
|
ability,
|
|
);
|
|
grantDamageResourceInWork(work, effect, result.amount, sourceId, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const rawAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
|
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
|
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
|
for (const recipient of recipients) {
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
|
aurasForEntity(work.auras, recipient),
|
|
"taken",
|
|
));
|
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
|
work.totalHealing += healPlayerInWork(work, amount);
|
|
} else {
|
|
const healed = healCompanionActorInWork(work, recipient, amount);
|
|
work.totalHealing += healed;
|
|
}
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], effect, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const recipient = effect.recipient === "caster"
|
|
? sourceId
|
|
: targetId ?? sourceId;
|
|
const previousAuras = work.auras;
|
|
work.auras = [...applyCombatAura(previousAuras, {
|
|
definition: auraDefinitionForAbility(effect.aura, ability),
|
|
sourceId,
|
|
targetId: recipient,
|
|
now,
|
|
}).auras];
|
|
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
|
|
if (effect.control && work.mobs[recipient]) {
|
|
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs: effect.aura.durationMs ?? 1_000,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${effect.aura.id}`, now);
|
|
}
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum);
|
|
if (resource === resourceProfileForClass(work.classId).type) {
|
|
work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "dot") {
|
|
for (const mobId of mobTargets) {
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "dot"
|
|
&& entry.targetId === mobId
|
|
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"dot", ability.id, source.actorId, mobId, effect, effect.ticks, effect.intervalMs, now,
|
|
effect.tag, school, work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
} else if (effect.kind === "hot") {
|
|
const recipients = healingRecipientIds(effect, sourceId, targetId, work.actors,
|
|
id => id === PLAYER_AURA_ENTITY_ID ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
|
[targetId && !work.mobs[targetId] ? targetId : sourceId], combatHasLineOfSight, now);
|
|
for (const recipient of recipients) {
|
|
work.effects.push(timedEffect(
|
|
"hot", ability.id, source.actorId, recipient, effect, effect.ticks, effect.intervalMs, now,
|
|
));
|
|
}
|
|
} else if (effect.kind === "shield") {
|
|
const amount = equipmentAdjustedCombatAmount(
|
|
effect, work.progression.level, work.gearStats, work.classId, ability,
|
|
);
|
|
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
|
work.shield = Math.max(work.shield, amount);
|
|
work.shieldExpiresAt = now + effect.durationMs;
|
|
} else {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: {
|
|
id: `coa-triggered-shield:${spellId}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs: effect.durationMs,
|
|
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
|
|
},
|
|
sourceId,
|
|
targetId: recipient,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "summon") {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-triggered-summon-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: /totem/i.test(ability.name) ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId: sourceId,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
position: getMobPosition(targetId ?? "") ?? [0, 0, 0],
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
});
|
|
} else if (effect.kind === "resource-drain") {
|
|
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
if (mobTargets.length && !effect.burn) {
|
|
const resourceBefore = work.resourcePools[resource] ?? 0;
|
|
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
|
|
} else if (!mobTargets.length) {
|
|
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount);
|
|
}
|
|
if (resource === resourceProfileForClass(work.classId).type) {
|
|
work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "pull") {
|
|
const playerPosition = useCombatStore.getState().playerPosition;
|
|
for (const mobId of mobTargets) {
|
|
const position = getMobPosition(mobId);
|
|
if (!position || effect.toward !== "caster") continue;
|
|
const dx = position[0] - playerPosition[0];
|
|
const dz = position[2] - playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(mobId, [
|
|
playerPosition[0] + dx / length * 1.5,
|
|
position[1],
|
|
playerPosition[2] + dz / length * 1.5,
|
|
]);
|
|
}
|
|
} else if (effect.kind === "dispel") {
|
|
const recipient = targetId ?? sourceId;
|
|
const targeted = work.auras.filter((aura) => aura.targetId === recipient);
|
|
const untouched = work.auras.filter((aura) => aura.targetId !== recipient);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship: effect.relationship === "target"
|
|
? work.mobs[recipient] ? "hostile" : "friendly"
|
|
: effect.relationship,
|
|
categories: effect.categories,
|
|
maxCount: effect.maxCount,
|
|
});
|
|
work.auras = [...untouched, ...result.auras];
|
|
} else if (effect.kind === "execute") {
|
|
for (const mobId of mobTargets) {
|
|
const mob = work.mobs[mobId];
|
|
if (mob && !mob.boss) damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
mobId,
|
|
Math.max(mob.health, mob.maxHealth * effect.coefficient),
|
|
now,
|
|
source,
|
|
school,
|
|
);
|
|
}
|
|
} else if (effect.kind === "threat") {
|
|
for (const mobId of mobTargets) {
|
|
const mob = work.mobs[mobId];
|
|
if (!mob) continue;
|
|
const current = mob.threatByActor[source.actorId] ?? 0;
|
|
work.mobs[mobId] = {
|
|
...mob,
|
|
threatByActor: {
|
|
...mob.threatByActor,
|
|
[source.actorId]: effect.mode === "percent"
|
|
? current * Math.max(0, 1 + effect.amount / 100)
|
|
: effect.mode === "redirect" ? 0 : Math.max(0, current + effect.amount),
|
|
},
|
|
};
|
|
}
|
|
} else if (effect.kind === "cooldown-reset") {
|
|
const candidates = cooldownResetCandidates(work.abilities, ability.id, effect.abilityName);
|
|
for (const candidate of candidates) {
|
|
if (effect.mode === "reset") work.cooldowns[candidate.id] = 0;
|
|
else {
|
|
const readyAt = work.cooldowns[candidate.id] ?? now;
|
|
const reduction = effect.amountPercent !== undefined
|
|
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent))
|
|
: effect.amountMs ?? 0;
|
|
work.cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
|
|
}
|
|
}
|
|
} else if (effect.kind === "trigger-spell" && !effect.expanded && depth < 1 && effect.spellId !== spellId) {
|
|
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1, allowCoaHidden);
|
|
}
|
|
}
|
|
}
|
|
|
|
function grantDamageResourceInWork(
|
|
work: MutableCastWork, effect: AbilityDamageResource, damage: number,
|
|
sourceActorId: string, abilityId: string, now: number,
|
|
): void {
|
|
const gain = effect.onDamageResource;
|
|
if (!gain || damage <= 0 || gain.amount <= 0) return;
|
|
const player = sourceActorId === PLAYER_AURA_ENTITY_ID;
|
|
const actor = player ? null : ensureCompanionActorInWork(work, sourceActorId) ?? work.actors[sourceActorId];
|
|
if (player ? work.health <= 0 : !actor?.alive) return;
|
|
const pool = actor?.resources[gain.resource];
|
|
const maximum = player ? work.maxResourcePools[gain.resource] ?? defaultResourceMaximum(gain.resource)
|
|
: pool?.maximum ?? defaultResourceMaximum(gain.resource);
|
|
const current = player ? work.resourcePools[gain.resource] ?? 0 : pool?.current ?? 0;
|
|
const auras = aurasForEntity(work.auras, sourceActorId);
|
|
const requested = Math.max(0, resolveAuraResourceValue(
|
|
resolveAuraResourceValue(gain.amount, auras, gain.resource, "generation"), auras, "power", "generation",
|
|
));
|
|
const restored = Math.max(0, Math.min(requested, maximum - current));
|
|
if (player) {
|
|
work.resourcePools[gain.resource] = current + restored;
|
|
if (gain.resource === resourceProfileForClass(work.classId).type) work.resource = current + restored;
|
|
} else if (actor) {
|
|
const next = { ...actor, resources: { ...actor.resources,
|
|
[gain.resource]: { ...(pool ?? normalizeActorResourcePool(gain.resource, current, maximum)), current: current + restored } } };
|
|
work.actors[sourceActorId] = next;
|
|
mirrorCompanionActor(next);
|
|
}
|
|
recordCombatEventInWork(work, { occurredAt: now, kind: "resource", sourceActorId, targetActorId: sourceActorId,
|
|
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
|
|
}
|
|
|
|
function withoutUnlearnedTalentBindings(
|
|
bindings: ActionBindings,
|
|
abilities: readonly AbilityDefinition[],
|
|
talentRanks: TalentRanks,
|
|
): ActionBindings {
|
|
const unlearned = new Set(abilities
|
|
.filter((ability) => abilityTalentNodeId(ability) && !(learnedAbilityRank(ability, talentRanks) ?? 0))
|
|
.map((ability) => ability.id));
|
|
return Object.fromEntries(Object.entries(bindings).map(([id, abilityId]) => [
|
|
id,
|
|
abilityId && unlearned.has(abilityId) ? null : abilityId,
|
|
])) as ActionBindings;
|
|
}
|
|
|
|
function timedEffect(
|
|
kind: "dot" | "hot",
|
|
abilityId: string,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string | null,
|
|
scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean },
|
|
ticks: number,
|
|
intervalMs: number,
|
|
now: number,
|
|
tag?: string,
|
|
damageSchool?: DamageSchool,
|
|
spellPenetration?: number,
|
|
): TimedCombatEffect {
|
|
effectSequence += 1;
|
|
return {
|
|
id: `${abilityId}:${effectSequence}`,
|
|
kind,
|
|
sourceAbilityId: abilityId,
|
|
sourceActorId,
|
|
targetId,
|
|
coefficient: scaling.coefficient,
|
|
...(scaling.basePoints === undefined ? {} : { basePoints: scaling.basePoints }),
|
|
...(scaling.dieSides === undefined ? {} : { dieSides: scaling.dieSides }),
|
|
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
|
|
...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }),
|
|
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
|
|
...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }),
|
|
...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }),
|
|
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
|
|
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
|
|
...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}),
|
|
remainingTicks: ticks,
|
|
intervalMs,
|
|
nextTickAt: now + (scaling.startsImmediately ? 0 : intervalMs),
|
|
...(tag ? { tag } : {}),
|
|
...(damageSchool ? { damageSchool } : {}),
|
|
...(spellPenetration ? { spellPenetration } : {}),
|
|
};
|
|
}
|
|
|
|
function detonateLivingBombInWork(work: MutableCastWork, effect: TimedCombatEffect, now: number): void {
|
|
if (effect.kind !== "dot" || !effect.targetId) return;
|
|
const source = abilityById(effect.sourceAbilityId);
|
|
if (source?.name !== "Living Bomb") return;
|
|
const center = getMobPosition(effect.targetId);
|
|
if (!center) return;
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId);
|
|
const ability = abilityAtLevel(source, sourceMember?.level ?? work.progression.level);
|
|
const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
sourceMember?.level ?? work.progression.level,
|
|
sourceMember?.gearStats ?? work.gearStats,
|
|
sourceMember?.classId ?? work.classId,
|
|
ability,
|
|
);
|
|
const explosionAmount = Math.max(1, Math.round(tickAmount * 2));
|
|
for (const id of nearbyMobRuntimeIds(center, 10)) {
|
|
if (work.mobs[id]?.dead) continue;
|
|
damageMobInWork(work.classId, work, id, explosionAmount, now,
|
|
threatSourceForActor(work, effect.sourceActorId, effect.sourceAbilityId, true), "fire",
|
|
sourceMember?.gearStats.spellPenetration ?? work.gearStats.spellPenetration);
|
|
}
|
|
emitCombatPresentation({
|
|
occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party",
|
|
sourceActorId: effect.sourceActorId, targetActorId: effect.targetId,
|
|
abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name,
|
|
source: presentationSourceForAbility(ability), school: "fire", delivery: "area",
|
|
origin: center, targetPosition: center,
|
|
});
|
|
}
|
|
|
|
function detonateLivingBombFromStore(effect: TimedCombatEffect, now: number): void {
|
|
if (!effect.targetId || abilityById(effect.sourceAbilityId)?.name !== "Living Bomb") return;
|
|
const center = getMobPosition(effect.targetId);
|
|
if (!center) return;
|
|
const state = useCombatStore.getState();
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId);
|
|
const source = abilityById(effect.sourceAbilityId)!;
|
|
const ability = abilityAtLevel(source, sourceMember?.level ?? state.level);
|
|
const tickAmount = effect.fixedAmount ?? equipmentAdjustedCombatAmount(effect,
|
|
sourceMember?.level ?? state.level, sourceMember?.gearStats ?? state.gearStats,
|
|
sourceMember?.classId ?? state.classId, ability);
|
|
for (const id of nearbyMobRuntimeIds(center, 10)) {
|
|
if (useCombatStore.getState().mobs[id]?.dead) continue;
|
|
useCombatStore.getState().damageMob(id, Math.max(1, Math.round(tickAmount * 2)), now,
|
|
threatSourceForActor(useCombatStore.getState(), effect.sourceActorId, effect.sourceAbilityId, true), "fire");
|
|
}
|
|
emitCombatPresentation({
|
|
occurredAt: now, phase: "impact", actorGroup: effect.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party",
|
|
sourceActorId: effect.sourceActorId, targetActorId: effect.targetId,
|
|
abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name,
|
|
source: presentationSourceForAbility(ability), school: "fire", delivery: "area",
|
|
origin: center, targetPosition: center,
|
|
});
|
|
}
|
|
|
|
interface RoguePoisonSourceStats {
|
|
readonly classId: ClassId;
|
|
readonly level: number;
|
|
readonly attackPower: number;
|
|
readonly spellPenetration: number;
|
|
}
|
|
|
|
function roguePoisonSourceStats(
|
|
work: MutableCastWork,
|
|
sourceActorId: AggroActorId,
|
|
): RoguePoisonSourceStats | null {
|
|
if (sourceActorId === PLAYER_AGGRO_ID) {
|
|
if (baseClassFor(work.classId) !== "rogue") return null;
|
|
const stats = playerCharacterStats(
|
|
work.classId,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId ?? null,
|
|
work.talentModifiers,
|
|
);
|
|
return {
|
|
classId: work.classId,
|
|
level: work.progression.level,
|
|
attackPower: stats.attackPower,
|
|
spellPenetration: work.gearStats.spellPenetration,
|
|
};
|
|
}
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
|
|
if (!member || baseClassFor(member.classId) !== "rogue") return null;
|
|
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
|
return {
|
|
classId: member.classId,
|
|
level: member.level,
|
|
attackPower: stats.attackPower,
|
|
spellPenetration: member.gearStats.spellPenetration,
|
|
};
|
|
}
|
|
|
|
function replaceDeadlyPoisonEffect(
|
|
work: MutableCastWork,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string,
|
|
stacks: number,
|
|
now: number,
|
|
refreshDuration: boolean,
|
|
): void {
|
|
const ability = abilityById("rogue-deadly-poison");
|
|
const source = roguePoisonSourceStats(work, sourceActorId);
|
|
const existing = work.effects.find((effect) => (
|
|
effect.kind === "dot"
|
|
&& effect.sourceActorId === sourceActorId
|
|
&& effect.targetId === targetId
|
|
&& effect.sourceAbilityId === "rogue-deadly-poison"
|
|
));
|
|
work.effects = work.effects.filter((effect) => effect !== existing);
|
|
if (!ability || !source || stacks <= 0) return;
|
|
const amount = rogueDeadlyPoisonTickDamage(source.level, source.attackPower, stacks);
|
|
if (!refreshDuration && existing) {
|
|
work.effects.push({ ...existing, fixedAmount: amount });
|
|
return;
|
|
}
|
|
work.effects.push({
|
|
...timedEffect(
|
|
"dot",
|
|
ability.id,
|
|
sourceActorId,
|
|
targetId,
|
|
{ coefficient: 0 },
|
|
4,
|
|
3_000,
|
|
now,
|
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
|
"nature",
|
|
source.spellPenetration,
|
|
),
|
|
fixedAmount: amount,
|
|
});
|
|
}
|
|
|
|
function applyRogueWeaponPoisonsInWork(
|
|
work: MutableCastWork,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string,
|
|
sourceAbilityId: string,
|
|
hitCount: number,
|
|
now: number,
|
|
): RoguePoisonProcResult {
|
|
const source = roguePoisonSourceStats(work, sourceActorId);
|
|
const target = work.mobs[targetId];
|
|
if (!source || !target || target.dead) {
|
|
return { instantProcs: 0, deadlyApplications: 0, deadlyStacks: 0, damage: 0 };
|
|
}
|
|
const instantCoating = hasActiveAuraTag(work.auras, ROGUE_INSTANT_POISON_TAG, sourceActorId, now);
|
|
const deadlyCoating = hasActiveAuraTag(work.auras, ROGUE_DEADLY_POISON_COATING_TAG, sourceActorId, now);
|
|
const envenomBuff = hasActiveAuraTag(work.auras, ROGUE_ENVENOM_BUFF_TAG, sourceActorId, now);
|
|
const isShiv = abilityById(sourceAbilityId)?.name === "Shiv";
|
|
const chances = roguePoisonApplicationChances(envenomBuff);
|
|
let instantProcs = 0;
|
|
let deadlyApplications = 0;
|
|
let damage = 0;
|
|
|
|
const applyInstant = (): void => {
|
|
if (!instantCoating || work.mobs[targetId]?.dead) return;
|
|
const result = damageMobInWork(
|
|
work.classId,
|
|
work,
|
|
targetId,
|
|
rogueInstantPoisonDamage(source.level, source.attackPower),
|
|
now,
|
|
threatSourceForActor(work, sourceActorId, "rogue-instant-poison", true),
|
|
"nature",
|
|
source.spellPenetration,
|
|
);
|
|
if (result.amount <= 0) return;
|
|
instantProcs += 1;
|
|
damage += result.amount;
|
|
};
|
|
|
|
for (let hitIndex = 0; hitIndex < Math.max(1, Math.floor(hitCount)); hitIndex += 1) {
|
|
if (work.mobs[targetId]?.dead) break;
|
|
if (!isShiv && instantCoating && stableCombatUnit(
|
|
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:instant`,
|
|
) < chances.instant) {
|
|
applyInstant();
|
|
}
|
|
if (!deadlyCoating || (!isShiv && stableCombatUnit(
|
|
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:deadly`,
|
|
) >= chances.deadly)) continue;
|
|
|
|
const previousStacks = matchingAuraStacks(
|
|
work.auras,
|
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
|
sourceActorId,
|
|
targetId,
|
|
now,
|
|
);
|
|
const application = applyCombatAura(work.auras, {
|
|
definition: ROGUE_DEADLY_POISON_AURA,
|
|
sourceId: sourceActorId,
|
|
targetId,
|
|
now,
|
|
});
|
|
work.auras = [...application.auras];
|
|
deadlyApplications += 1;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "aura",
|
|
sourceActorId,
|
|
targetActorId: targetId,
|
|
abilityId: "rogue-deadly-poison",
|
|
school: "nature",
|
|
rawAmount: 0,
|
|
effectiveAmount: application.aura.stacks,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, application.aura.stacks, now, true);
|
|
// At five doses, another Deadly Poison application triggers the poison on
|
|
// the other weapon instead of creating a sixth dose.
|
|
if (previousStacks >= 5) applyInstant();
|
|
}
|
|
return {
|
|
instantProcs,
|
|
deadlyApplications,
|
|
deadlyStacks: matchingAuraStacks(
|
|
work.auras,
|
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
|
sourceActorId,
|
|
targetId,
|
|
now,
|
|
),
|
|
damage,
|
|
};
|
|
}
|
|
|
|
function consumeRogueDeadlyPoisonInWork(
|
|
work: MutableCastWork,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string,
|
|
maximumDoses: number,
|
|
now: number,
|
|
): number {
|
|
const index = work.auras.findIndex((aura) => (
|
|
aura.sourceId === sourceActorId
|
|
&& aura.targetId === targetId
|
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
|
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
|
|
));
|
|
if (index < 0) return 0;
|
|
const aura = work.auras[index];
|
|
const consumed = Math.min(aura.stacks, Math.max(0, Math.floor(maximumDoses)));
|
|
const remaining = aura.stacks - consumed;
|
|
if (remaining <= 0) work.auras.splice(index, 1);
|
|
else work.auras[index] = { ...aura, stacks: remaining };
|
|
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, remaining, now, false);
|
|
return consumed;
|
|
}
|
|
|
|
function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number {
|
|
return aurasForEntity(auras, entityId).reduce((total, aura) => (
|
|
total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0)
|
|
), 0);
|
|
}
|
|
|
|
function ensureCompanionActorInWork(work: MutableCastWork, id: string): CombatActorState | null {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (!member) return null;
|
|
const existing = work.actors[id];
|
|
const actor = companionActorFromMember(member, entityAbsorbAmount(work.auras, id), existing);
|
|
work.actors[id] = actor;
|
|
return actor;
|
|
}
|
|
|
|
function mirrorCompanionActor(actor: CombatActorState): void {
|
|
if (actor.kind !== "companion") return;
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actor.id);
|
|
if (!member) return;
|
|
const mirrored = memberFromCompanionActor(member, actor);
|
|
usePartyStore.getState().updateMemberCombat(actor.id, {
|
|
health: mirrored.health,
|
|
maxHealth: mirrored.maxHealth,
|
|
resource: mirrored.resource,
|
|
maxResource: mirrored.maxResource,
|
|
resourcePools: mirrored.resourcePools,
|
|
lastResourceSpentAt: mirrored.lastResourceSpentAt,
|
|
cooldowns: mirrored.cooldowns,
|
|
globalCooldownEndsAt: mirrored.globalCooldownEndsAt,
|
|
activeCast: mirrored.activeCast,
|
|
activeCastTargetId: mirrored.activeCastTargetId,
|
|
schoolLockedUntil: mirrored.schoolLockedUntil,
|
|
runes: mirrored.runes,
|
|
controlledUntil: mirrored.controlledUntil,
|
|
status: mirrored.status,
|
|
});
|
|
}
|
|
|
|
function applyAuraHealthTransitionInWork(
|
|
work: MutableCastWork,
|
|
targetId: string,
|
|
previousAuras: readonly ActiveAura[],
|
|
nextAuras: readonly ActiveAura[],
|
|
): void {
|
|
if (targetId === PLAYER_AURA_ENTITY_ID) {
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
work.classId,
|
|
work.progression.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
nextAuras,
|
|
);
|
|
const delta = maxHealth - work.maxHealth;
|
|
if (!delta) return;
|
|
work.maxHealth = maxHealth;
|
|
work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth);
|
|
work.progression.maxHealth = work.maxHealth;
|
|
work.progression.health = work.health;
|
|
const actor = work.actors[targetId];
|
|
if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth };
|
|
return;
|
|
}
|
|
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId);
|
|
const actor = ensureCompanionActorInWork(work, targetId);
|
|
if (!member || !actor) return;
|
|
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
|
const delta = auraMaximumHealthBonus(base, nextAuras, targetId)
|
|
- auraMaximumHealthBonus(base, previousAuras, targetId);
|
|
if (!delta) return;
|
|
const maxHealth = Math.max(1, actor.maxHealth + delta);
|
|
const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth);
|
|
const nextActor = Object.freeze({ ...actor, health, maxHealth });
|
|
work.actors[targetId] = nextActor;
|
|
mirrorCompanionActor(nextActor);
|
|
}
|
|
|
|
/** Ordinary healing never transitions a corpse back to a living actor. */
|
|
function healPlayerInWork(work: MutableCastWork, amount: number): number {
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (work.health <= 0 || requested <= 0) return 0;
|
|
const healed = Math.min(requested, Math.max(0, work.maxHealth - work.health));
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
const actor = work.actors[PLAYER_AURA_ENTITY_ID];
|
|
if (actor) work.actors[PLAYER_AURA_ENTITY_ID] = { ...actor, health: work.health, alive: true };
|
|
return healed;
|
|
}
|
|
|
|
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (!actor || actor.health <= 0 || requested <= 0) return 0;
|
|
const healed = Math.min(requested, actor.maxHealth - actor.health);
|
|
if (healed <= 0) return 0;
|
|
const next = Object.freeze({ ...actor, health: actor.health + healed, alive: true });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
return healed;
|
|
}
|
|
|
|
function reviveCompanionActorInWork(work: MutableCastWork, id: string, percentMaxHealth: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
if (!actor || actor.health > 0) return 0;
|
|
const health = Math.max(1, Math.round(actor.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth))));
|
|
const next = Object.freeze({ ...actor, health, alive: true, controlledUntil: 0, cast: null });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, status: "ready", controlMechanic: null }
|
|
: member),
|
|
}));
|
|
return health;
|
|
}
|
|
|
|
function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (!actor || actor.health <= 0 || requested <= 0) return 0;
|
|
const applied = Math.min(actor.health, requested);
|
|
const health = actor.health - applied;
|
|
const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
if (health <= 0) {
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, status: "down", activeCast: null, activeCastTargetId: null }
|
|
: member),
|
|
}));
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
function clearCompanionControlInWork(work: MutableCastWork, id: string): boolean {
|
|
const actor = ensureCompanionActorInWork(work, id);
|
|
if (!actor || actor.controlledUntil <= 0) return false;
|
|
const next = Object.freeze({ ...actor, controlledUntil: 0 });
|
|
work.actors[id] = next;
|
|
mirrorCompanionActor(next);
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => member.id === id
|
|
? { ...member, controlMechanic: null }
|
|
: member),
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
function combatActorSnapshots(state: CombatState): Readonly<Record<string, CombatActorState>> {
|
|
const actors: Record<string, CombatActorState> = {};
|
|
const resourceProfile = resourceProfileForClass(state.classId);
|
|
const playerResources = Object.fromEntries(
|
|
Object.entries(state.maxResourcePools).map(([id, maximum]) => [id, normalizeActorResourcePool(
|
|
id,
|
|
state.resourcePools[id as ResourceType] ?? 0,
|
|
maximum ?? 0,
|
|
id === resourceProfile.type ? resourceProfile.regenerationPerSecond : 0,
|
|
state.lastResourceSpentAt[id as ResourceType] ?? 0,
|
|
)]),
|
|
);
|
|
if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = {
|
|
id: PLAYER_AURA_ENTITY_ID,
|
|
threatActorId: PLAYER_AURA_ENTITY_ID,
|
|
kind: "player",
|
|
classId: state.classId,
|
|
level: state.level,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
absorb: state.shield + entityAbsorbAmount(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
resources: playerResources,
|
|
cooldowns: state.cooldowns,
|
|
globalCooldownEndsAt: state.globalCooldownEndsAt,
|
|
cast: state.activeCast ? {
|
|
abilityId: state.activeCast.abilityId,
|
|
school: state.activeCast.school,
|
|
mode: state.activeCast.mode,
|
|
startedAt: state.activeCast.startedAt,
|
|
completesAt: state.activeCast.completesAt,
|
|
originalDurationMs: state.activeCast.originalDurationMs,
|
|
pushbackHits: state.activeCast.pushbackHits,
|
|
} : null,
|
|
controlledUntil: state.controlledUntil,
|
|
schoolLockedUntil: state.schoolLockedUntil,
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
runes: state.deathKnightRunes,
|
|
alive: state.health > 0,
|
|
};
|
|
for (const member of usePartyStore.getState().members) {
|
|
actors[member.id] = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, member.id),
|
|
state.actors[member.id],
|
|
);
|
|
}
|
|
for (const actor of onlinePlayerRuntimeActors()) actors[actor.id] = actor;
|
|
for (const mob of Object.values(state.mobs)) {
|
|
actors[mob.id] = {
|
|
id: mob.id,
|
|
threatActorId: mob.id,
|
|
kind: "enemy",
|
|
level: mob.level,
|
|
health: mob.health,
|
|
maxHealth: mob.maxHealth,
|
|
absorb: entityAbsorbAmount(state.auras, mob.id),
|
|
resources: mob.maxMana > 0
|
|
? { mana: normalizeActorResourcePool("mana", mob.maxMana, mob.maxMana) }
|
|
: {},
|
|
cooldowns: mob.abilityReadyAt,
|
|
globalCooldownEndsAt: 0,
|
|
cast: mob.activeCast ? {
|
|
abilityId: mob.activeCast.attackId,
|
|
school: mob.activeCast.school,
|
|
mode: "cast",
|
|
startedAt: mob.activeCast.startedAt,
|
|
completesAt: mob.activeCast.completesAt,
|
|
originalDurationMs: mob.activeCast.completesAt - mob.activeCast.startedAt,
|
|
pushbackHits: 0,
|
|
} : null,
|
|
controlledUntil: Math.max(0, ...mob.statuses.map((status) => status.endsAt)),
|
|
schoolLockedUntil: mob.schoolLockedUntil,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
alive: !mob.dead,
|
|
};
|
|
}
|
|
for (const summon of state.summons) {
|
|
actors[summon.id] = combatActorFromSummon(summon, state.level, state.auras);
|
|
}
|
|
return Object.freeze(actors);
|
|
}
|
|
|
|
function initialState() {
|
|
const classId: ClassId = "priest";
|
|
const raceId: RaceId = "human";
|
|
const level = 1;
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks: TalentRanks = {};
|
|
const talentModifiers = characterStatModifiers(classId, level, talentRanks);
|
|
const maxHealth = talentAdjustedMaxHealth(classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
level,
|
|
talentModifiers,
|
|
EMPTY_ITEM_STATS,
|
|
raceId,
|
|
);
|
|
const abilities = abilitiesForClass(classId);
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
|
return {
|
|
classId,
|
|
specializationId: null,
|
|
gearRole: "healer" as PartyRole,
|
|
secondaryClassId: null,
|
|
raceId,
|
|
level,
|
|
xp: 0,
|
|
xpToNext: xpRequiredForNextLevel(level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
lastResourceSpentAt: {},
|
|
resourceName: resource.name,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
deathKnightRunes: [],
|
|
abilities,
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
playerAnimationEvent: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs: {},
|
|
selectedTargetId: null,
|
|
selectedFriendlyActorId: null,
|
|
effects: [],
|
|
auras: [],
|
|
summons: [],
|
|
areaEffects: [],
|
|
pendingGroundTarget: null,
|
|
actors: {},
|
|
combatEvents: [],
|
|
feedback: null,
|
|
lastCombatResult: null,
|
|
bossLootRewards: [] as readonly BossLootReward[],
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
|
|
inventory: [] as readonly InventoryItem[],
|
|
bossCards: emptyBossCardCollection(),
|
|
equipment: {} as EquipmentAssignments,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
settings: DEFAULT_GAMEPLAY_SETTINGS,
|
|
playerPosition: [0, 0, 0] as CombatPosition,
|
|
};
|
|
}
|
|
|
|
export const useCombatStore = create<CombatState>((set, get) => ({
|
|
...initialState(),
|
|
|
|
initializeCharacter: (profile) => {
|
|
resetCombatPresentationSession();
|
|
const classId = profile.classId;
|
|
const specialization = specializationById(profile.specializationId);
|
|
const specializationId = specialization?.classId === classId ? specialization.id : null;
|
|
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
|
|
const secondaryClassId = profile.secondaryClassId ?? null;
|
|
const raceId = profile.raceId ?? "human";
|
|
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
|
|
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
|
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
|
const inventory = normalizeInventory(profile.inventory);
|
|
const equipment = normalizeEquipment(profile.equipment, inventory);
|
|
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId,
|
|
secondaryClassId,
|
|
specializationId,
|
|
role: gearRole,
|
|
level: progression.level,
|
|
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
// RoM secondary pools use native resource limits, not the mapped WoW class's mana.
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource,
|
|
isRomClassId(classId) ? defaultResourceMaximum("mana")
|
|
: deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana);
|
|
set((state) => ({
|
|
...initialState(),
|
|
classId,
|
|
specializationId,
|
|
gearRole,
|
|
secondaryClassId,
|
|
raceId,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: xpRequiredForNextLevel(progression.level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
resourceName: resource.name,
|
|
abilities,
|
|
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
|
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: withoutUnlearnedTalentBindings(
|
|
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
|
abilities,
|
|
talentRanks,
|
|
),
|
|
inventory,
|
|
bossCards: normalizeBossCardCollection(profile.bossCards),
|
|
equipment,
|
|
gearStats,
|
|
settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}),
|
|
playerPosition: state.playerPosition,
|
|
}));
|
|
},
|
|
|
|
refreshResolvedGear: () => set((state) => {
|
|
const context: HeirloomContext = {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
};
|
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
|
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
|
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
state.auras,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
|
|
return {
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
|
|
};
|
|
}),
|
|
|
|
registerMob: (id, registration) => set((state) => {
|
|
if (!id) return state;
|
|
const existing = state.mobs[id];
|
|
const boss = registration.boss ?? existing?.boss ?? false;
|
|
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
|
|
const creatureFamily = registration.creatureFamily
|
|
?? (registration.serverEntry === undefined && existing?.creatureFamily
|
|
? existing.creatureFamily : romCreatureFamilyForServerEntry(serverEntry));
|
|
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
|
|
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
|
|
const resistances: MobMagicResistances = {
|
|
arcane: Math.max(0, registration.resistances?.arcane ?? existing?.resistances.arcane ?? 0),
|
|
fire: Math.max(0, registration.resistances?.fire ?? existing?.resistances.fire ?? 0),
|
|
frost: Math.max(0, registration.resistances?.frost ?? existing?.resistances.frost ?? 0),
|
|
nature: Math.max(0, registration.resistances?.nature ?? existing?.resistances.nature ?? 0),
|
|
shadow: Math.max(0, registration.resistances?.shadow ?? existing?.resistances.shadow ?? 0),
|
|
};
|
|
const defaults = defaultMobStats(level, boss, serverEntry ?? undefined);
|
|
const mechanicImmunities = registration.mechanicImmunities
|
|
?? existing?.mechanicImmunities
|
|
?? defaults.mechanicImmunities;
|
|
const schoolImmunities = registration.schoolImmunities
|
|
?? existing?.schoolImmunities
|
|
?? defaults.schoolImmunities;
|
|
const healthMultiplier = Math.max(0.01, registration.healthMultiplier ?? existing?.healthMultiplier ?? 1);
|
|
const damageMultiplier = Math.max(0.01, registration.damageMultiplier ?? existing?.damageMultiplier ?? 1);
|
|
const bonusLootChance = clamp(
|
|
registration.bonusLootChance ?? existing?.bonusLootChance ?? 0,
|
|
0,
|
|
1,
|
|
);
|
|
const maxHealth = Math.max(1, Math.round(
|
|
registration.maxHealth
|
|
?? existing?.maxHealth
|
|
?? defaults.maxHealth * healthMultiplier,
|
|
));
|
|
const xpReward = Math.max(0, Math.round(registration.xpReward ?? existing?.xpReward ?? defaults.xpReward));
|
|
const hasLoot = registration.hasLoot ?? existing?.hasLoot ?? false;
|
|
const fallbackAttack = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
range: boss ? 5.2 : 4.3,
|
|
cooldownMs: defaults.swingMs,
|
|
};
|
|
const attacks = registration.attacks
|
|
? normalizeEnemyAttacks(registration.attacks, fallbackAttack)
|
|
: existing?.attacks ?? normalizeEnemyAttacks(undefined, fallbackAttack);
|
|
const attackRange = maximumEnemyAttackRange(attacks);
|
|
const swingMs = defaults.swingMs;
|
|
const leashRange = Math.max(
|
|
attackRange + 1,
|
|
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
|
|
);
|
|
const aggroRange = Math.max(
|
|
0,
|
|
Math.min(
|
|
leashRange,
|
|
registration.aggroRange
|
|
?? existing?.aggroRange
|
|
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
|
|
),
|
|
);
|
|
const moveSpeed = Math.max(
|
|
0,
|
|
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
|
);
|
|
const attackDamage = existing && level === existing.level && registration.damageMultiplier !== undefined
|
|
? Math.max(1, Math.round(
|
|
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
|
))
|
|
: (existing && level === existing.level ? existing.attackDamage : undefined) ?? Math.max(1, Math.round(
|
|
defaults.attackDamage * damageMultiplier,
|
|
));
|
|
const mob: MobCombatState = existing
|
|
? {
|
|
...existing,
|
|
name: registration.name || existing.name,
|
|
serverEntry,
|
|
creatureFamily,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
attacks,
|
|
attackRange,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackDamage,
|
|
health: existing.dead || existing.health <= 0
|
|
? 0
|
|
: clamp(Math.round(existing.health / Math.max(1, existing.maxHealth) * maxHealth), 1, maxHealth),
|
|
xpReward,
|
|
hasLoot,
|
|
}
|
|
: {
|
|
id,
|
|
name: registration.name || "Unknown Creature",
|
|
serverEntry,
|
|
creatureFamily,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
health: maxHealth,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
dead: false,
|
|
hasLoot,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle",
|
|
homePosition: getMobPosition(id),
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange,
|
|
attackDamage,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks,
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
return { mobs: { ...state.mobs, [id]: mob } };
|
|
}),
|
|
|
|
removeMob: (id) => set((state) => {
|
|
if (!state.mobs[id]) return state;
|
|
const mobs = { ...state.mobs };
|
|
delete mobs[id];
|
|
return {
|
|
mobs,
|
|
selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
effects: state.effects.filter((effect) => effect.targetId !== id),
|
|
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
|
};
|
|
}),
|
|
|
|
lootMob: (id, now = combatNow()) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
|
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
|
const lootMessage = mob.pendingLoot.length
|
|
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
|
: `Looted ${mob.name}.`;
|
|
set((state) => {
|
|
const bossReward = mob.boss
|
|
? {
|
|
id: ++bossLootRewardSequence,
|
|
bossId: id,
|
|
bossName: mob.name,
|
|
items: mob.pendingLoot,
|
|
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
|
|
acquiredAt: now,
|
|
} satisfies BossLootReward
|
|
: null;
|
|
return {
|
|
inventory: [...state.inventory, ...mob.pendingLoot],
|
|
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
|
|
mobs: {
|
|
...state.mobs,
|
|
[id]: {
|
|
...state.mobs[id],
|
|
looted: true,
|
|
corpseDespawnAt,
|
|
despawned: now >= corpseDespawnAt,
|
|
},
|
|
},
|
|
...(bossReward
|
|
? { bossLootRewards: [...state.bossLootRewards, bossReward] }
|
|
: { feedback: nextFeedback("loot", lootMessage, { targetId: id }) }),
|
|
};
|
|
});
|
|
return true;
|
|
},
|
|
|
|
dismissBossLootReward: (id) => set((state) => ({
|
|
bossLootRewards: id === undefined
|
|
? state.bossLootRewards.slice(1)
|
|
: state.bossLootRewards.filter((reward) => reward.id !== id),
|
|
})),
|
|
|
|
collectSharedBossCards: (drop, bossName, now) => set((state) => {
|
|
const bossCards = collectBossCardDrop(state.bossCards, drop);
|
|
if (bossCards === state.bossCards) return state;
|
|
return {
|
|
bossCards,
|
|
bossLootRewards: [...state.bossLootRewards, {
|
|
id: ++bossLootRewardSequence, bossId: drop.spawnId, bossName, items: [], cards: drop, acquiredAt: now,
|
|
}],
|
|
};
|
|
}),
|
|
|
|
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
|
|
const state = get();
|
|
const ownerMember = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? null
|
|
: usePartyStore.getState().members.find((member) => member.id === ownerId);
|
|
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? state.classId
|
|
: ownerMember?.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,
|
|
{
|
|
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
|
|
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
|
|
},
|
|
);
|
|
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"
|
|
: ownerMember?.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,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
state.auras,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: change.assignments,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
|
|
});
|
|
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
unequipItem: (ownerId, slot) => {
|
|
const state = get();
|
|
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
|
if (next === state.equipment) return false;
|
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
state.auras,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: next,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", "Item returned to the shared inventory."),
|
|
});
|
|
usePartyStore.getState().syncEquipment(next, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
selectMob: (id) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || mob.dead) return false;
|
|
set((state) => ({
|
|
selectedTargetId: id,
|
|
autoAttackTargetId: state.autoAttackTargetId ? id : null,
|
|
nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id
|
|
? 0
|
|
: state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? state.comboPoints : 0,
|
|
comboTargetId: state.selectedTargetId === id ? state.comboTargetId : id,
|
|
feedback: nextFeedback("target", mob.name, { targetId: id }),
|
|
}));
|
|
return true;
|
|
},
|
|
|
|
selectNearestTarget: (origin, maxRange = 40) => {
|
|
const state = get();
|
|
const id = eligibleTargetIds(state, currentOrigin(state, origin), maxRange)[0] ?? null;
|
|
if (id) state.selectMob(id);
|
|
else state.clearTarget();
|
|
return id;
|
|
},
|
|
|
|
cycleTarget: (direction = "next", origin, maxRange = 40) => {
|
|
const state = get();
|
|
const ids = eligibleTargetIds(state, currentOrigin(state, origin), maxRange);
|
|
if (!ids.length) {
|
|
state.clearTarget();
|
|
return null;
|
|
}
|
|
const step = direction === "previous" || direction === -1 ? -1 : 1;
|
|
const currentIndex = state.selectedTargetId ? ids.indexOf(state.selectedTargetId) : -1;
|
|
const nextIndex = currentIndex < 0
|
|
? (step > 0 ? 0 : ids.length - 1)
|
|
: (currentIndex + step + ids.length) % ids.length;
|
|
const id = ids[nextIndex];
|
|
state.selectMob(id);
|
|
return id;
|
|
},
|
|
|
|
clearTarget: () => set((state) => ({
|
|
selectedTargetId: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
feedback: state.selectedTargetId
|
|
? nextFeedback("target", "Target cleared.")
|
|
: state.feedback,
|
|
})),
|
|
|
|
selectFriendlyActor: (id) => {
|
|
if (id === null) {
|
|
set({ selectedFriendlyActorId: null });
|
|
return true;
|
|
}
|
|
const state = get();
|
|
const actor = state.actors[id];
|
|
if (!actor || !["player", "companion", "pet", "totem", "summon"].includes(actor.kind)) return false;
|
|
set({
|
|
selectedFriendlyActorId: id,
|
|
feedback: nextFeedback("target", `Friendly target selected: ${state.summons.find((summon) => summon.id === id)?.name ?? id}.`, { targetId: id }),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
setPlayerPosition: (position) => set((state) => {
|
|
const next: CombatPosition = [position[0], position[1], position[2]];
|
|
const cast = state.activeCast;
|
|
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
|
|
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
|
));
|
|
const positionUnchanged = (
|
|
(next[0] - state.playerPosition[0]) ** 2
|
|
+ (next[1] - state.playerPosition[1]) ** 2
|
|
+ (next[2] - state.playerPosition[2]) ** 2
|
|
) <= 1e-8;
|
|
if (positionUnchanged && !movedWhileCasting) return state;
|
|
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
|
|
return {
|
|
playerPosition: next,
|
|
activeCast: movedWhileCasting ? null : cast,
|
|
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "interrupt",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: cast!.abilityId,
|
|
school: cast!.school,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "movement",
|
|
}) : state.combatEvents,
|
|
playerAnimationEvent: movedWhileCasting
|
|
? nextAnimationEvent("ability-cancel", cast!.abilityId)
|
|
: state.playerAnimationEvent,
|
|
feedback: movedWhileCasting
|
|
? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId })
|
|
: state.feedback,
|
|
};
|
|
}),
|
|
|
|
beginGroundTarget: (abilityId) => {
|
|
const ability = abilityById(abilityId);
|
|
if (!ability || targetingRulesForAbility(ability).shape !== "ground") return false;
|
|
const state = get();
|
|
const targeting = targetingRulesForAbility(ability);
|
|
const maximumRange = targeting.groundRange ?? ability.range.max;
|
|
const player = combatSpatialActor(PLAYER_AURA_ENTITY_ID);
|
|
const yaw = player?.yaw ?? 0;
|
|
const distance = Math.min(10, Math.max(0, maximumRange));
|
|
const requested: CombatPosition = [
|
|
state.playerPosition[0] + Math.sin(yaw) * distance,
|
|
state.playerPosition[1],
|
|
state.playerPosition[2] + Math.cos(yaw) * distance,
|
|
];
|
|
const projected = projectCombatGroundPoint(requested, maximumRange, state.playerPosition);
|
|
set({
|
|
pendingGroundTarget: {
|
|
abilityId,
|
|
maximumRange,
|
|
radius: targeting.radius ?? ability.radius ?? 4,
|
|
position: projected ?? requested,
|
|
valid: projected !== null && (!targeting.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)),
|
|
},
|
|
feedback: nextFeedback("target", `Choose a location for ${ability.name}.`, { abilityId }),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
moveGroundTarget: (moveX, moveForward, yaw, deltaSeconds) => set((state) => {
|
|
const pending = state.pendingGroundTarget;
|
|
if (!pending) return state;
|
|
const magnitude = Math.hypot(moveX, moveForward);
|
|
if (magnitude < 0.05 || !Number.isFinite(deltaSeconds)) return state;
|
|
const normalizedX = moveX / Math.max(1, magnitude);
|
|
const normalizedForward = moveForward / Math.max(1, magnitude);
|
|
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
|
|
const worldX = -normalizedX * Math.cos(safeYaw) + normalizedForward * Math.sin(safeYaw);
|
|
const worldZ = normalizedX * Math.sin(safeYaw) + normalizedForward * Math.cos(safeYaw);
|
|
const speed = 10;
|
|
const requested: CombatPosition = [
|
|
pending.position[0] + worldX * speed * clamp(deltaSeconds, 0, 0.1),
|
|
pending.position[1],
|
|
pending.position[2] + worldZ * speed * clamp(deltaSeconds, 0, 0.1),
|
|
];
|
|
const projected = projectCombatGroundPoint(requested, pending.maximumRange, state.playerPosition);
|
|
const ability = abilityById(pending.abilityId);
|
|
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
|
const valid = Boolean(projected
|
|
&& Math.sqrt(distanceSquared(state.playerPosition, projected)) <= pending.maximumRange
|
|
&& (!targeting?.requiresLineOfSight || combatHasLineOfSight(state.playerPosition, projected)));
|
|
return {
|
|
pendingGroundTarget: {
|
|
...pending,
|
|
position: projected ?? pending.position,
|
|
valid,
|
|
},
|
|
};
|
|
}),
|
|
|
|
confirmGroundTarget: (position, now = combatNow()) => {
|
|
const pending = get().pendingGroundTarget;
|
|
if (!pending) return emptyCastResult("invalid-destination");
|
|
const destination = position ?? pending.position;
|
|
if (!pending.valid && position === undefined) return emptyCastResult("invalid-destination", pending.abilityId);
|
|
set({ pendingGroundTarget: null });
|
|
return get().castAbility(pending.abilityId, { origin: get().playerPosition, groundPosition: destination, now });
|
|
},
|
|
|
|
cancelGroundTarget: () => set({ pendingGroundTarget: null }),
|
|
|
|
synchronizeActors: () => {
|
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
|
set((state) => {
|
|
actors = combatActorSnapshots(state);
|
|
return { actors };
|
|
});
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
|
|
}));
|
|
},
|
|
applyDefaultPartyBuffs: (now = combatNow()) => {
|
|
get().synchronizeActors();
|
|
const partyMembers = usePartyStore.getState().members;
|
|
set((state) => {
|
|
const auras = state.auras.filter((aura) => !aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"));
|
|
if (auras.length === state.auras.length) return state;
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
|
|
state.gearStats, state.raceId, state.secondaryClassId, auras);
|
|
const health = state.health <= 0 ? 0 : clamp(state.health + maxHealth - state.maxHealth, 1, maxHealth);
|
|
const actors = { ...state.actors };
|
|
if (actors[PLAYER_AURA_ENTITY_ID]) actors[PLAYER_AURA_ENTITY_ID] = {
|
|
...actors[PLAYER_AURA_ENTITY_ID], health, maxHealth,
|
|
};
|
|
for (const member of partyMembers) {
|
|
const actor = actors[member.id];
|
|
if (!actor) continue;
|
|
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
|
const delta = auraMaximumHealthBonus(base, auras, member.id)
|
|
- auraMaximumHealthBonus(base, state.auras, member.id);
|
|
const memberMaxHealth = Math.max(1, actor.maxHealth + delta);
|
|
actors[member.id] = {
|
|
...actor,
|
|
maxHealth: memberMaxHealth,
|
|
health: actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, memberMaxHealth),
|
|
};
|
|
}
|
|
return { auras, health, maxHealth, actors };
|
|
});
|
|
get().synchronizeActors();
|
|
const state = get();
|
|
const rules = usePartyBuffPolicyStore.getState().config.rules;
|
|
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
|
|
const sources = [
|
|
...(includePlayer ? [{
|
|
id: PLAYER_AURA_ENTITY_ID,
|
|
classId: state.classId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
abilities: state.abilities,
|
|
}] : []),
|
|
...partyMembers.filter((member) => member.health > 0).map((member) => ({
|
|
id: member.id,
|
|
classId: member.classId,
|
|
role: member.role,
|
|
level: member.level,
|
|
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
|
|
})),
|
|
];
|
|
const selected = new Map<string, {
|
|
sourceId: string;
|
|
ability: AbilityDefinition;
|
|
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
|
|
potency: number;
|
|
scope: "party" | "tank" | "healer";
|
|
}>();
|
|
for (const rule of rules) {
|
|
for (const source of sources) {
|
|
const catalogAbility = source.abilities.find((ability) => ability.id === rule.abilityId);
|
|
if (!catalogAbility || !isAbilityUnlocked(catalogAbility, source.level)) continue;
|
|
const ability = abilityAtLevel(catalogAbility, source.level);
|
|
const effects = permanentBuffEffects(ability);
|
|
if (!effects.length) continue;
|
|
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
|
|
const potency = defaultPartyBuffPotency(effects);
|
|
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
|
|
selected.set(family, { sourceId: source.id, ability, effects, potency, scope: rule.scope });
|
|
}
|
|
}
|
|
for (const [family, buff] of selected) {
|
|
const recipients = sources.filter((source) => buff.scope === "party" || source.role === buff.scope)
|
|
.map((source) => source.id);
|
|
for (const recipientId of recipients) {
|
|
for (const effect of buff.effects) {
|
|
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
durationMs: null,
|
|
uniqueBySource: false,
|
|
exclusiveGroup: `gm-party-buff:${family}`,
|
|
},
|
|
}, now);
|
|
}
|
|
}
|
|
}
|
|
get().synchronizeActors();
|
|
},
|
|
applyPrecombatPreparation: (now = combatNow()) => {
|
|
get().synchronizeActors();
|
|
const state = get();
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
|
|
const sources = [
|
|
...(includePlayer ? [{
|
|
id: PLAYER_AURA_ENTITY_ID,
|
|
classId: state.classId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
specialization: specializationById(state.specializationId)?.displayName ?? null,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
talentRanks: state.talentRanks,
|
|
abilities: state.abilities,
|
|
}] : []),
|
|
...partyMembers.filter((member) => member.health > 0).map((member) => ({
|
|
id: member.id,
|
|
classId: member.classId,
|
|
secondaryClassId: member.secondaryClassId ?? null,
|
|
specializationId: member.specializationId,
|
|
specialization: member.specialization,
|
|
role: member.role,
|
|
level: member.level,
|
|
talentRanks: member.talentRanks,
|
|
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
|
|
})),
|
|
];
|
|
for (const source of sources) {
|
|
const rotation = rotationProfileFor(source.classId, source.specializationId, source.specialization);
|
|
const priority = (ability: AbilityDefinition) => {
|
|
const index = rotation?.abilityPriority.indexOf(ability.name) ?? -1;
|
|
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
|
|
};
|
|
const abilities = source.abilities
|
|
.map((ability) => learnedAbilityForBuild(ability, source.level, source.talentRanks))
|
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
|
.filter(abilityIsPrecombatPreparation)
|
|
.filter((ability) => !rotation || priority(ability) < Number.MAX_SAFE_INTEGER)
|
|
.sort((left, right) => priority(left) - priority(right) || left.name.localeCompare(right.name));
|
|
for (const ability of abilities) {
|
|
const effects = precombatPreparationEffects(ability);
|
|
const auraEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
|
|
effect.kind === "apply-aura"
|
|
));
|
|
const current = get();
|
|
const maintained = current.auras.some((aura) => (
|
|
aura.targetId === source.id
|
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
|
&& auraEffects.some((effect) => effect.aura.id === aura.definition.id)
|
|
));
|
|
const blockedByExclusiveGroup = current.auras.some((aura) => (
|
|
aura.targetId === source.id
|
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
|
&& Boolean(aura.definition.exclusiveGroup)
|
|
&& auraEffects.some((effect) => effect.aura.exclusiveGroup === aura.definition.exclusiveGroup)
|
|
));
|
|
if (!maintained && !blockedByExclusiveGroup) {
|
|
for (const effect of auraEffects) get().applyActorAura(source.id, source.id, ability, effect, now);
|
|
}
|
|
const petEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "pet" }> => (
|
|
effect.kind === "pet"
|
|
));
|
|
if (!petEffects.length) continue;
|
|
const hasActivePet = get().summons.some((summon) => {
|
|
if (summon.ownerActorId !== source.id || summon.expiresAt <= now) return false;
|
|
const summonAbility = abilityById(summon.sourceAbilityId);
|
|
return summonAbility?.effects.some((effect) => effect.kind === "pet"
|
|
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
|
|
});
|
|
if (hasActivePet) continue;
|
|
const position = actorPosition(get(), source.id) ?? get().playerPosition;
|
|
const petEffect = petEffects[0];
|
|
get().summonActor(source.id, ability, {
|
|
kind: "summon",
|
|
creatureId: petEffect.creatureId ?? 1,
|
|
coefficient: 0.35,
|
|
durationMs: Number.MAX_SAFE_INTEGER - now,
|
|
}, position, null, now);
|
|
}
|
|
}
|
|
get().synchronizeActors();
|
|
},
|
|
commitCompanionActors: (members) => {
|
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
|
set((state) => {
|
|
const next = { ...state.actors };
|
|
for (const member of members) {
|
|
const existing = next[member.id] ?? companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, member.id),
|
|
);
|
|
next[member.id] = companionActorWithMemberRuntime(existing, member);
|
|
}
|
|
actors = Object.freeze(next);
|
|
return { actors };
|
|
});
|
|
usePartyStore.setState((party) => ({
|
|
members: party.members.map((member) => {
|
|
const runtime = members.find((candidate) => candidate.id === member.id) ?? member;
|
|
return memberFromCompanionActor(runtime, actors[member.id]);
|
|
}),
|
|
}));
|
|
},
|
|
|
|
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
|
|
const request: CastRequest = Array.isArray(requestOrOrigin)
|
|
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
|
|
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
|
|
const origin = request.origin;
|
|
const now = request.now ?? legacyNow;
|
|
const completingCast = request.completingCast ?? legacyCompletingCast;
|
|
const state = get();
|
|
const party = usePartyStore.getState();
|
|
const catalogAbility = abilityById(abilityId);
|
|
const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
|
|
const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
|
|
let ability = catalogAbility
|
|
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
|
: null;
|
|
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
|
if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) =>
|
|
aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) {
|
|
const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage"
|
|
? { ...cost, amount: Math.max(0, cost.amount - 20) } : cost);
|
|
ability = {
|
|
...ability,
|
|
cooldownMs: Math.max(0, ability.cooldownMs - 20_000),
|
|
cost: adjustedCosts[0] ?? ability.cost,
|
|
costs: adjustedCosts,
|
|
};
|
|
}
|
|
const innerFocusAura = activePlayerAuras.find((aura) => (
|
|
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
|
));
|
|
const consumesInnerFocus = Boolean(
|
|
innerFocusAura
|
|
&& ability?.name !== "Inner Focus"
|
|
&& ability?.effects.some((effect) => effect.kind === "heal"),
|
|
);
|
|
const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag));
|
|
const holyAuraFriendly = Boolean(
|
|
ability?.source === "runewaker"
|
|
&& ability.name === "Holy Aura"
|
|
&& party.selectedMemberId
|
|
&& hasRuneWakerState("rom-holy-salvation-candle")
|
|
);
|
|
const effectiveTarget = holyAuraFriendly
|
|
? "friendly"
|
|
: ability?.target === "any"
|
|
? completingCast
|
|
? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly"
|
|
: state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile"
|
|
: ability?.target;
|
|
const modifiers: readonly TalentModifier[] = consumesInnerFocus ? [
|
|
...state.talentModifiers,
|
|
{
|
|
kind: "critical-chance-percent",
|
|
amount: 25,
|
|
unit: "percent",
|
|
scope: "your healing spells",
|
|
sourceTalentId: "talent-348:active",
|
|
sourceTalentName: "Inner Focus",
|
|
rank: 1,
|
|
description: "The next direct or area heal costs no resource and gains 25 percentage points of critical chance.",
|
|
},
|
|
] : state.talentModifiers;
|
|
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
|
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
|
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
|
const baseTargeting = ability ? targetingRulesForAbility(ability) : null;
|
|
const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot")
|
|
? { ...baseTargeting, maximumTargets: 5 }
|
|
: baseTargeting;
|
|
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
|
const runeCost = ability && baseClassFor(state.classId) === "death-knight"
|
|
? runeCostForAbility(ability)
|
|
: null;
|
|
let failure: CastFailureReason | undefined;
|
|
if (!ability) failure = "unknown-ability";
|
|
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
|
|
else if (talentNodeId && !learnedTalentRank) failure = "not-learned";
|
|
else if (ability.passive) failure = "passive";
|
|
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
|
|
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
|
|
else if (state.health <= 0) failure = "player-dead";
|
|
else if (
|
|
state.controlledUntil > now
|
|
&& enemyControlPreventsActions(state.controlMechanic)
|
|
) failure = "player-controlled";
|
|
else if (
|
|
effectiveTarget === "hostile"
|
|
&& abilityAttackKind(state.classId, ability) === "melee"
|
|
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
|
) failure = "player-controlled";
|
|
else if (state.activeCast && !completingCast) failure = "already-casting";
|
|
else if (!completingCast && ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
|
else if (!completingCast && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
|
|
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
|
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
|
|
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
|
else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
|
|
else if (abilityRequiresOutOfCombat(ability)
|
|
&& !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish"))
|
|
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
|
else if (ability.name === "Enraged Regeneration"
|
|
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
|
|
else if (costs.some((cost) => cost.resource === "health"
|
|
? state.health <= cost.amount
|
|
: (cost.resource === resourceProfileForClass(state.classId).type
|
|
? state.resource
|
|
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
|
|
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
|
|
|
|
const playerOrigin = currentOrigin(state, origin);
|
|
// `null` is a meaningful snapshotted target for friendly casts: it means
|
|
// the player. Do not fall through to a newly selected ally on completion.
|
|
let targetId = completingCast
|
|
? completingCast.targetId
|
|
: request.targetActorId !== undefined
|
|
? request.targetActorId
|
|
: effectiveTarget === "hostile"
|
|
? state.selectedTargetId
|
|
: effectiveTarget === "friendly"
|
|
? state.selectedFriendlyActorId ?? party.selectedMemberId
|
|
: null;
|
|
let targetPosition: CombatPosition | null = null;
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.shape !== "ground") {
|
|
let target = targetId ? state.mobs[targetId] : null;
|
|
if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) {
|
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
|
const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers);
|
|
const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin));
|
|
return distance >= minimumRange;
|
|
});
|
|
targetId = eligible ?? null;
|
|
target = targetId ? state.mobs[targetId] : null;
|
|
}
|
|
if (!targetId || !target) failure = "no-target";
|
|
else if (target.dead) failure = "target-dead";
|
|
else {
|
|
targetPosition = getMobPosition(targetId);
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
} else if (!failure && ability && effectiveTarget === "friendly" && targetId) {
|
|
const partyTarget = party.members.find((member) => member.id === targetId);
|
|
const target = state.actors[targetId]
|
|
?? (partyTarget ? companionActorFromMember(partyTarget, entityAbsorbAmount(state.auras, targetId)) : null);
|
|
if (!target || !["companion", "pet", "totem", "summon"].includes(target.kind)) failure = "target-unavailable";
|
|
else if (!target.alive && !ability.effects.some((effect) => effect.kind === "resurrection")) {
|
|
failure = "target-dead";
|
|
}
|
|
else {
|
|
targetPosition = getPartyRuntimePosition(targetId)
|
|
?? state.summons.find((summon) => summon.id === targetId)?.position
|
|
?? null;
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(distanceSquared(playerOrigin, targetPosition));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!failure && ability && targeting?.requiresLineOfSight && targetPosition) {
|
|
if (!combatHasLineOfSight(playerOrigin, targetPosition)) failure = "line-of-sight";
|
|
}
|
|
if (!failure && ability?.name === "Kill Shot" && targetId) {
|
|
const target = state.mobs[targetId];
|
|
if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met";
|
|
}
|
|
if (!failure && ability?.name === "Power Word: Shield"
|
|
&& hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met";
|
|
if (!failure && ability?.source === "runewaker") {
|
|
const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined;
|
|
if (ability.dbcSpellId === 491613 && family === "living"
|
|
&& !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met";
|
|
if (ability.dbcSpellId === 490347 && family === "undead") failure = "condition-not-met";
|
|
if (ability.dbcSpellId === 492920 && !state.effects.some((effect) =>
|
|
effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0
|
|
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) failure = "condition-not-met";
|
|
if (ability.dbcSpellId === 490151
|
|
&& (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met";
|
|
if (ability.dbcSpellId === 490347 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met";
|
|
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
|
|
const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
});
|
|
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met";
|
|
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met";
|
|
}
|
|
}
|
|
const playerSpatial = combatSpatialActor(PLAYER_AGGRO_ID);
|
|
const targetSpatial = targetId ? combatSpatialActor(targetId) : null;
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresFacing && playerSpatial && targetPosition) {
|
|
if (!isPositionInFacingArc(playerSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) {
|
|
failure = "not-facing";
|
|
}
|
|
}
|
|
if (!failure && ability && effectiveTarget === "hostile" && targeting?.requiresBehindTarget && targetSpatial) {
|
|
if (!isSourceBehindTarget(playerOrigin, targetSpatial)) failure = "must-be-behind";
|
|
}
|
|
let groundPosition: CombatPosition | null = completingCast?.groundPosition ?? null;
|
|
if (!failure && ability && targeting?.shape === "ground") {
|
|
const requestedGround = request.groundPosition ?? targetPosition ?? playerOrigin;
|
|
const maximumGroundRange = talentAdjustedRange(targeting.groundRange ?? ability.range.max, ability, modifiers);
|
|
const projected = projectCombatGroundPoint(requestedGround, maximumGroundRange, playerOrigin);
|
|
if (!projected || Math.sqrt(distanceSquared(playerOrigin, projected)) > maximumGroundRange) failure = "invalid-destination";
|
|
else if (targeting.requiresLineOfSight && !combatHasLineOfSight(playerOrigin, projected)) failure = "line-of-sight";
|
|
else groundPosition = [projected[0], projected[1], projected[2]];
|
|
}
|
|
|
|
const autoAttackTarget = targetId ? state.mobs[targetId] : null;
|
|
const startsAutoAttack = Boolean(
|
|
ability
|
|
&& effectiveTarget === "hostile"
|
|
&& abilityAttackKind(state.classId, ability) === "melee"
|
|
&& targetId
|
|
&& autoAttackTarget
|
|
&& !autoAttackTarget.dead
|
|
&& state.abilities.some((entry) => entry.id === ability.id)
|
|
&& !ability.passive
|
|
&& isAbilityUnlocked(ability, state.level)
|
|
&& state.health > 0,
|
|
);
|
|
const autoAttackUpdate = startsAutoAttack && targetId
|
|
? {
|
|
autoAttackTargetId: targetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now,
|
|
}
|
|
: {};
|
|
|
|
if (failure || !ability) {
|
|
const reason = failure ?? "unknown-ability";
|
|
set({
|
|
feedback: nextFeedback("error", castFailureMessage(reason, state.resourceName, ability?.unlockLevel), { abilityId }),
|
|
...autoAttackUpdate,
|
|
...(completingCast
|
|
? { activeCast: null, playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
|
|
: {}),
|
|
});
|
|
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
|
|
return emptyCastResult(reason, abilityId);
|
|
}
|
|
|
|
let affectedMobIds: string[] = [];
|
|
if (targeting?.shape === "chain" && effectiveTarget === "hostile" && targetId && targetPosition) {
|
|
let previous = targetPosition;
|
|
const ordered = [targetId, ...Object.keys(state.mobs).filter((id) => id !== targetId).sort()];
|
|
for (const id of ordered) {
|
|
if (affectedMobIds.length >= (targeting.maximumTargets ?? 3)) break;
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
if (!mob || mob.dead || !position) continue;
|
|
if (affectedMobIds.length > 0 && Math.sqrt(distanceSquared(previous, position)) > (targeting.jumpRange ?? 12)) continue;
|
|
if (!combatHasLineOfSight(previous, position)) continue;
|
|
affectedMobIds.push(id);
|
|
previous = position;
|
|
}
|
|
} else if (targeting?.shape === "line" && effectiveTarget === "hostile" && targetId && targetPosition) {
|
|
const directionX = targetPosition[0] - playerOrigin[0];
|
|
const directionZ = targetPosition[2] - playerOrigin[2];
|
|
const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ));
|
|
const reach = targetDistance + 4;
|
|
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
if (!mob || mob.dead || !position || !combatHasLineOfSight(playerOrigin, position)) return false;
|
|
const offsetX = position[0] - playerOrigin[0];
|
|
const offsetZ = position[2] - playerOrigin[2];
|
|
const forward = (offsetX * directionX + offsetZ * directionZ) / targetDistance;
|
|
const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance;
|
|
return forward >= 0 && forward <= reach && sideways <= 1.25;
|
|
});
|
|
} else if (targeting?.shape === "cone" && effectiveTarget === "hostile") {
|
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
|
affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
return Boolean(mob && !mob.dead && position
|
|
&& combatHasLineOfSight(playerOrigin, position)
|
|
&& (!playerSpatial || isPositionInFacingArc(playerSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
|
});
|
|
} else if (ability.radius !== undefined || targeting?.shape === "ground") {
|
|
const center = groundPosition ?? (effectiveTarget === "hostile" ? targetPosition! : playerOrigin);
|
|
affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(targeting?.radius ?? ability.radius ?? 0, ability, modifiers)).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
const position = getMobPosition(id);
|
|
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(center, position));
|
|
});
|
|
} else if (effectiveTarget === "hostile" && targetId) {
|
|
affectedMobIds = [targetId];
|
|
}
|
|
if (targeting?.maximumTargets && affectedMobIds.length > targeting.maximumTargets) {
|
|
affectedMobIds = affectedMobIds.slice(0, targeting.maximumTargets);
|
|
}
|
|
|
|
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
|
|
&& ability.castMode !== "channel" && ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
|
|
const ordinaryCastTimeMs = talentAdjustedCastTime(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs;
|
|
const gcdMs = talentAdjustedGcd(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
const spentPools: Partial<Record<ResourceType, number>> = {
|
|
...state.resourcePools,
|
|
[activeResourceType]: state.resource,
|
|
};
|
|
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
|
|
let healthAfterCosts = state.health;
|
|
for (const cost of costsToSpend) {
|
|
if (cost.resource === "health") healthAfterCosts = Math.max(1, healthAfterCosts - cost.amount);
|
|
else {
|
|
spentPools[cost.resource] = Math.max(0, (spentPools[cost.resource] ?? 0) - cost.amount);
|
|
if (cost.amount > 0) lastResourceSpentAt[cost.resource] = now;
|
|
}
|
|
}
|
|
const spentRunes = runeCost && completingCast?.mode !== "channel"
|
|
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
|
|
: state.deathKnightRunes;
|
|
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
|
if (!completingCast && castTimeMs > 0) {
|
|
castSequence += 1;
|
|
const activeCast: ActiveCast = {
|
|
id: castSequence,
|
|
abilityId: ability.id,
|
|
targetId,
|
|
mode: ability.castMode === "channel" ? "channel" : "cast",
|
|
startedAt: now,
|
|
completesAt: now + castTimeMs,
|
|
originalDurationMs: castTimeMs,
|
|
pushbackHits: 0,
|
|
school: spellSchool,
|
|
canCastWhileMoving: canAbilityCastWhileMoving(ability)
|
|
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
|
|
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
|
groundPosition,
|
|
totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0,
|
|
ticksCompleted: 0,
|
|
nextTickAt: ability.castMode === "channel" ? channelPulseAt(ability, now, castTimeMs, 0) : 0,
|
|
};
|
|
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
|
|
if (activeCast.mode === "channel" && cooldownMs > 0) {
|
|
channelCooldowns[ability.id] = now + cooldownMs;
|
|
}
|
|
set({
|
|
activeCast,
|
|
...(lastPrayerReady ? { auras: state.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("rom-last-prayer")
|
|
)) } : {}),
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
|
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
|
resource: activeCast.mode === "channel" ? activeResourceAfterCosts : state.resource,
|
|
resourcePools: activeCast.mode === "channel" ? spentPools : state.resourcePools,
|
|
lastResourceSpentAt: activeCast.mode === "channel" ? lastResourceSpentAt : state.lastResourceSpentAt,
|
|
deathKnightRunes: activeCast.mode === "channel" && spentRunes ? spentRunes : state.deathKnightRunes,
|
|
cooldowns: channelCooldowns,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "cast",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "started",
|
|
}),
|
|
globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt,
|
|
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
|
feedback: nextFeedback(
|
|
"success",
|
|
`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${ability.name}...`,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined },
|
|
),
|
|
});
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"cast-start",
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...combatActorSnapshots(state) },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: healthAfterCosts,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: activeResourceAfterCosts,
|
|
maxResource: state.maxResource,
|
|
resourcePools: spentPools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt,
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: spentRunes ?? state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: modifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
if (consumesInnerFocus && innerFocusAura) {
|
|
work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId);
|
|
}
|
|
if (ability.name === "Enraged Regeneration") {
|
|
work.auras = work.auras.filter((aura) => !consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now));
|
|
}
|
|
const castThreatSource = playerThreatSource(state, ability.id);
|
|
|
|
if (targeting?.shape === "ground" && groundPosition && completingCast?.mode !== "channel") {
|
|
const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot");
|
|
const durationMs = periodic.reduce((maximum, effect) => (
|
|
Math.max(maximum, effect.ticks * effect.intervalMs)
|
|
), 0);
|
|
if (durationMs > 0) {
|
|
areaEffectSequence += 1;
|
|
work.areaEffects.push({
|
|
id: `area-effect-${areaEffectSequence}`,
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
sourceAbilityId: ability.id,
|
|
position: groundPosition,
|
|
radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers),
|
|
createdAt: now,
|
|
expiresAt: now + durationMs,
|
|
tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)),
|
|
nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)),
|
|
});
|
|
}
|
|
}
|
|
|
|
const hasHostileEffect = (ability.target !== "any" || effectiveTarget === "hostile") && ability.effects.some((effect) => (
|
|
effect.kind === "damage"
|
|
|| effect.kind === "finisher-damage"
|
|
|| effect.kind === "dot"
|
|
|| effect.kind === "taunt"
|
|
|| effect.kind === "status"
|
|
|| (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")
|
|
|| effect.kind === "interrupt"
|
|
|| effect.kind === "pull"
|
|
|| effect.kind === "execute"
|
|
|| effect.kind === "resource-drain"
|
|
|| effect.kind === "threat"
|
|
));
|
|
if (hasHostileEffect) {
|
|
work.auras = work.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("stealth")
|
|
));
|
|
}
|
|
if (hasHostileEffect) {
|
|
for (const id of affectedMobIds) engageMobGroupInWork(work, id, now, castThreatSource.actorId);
|
|
}
|
|
|
|
const damageSchool = abilityDamageSchool(state.classId, ability);
|
|
const attackKind = abilityAttackKind(state.classId, ability);
|
|
let playerPositionAfterEffects: CombatPosition = state.playerPosition;
|
|
let clearedPlayerControl = false;
|
|
const cooldownAdjustments: Array<Extract<AbilityEffect, { kind: "cooldown-reset" }>> = [];
|
|
const romMainHand = ability.source === "runewaker" && state.classId === "rom-rogue"
|
|
? resolvedEquipmentLoadout(state.equipment, state.inventory, {
|
|
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
|
ownerKind: "player",
|
|
classId: state.classId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
specializationId: state.specializationId,
|
|
role: state.gearRole,
|
|
level: state.level,
|
|
})["main-hand"]
|
|
: null;
|
|
const castEffects = romEffectsForCast(ability, {
|
|
sealStacks: targetId && state.comboTargetId === targetId ? state.comboPoints : 0,
|
|
bleedCount: romTargetBleedCount(state.effects, PLAYER_AGGRO_ID, targetId),
|
|
charged: hasRuneWakerState("rom-charged"),
|
|
mainHandDagger: romMainHand?.weaponType === "dagger",
|
|
casterMaxHealth: state.maxHealth,
|
|
passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)
|
|
.flatMap((aura) => aura.definition.utilityTags ?? []),
|
|
targetTags: work.auras.filter((aura) => aura.targetId === targetId)
|
|
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
|
|
romCreatureFamilyTargetTags(targetId && work.mobs[targetId]
|
|
? work.mobs[targetId].creatureFamily : "unknown"),
|
|
work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0)
|
|
.flatMap((effect) => [
|
|
...(effect.tag ? [effect.tag] : []),
|
|
...(effect.tag?.includes("501690") ? ["rom-501690"] : []),
|
|
...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []),
|
|
]),
|
|
),
|
|
});
|
|
if (ability.dbcSpellId === 492920 && targetId
|
|
&& castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) {
|
|
work.effects = work.effects.filter((effect) => !(effect.kind === "dot"
|
|
&& effect.targetId === targetId
|
|
&& effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35"));
|
|
}
|
|
const effectsToResolve = completingCast?.mode === "channel"
|
|
? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind))
|
|
: castEffects;
|
|
let clearedSingleTargetControls = false;
|
|
for (const effect of effectsToResolve) {
|
|
if (ability.name === "Penance" && effectiveTarget === "friendly"
|
|
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
|
|
if (ability.name === "Divine Hymn" && effect.kind === "apply-aura") continue;
|
|
if (
|
|
ability.target === "any"
|
|
&& effectiveTarget === "hostile"
|
|
&& ["heal", "hot", "shield", "resurrection"].includes(effect.kind)
|
|
) continue;
|
|
if (
|
|
effect.kind === "apply-aura"
|
|
&& ability.target === "any"
|
|
&& (
|
|
(effectiveTarget === "friendly" && effect.aura.disposition === "debuff")
|
|
|| (effectiveTarget === "hostile" && effect.aura.disposition === "buff" && effect.recipient !== "caster")
|
|
)
|
|
) continue;
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
) + (effect.bonusFlat ?? 0),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const [targetIndex, id] of affectedMobIds.entries()) {
|
|
const effectSchool = effect.school ?? damageSchool;
|
|
const sunderStacks = effect.bonusPerSunderStack
|
|
? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
|
|
: 0;
|
|
const result = resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0))
|
|
* Math.pow(effect.falloffPerJump ?? 1, targetIndex)),
|
|
now,
|
|
castThreatSource,
|
|
effectSchool === "physical" ? attackKind : "spell",
|
|
effectSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
|
ability,
|
|
false,
|
|
ability.name === "Shiv" ? { canCrit: false } : {},
|
|
);
|
|
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
|
|
if (ability.dbcSpellId === 490133) {
|
|
const bonus = romScoutRogueWhirlwindBonusEffect(
|
|
ability,
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).flatMap((aura) => aura.definition.utilityTags ?? []),
|
|
work.effects.filter((active) => active.targetId === id && active.remainingTicks > 0)
|
|
.flatMap((active) => active.tag ? [active.tag] : []),
|
|
);
|
|
if (bonus) {
|
|
const bonusAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(bonus, work.progression.level, work.gearStats, state.classId, ability),
|
|
modifiers, "damage-percent", ability,
|
|
);
|
|
resolveAndDamageMobInWork(
|
|
state.classId, work, id, bonusAmount, now, castThreatSource,
|
|
"melee", "physical", `${castThreatSource.actorId}:${ability.id}:${id}:${now}:poison-bonus`, ability,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else if (effect.kind === "finisher-damage") {
|
|
const comboPoints = work.comboPoints;
|
|
for (const id of affectedMobIds) {
|
|
const envenomDoses = effect.poison
|
|
? Math.min(
|
|
comboPoints,
|
|
effect.poison.maximumDoses,
|
|
matchingAuraStacks(
|
|
work.auras,
|
|
effect.poison.doseAuraTag,
|
|
castThreatSource.actorId,
|
|
id,
|
|
now,
|
|
),
|
|
)
|
|
: 0;
|
|
if (
|
|
effect.poison
|
|
&& envenomDoses > 0
|
|
&& !rogueEnvenomPreservesDeadlyPoison(
|
|
work.talentRanks,
|
|
`${castThreatSource.actorId}:${id}:${ability.id}:${now}:master-poisoner`,
|
|
)
|
|
) {
|
|
consumeRogueDeadlyPoisonInWork(
|
|
work,
|
|
castThreatSource.actorId,
|
|
id,
|
|
envenomDoses,
|
|
now,
|
|
);
|
|
}
|
|
const scaling = effect.poison
|
|
? {
|
|
coefficient: 0,
|
|
basePoints: effect.poison.basePointsPerDose * envenomDoses,
|
|
dieSides: 1,
|
|
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * envenomDoses,
|
|
}
|
|
: effect.baseCoefficient + effect.perComboCoefficient * comboPoints;
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
scaling,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:finisher`,
|
|
ability,
|
|
);
|
|
}
|
|
work.comboPoints = 0;
|
|
work.comboTargetId = null;
|
|
} else if (effect.kind === "heal") {
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const friendlySummons = work.summons.filter((summon) => summon.health > 0);
|
|
const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0);
|
|
const selectedFriendly = effectiveTarget === "friendly" && targetId
|
|
? partyMembers.find((member) => member.id === targetId)
|
|
: null;
|
|
const selectedFriendlySummon = effectiveTarget === "friendly" && targetId
|
|
? friendlySummons.find((summon) => summon.id === targetId)
|
|
: null;
|
|
const selectedOnlineFriendly = effectiveTarget === "friendly" && targetId
|
|
? onlineFriendlies.find((actor) => actor.id === targetId)
|
|
: null;
|
|
const allLivingFriendlies = [
|
|
PLAYER_AURA_ENTITY_ID,
|
|
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
|
|
...friendlySummons.map((summon) => summon.id),
|
|
...onlineFriendlies.map((actor) => actor.id),
|
|
];
|
|
const friendlyPosition = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID
|
|
? playerOrigin
|
|
: getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null;
|
|
const healingCenter = groundPosition ?? (targetId ? friendlyPosition(targetId) : null) ?? playerOrigin;
|
|
let healingTargetIds: string[];
|
|
if (targeting?.shape === "chain") {
|
|
const ordered = [...allLivingFriendlies].sort((left, right) => {
|
|
const selectedId = selectedFriendly?.id ?? selectedFriendlySummon?.id ?? PLAYER_AURA_ENTITY_ID;
|
|
if (left === selectedId) return -1;
|
|
if (right === selectedId) return 1;
|
|
const leftMember = partyMembers.find((member) => member.id === left);
|
|
const rightMember = partyMembers.find((member) => member.id === right);
|
|
const leftSummon = friendlySummons.find((summon) => summon.id === left);
|
|
const rightSummon = friendlySummons.find((summon) => summon.id === right);
|
|
const leftOnline = onlineFriendlies.find((actor) => actor.id === left);
|
|
const rightOnline = onlineFriendlies.find((actor) => actor.id === right);
|
|
const leftRatio = leftMember ? leftMember.health / leftMember.maxHealth
|
|
: leftSummon ? leftSummon.health / leftSummon.maxHealth
|
|
: leftOnline ? leftOnline.health / leftOnline.maxHealth
|
|
: work.health / work.maxHealth;
|
|
const rightRatio = rightMember ? rightMember.health / rightMember.maxHealth
|
|
: rightSummon ? rightSummon.health / rightSummon.maxHealth
|
|
: rightOnline ? rightOnline.health / rightOnline.maxHealth
|
|
: work.health / work.maxHealth;
|
|
return leftRatio - rightRatio || left.localeCompare(right);
|
|
});
|
|
healingTargetIds = [];
|
|
for (const candidate of ordered) {
|
|
if (healingTargetIds.length >= (targeting.maximumTargets ?? 3)) break;
|
|
const position = friendlyPosition(candidate);
|
|
const previousPosition = healingTargetIds.length
|
|
? friendlyPosition(healingTargetIds.at(-1)!)
|
|
: playerOrigin;
|
|
if (!position || !previousPosition) continue;
|
|
if (Math.sqrt(distanceSquared(previousPosition, position)) > (targeting.jumpRange ?? 12)) continue;
|
|
if (!combatHasLineOfSight(previousPosition, position)) continue;
|
|
healingTargetIds.push(candidate);
|
|
}
|
|
} else if ((targeting?.shape === "circle" || targeting?.shape === "ground") && effectiveTarget !== "hostile") {
|
|
const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers);
|
|
healingTargetIds = allLivingFriendlies.filter((id) => {
|
|
const position = friendlyPosition(id);
|
|
return Boolean(position
|
|
&& Math.sqrt(distanceSquared(healingCenter, position)) <= radius
|
|
&& combatHasLineOfSight(healingCenter, position));
|
|
});
|
|
} else {
|
|
healingTargetIds = [selectedFriendly?.id ?? selectedFriendlySummon?.id ?? selectedOnlineFriendly?.id ?? PLAYER_AURA_ENTITY_ID];
|
|
}
|
|
const casterStats = applyAuraStats(
|
|
playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers),
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
const criticalChance = Math.max(0, (
|
|
casterStats.spellCritPercent
|
|
+ applyTalentModifierValue(
|
|
0,
|
|
modifiers,
|
|
"critical-chance-percent",
|
|
abilityTalentContext(ability),
|
|
)
|
|
) / 100);
|
|
let directHealing = 0;
|
|
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
|
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
|
|
for (const healingTargetId of [...new Set(healingTargetIds)]) {
|
|
const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId);
|
|
const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId);
|
|
const friendlyOnline = onlineFriendlies.find((actor) => actor.id === healingTargetId);
|
|
const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? friendlySummon?.maxHealth ?? friendlyOnline?.maxHealth ?? work.maxHealth;
|
|
const healingTargetHealth = friendlyTarget?.health ?? friendlySummon?.health ?? friendlyOnline?.health ?? work.health;
|
|
const unmodifiedAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
unmodifiedAmount,
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, healingTargetId),
|
|
"taken",
|
|
));
|
|
const resolution = resolveHealing(
|
|
amount,
|
|
healingTargetHealth,
|
|
healingTargetMaxHealth,
|
|
criticalChance,
|
|
stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal`),
|
|
);
|
|
const healed = friendlyTarget
|
|
? healCompanionActorInWork(work, healingTargetId, resolution.rawAmount)
|
|
: friendlySummon
|
|
? Math.min(resolution.rawAmount, friendlySummon.maxHealth - friendlySummon.health)
|
|
: friendlyOnline
|
|
? Math.min(resolution.rawAmount, friendlyOnline.maxHealth - friendlyOnline.health)
|
|
: healPlayerInWork(work, resolution.rawAmount);
|
|
if (friendlySummon && healed > 0) {
|
|
work.summons = work.summons.map((summon) => summon.id === healingTargetId
|
|
? { ...summon, health: summon.health + healed }
|
|
: summon);
|
|
const actor = work.actors[healingTargetId];
|
|
if (actor) work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
|
} else if (friendlyOnline && healed > 0) {
|
|
const actor = work.actors[healingTargetId] ?? friendlyOnline;
|
|
work.actors[healingTargetId] = { ...actor, health: actor.health + healed, alive: true };
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: healingTargetId,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: resolution.rawAmount,
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, resolution.rawAmount - healed),
|
|
absorbed: 0,
|
|
critical: resolution.critical,
|
|
});
|
|
directHealing += healed;
|
|
if (healed <= 0) continue;
|
|
evaluateProcsInWork(work, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: healingTargetId,
|
|
abilityId: ability.id,
|
|
amount: healed,
|
|
}, now);
|
|
}
|
|
work.totalHealing += directHealing;
|
|
const beacon = work.auras.find((aura) => (
|
|
aura.sourceId === PLAYER_AURA_ENTITY_ID
|
|
&& !healingTargetIds.includes(aura.targetId)
|
|
&& aura.definition.utilityTags?.includes("beacon-of-light")
|
|
));
|
|
if (beacon?.targetId === PLAYER_AURA_ENTITY_ID) {
|
|
work.totalHealing += healPlayerInWork(work, directHealing);
|
|
} else if (beacon) {
|
|
work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing);
|
|
}
|
|
addHealingThreatInWork(work, castThreatSource, directHealing);
|
|
} else if (effect.kind === "resurrection") {
|
|
const restored = targetId
|
|
? reviveCompanionActorInWork(work, targetId, effect.percentMaxHealth)
|
|
: 0;
|
|
work.totalHealing += restored;
|
|
if (restored > 0 && targetId) recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: targetId,
|
|
abilityId: ability.id,
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
addHealingThreatInWork(work, castThreatSource, restored);
|
|
} else if (effect.kind === "summon") {
|
|
summonSequence += 1;
|
|
if (/totem/i.test(ability.name)) {
|
|
work.summons = work.summons.filter((summon) => summon.exclusiveGroup !== "totem");
|
|
}
|
|
work.summons.push({
|
|
id: `player-summon-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: /totem/i.test(ability.name) ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
|
position: groundPosition ?? playerOrigin,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}),
|
|
});
|
|
if (ability.target === "hostile") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:summon`,
|
|
ability,
|
|
);
|
|
}
|
|
}
|
|
} else if (effect.kind === "shield") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const recipients = ability.radius !== undefined
|
|
? [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => {
|
|
const position = getPartyRuntimePosition(member.id);
|
|
return member.health > 0 && Boolean(position
|
|
&& Math.sqrt(distanceSquared(playerOrigin, position)) <= ability.radius!
|
|
&& combatHasLineOfSight(playerOrigin, position));
|
|
}).map((member) => member.id)]
|
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
|
for (const recipientId of recipients) {
|
|
if (recipientId === PLAYER_AURA_ENTITY_ID) {
|
|
work.shield = Math.max(work.shield, amount);
|
|
work.shieldExpiresAt = now + durationMs;
|
|
} else {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: {
|
|
id: `shield:${ability.id}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs,
|
|
stackBehavior: "replace",
|
|
absorbs: [{ id: `${ability.id}:absorb`, amount }],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: recipientId,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: amount,
|
|
effectiveAmount: amount,
|
|
overheal: 0,
|
|
absorbed: amount,
|
|
critical: false,
|
|
});
|
|
}
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const friendlyResourceTarget = effectiveTarget === "friendly" && targetId
|
|
? usePartyStore.getState().members.find((member) => member.id === targetId)
|
|
: null;
|
|
if (friendlyResourceTarget) {
|
|
const maximum = friendlyResourceTarget.resourceType === resource
|
|
? friendlyResourceTarget.maxResource
|
|
: 100;
|
|
const current = friendlyResourceTarget.resourcePools[resource] ?? 0;
|
|
const requested = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
const restored = Math.max(0, Math.min(requested, maximum - current));
|
|
const actor = ensureCompanionActorInWork(work, friendlyResourceTarget.id);
|
|
if (actor) {
|
|
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, current, maximum);
|
|
const next = Object.freeze({
|
|
...actor,
|
|
resources: Object.freeze({
|
|
...actor.resources,
|
|
[resource]: Object.freeze({ ...pool, current: current + restored }),
|
|
}),
|
|
});
|
|
work.actors[friendlyResourceTarget.id] = next;
|
|
mirrorCompanionActor(next);
|
|
}
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: friendlyResourceTarget.id,
|
|
abilityId: ability.id,
|
|
rawAmount: requested,
|
|
effectiveAmount: restored,
|
|
overheal: Math.max(0, requested - restored),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
continue;
|
|
}
|
|
const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
const rawAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
const amount = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(rawAmount, targetAuras, resource, "generation"),
|
|
targetAuras,
|
|
"power",
|
|
"generation",
|
|
);
|
|
const resourceBefore = work.resourcePools[resource] ?? 0;
|
|
work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
rawAmount: amount,
|
|
effectiveAmount: (work.resourcePools[resource] ?? 0) - resourceBefore,
|
|
overheal: Math.max(0, amount - ((work.resourcePools[resource] ?? 0) - resourceBefore)),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
} else if (effect.kind === "combo") {
|
|
const comboTargetId = targetId && work.mobs[targetId] ? targetId : null;
|
|
if (ability.dbcSpellId === 490151 && comboTargetId) {
|
|
work.effects = work.effects.filter((entry) => !(entry.kind === "dot"
|
|
&& entry.tag === "rom-holy-seal" && entry.sourceActorId === PLAYER_AGGRO_ID
|
|
&& entry.targetId === comboTargetId));
|
|
}
|
|
work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0,
|
|
state.classId === "rom-knight" ? 4 : 5);
|
|
work.comboTargetId = comboTargetId;
|
|
} else if (effect.kind === "dot") {
|
|
if (targeting?.shape === "ground") continue;
|
|
const selfDrainPercent = romSelfDrainPercent(ability.dbcSpellId);
|
|
if (selfDrainPercent > 0) {
|
|
work.effects = work.effects.filter((entry) => !(entry.kind === "dot"
|
|
&& entry.sourceAbilityId === ability.id && entry.targetId === PLAYER_AURA_ENTITY_ID));
|
|
work.effects.push({
|
|
...timedEffect("dot", ability.id, PLAYER_AGGRO_ID, PLAYER_AURA_ENTITY_ID,
|
|
effect, effect.ticks, effect.intervalMs, now, "rom-self-health-drain"),
|
|
fixedAmount: Math.max(1, Math.round(work.maxHealth * selfDrainPercent / 100)),
|
|
});
|
|
continue;
|
|
}
|
|
for (const id of affectedMobIds) {
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "dot"
|
|
&& entry.targetId === id
|
|
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"dot",
|
|
ability.id,
|
|
castThreatSource.actorId,
|
|
id,
|
|
effect,
|
|
ability.name === "Rupture"
|
|
? Math.max(4, Math.min(8, 3 + work.comboPoints))
|
|
: effect.ticks,
|
|
effect.intervalMs,
|
|
now,
|
|
effect.tag,
|
|
effect.school ?? damageSchool,
|
|
work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
} else if (effect.kind === "hot") {
|
|
if (targeting?.shape === "ground") continue;
|
|
const hotTargetId = effectiveTarget === "friendly" ? targetId : null;
|
|
const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
|
|
id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id),
|
|
[hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now);
|
|
for (const recipient of recipients) {
|
|
const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient;
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "hot"
|
|
&& entry.sourceAbilityId === ability.id
|
|
&& entry.targetId === normalizedTarget
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now,
|
|
));
|
|
}
|
|
} else if (effect.kind === "taunt") {
|
|
const friendlyAttackers = effectiveTarget === "friendly" && targetId
|
|
? Object.values(work.mobs)
|
|
.filter((mob) => !mob.dead && mob.targetActorId === targetId)
|
|
.map((mob) => mob.id)
|
|
: [];
|
|
const tauntTargets = affectedMobIds.length ? affectedMobIds : friendlyAttackers;
|
|
const candidates = effect.maxTargets ? tauntTargets.slice(0, effect.maxTargets) : tauntTargets;
|
|
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
const previous = mob.tauntDiminishingReturns;
|
|
const applications = mob.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
|
const multiplier = mob.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
|
const effectiveDurationMs = Math.round(durationMs * multiplier);
|
|
const tauntDiminishingReturns = mob.boss
|
|
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
|
|
: mob.tauntDiminishingReturns;
|
|
if (effectiveDurationMs <= 0) {
|
|
work.mobs[id] = { ...mob, tauntDiminishingReturns };
|
|
continue;
|
|
}
|
|
work.mobs[id] = {
|
|
...mob,
|
|
tauntDiminishingReturns,
|
|
threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId),
|
|
targetActorId: castThreatSource.actorId,
|
|
forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + effectiveDurationMs },
|
|
};
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
|
if (romSingleTargetControl(ability) && candidates.length && !clearedSingleTargetControls) {
|
|
for (const [mobId, mob] of Object.entries(work.mobs)) {
|
|
work.mobs[mobId] = { ...mob, statuses: mob.statuses.filter((status) => !(
|
|
status.sourceAbilityId === ability.id && status.sourceActorId === PLAYER_AGGRO_ID
|
|
)) };
|
|
}
|
|
clearedSingleTargetControls = true;
|
|
}
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue;
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
...effect,
|
|
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
|
|
}, ability.id, now, PLAYER_AGGRO_ID);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const sourceDurationMs = ability.name === "Envenom"
|
|
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
|
|
? (work.comboPoints + 1) * 1_000
|
|
: ability.name === "Rupture"
|
|
? Math.max(4, Math.min(8, 3 + work.comboPoints)) * 2_000
|
|
: holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
|
|
? Math.max(1_000, effect.aura.durationMs - 2_000)
|
|
: effect.aura.durationMs;
|
|
const durationMs = sourceDurationMs === null
|
|
? null
|
|
: ability.name === "Rupture" || ability.name === "Envenom"
|
|
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
|
|
? sourceDurationMs
|
|
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
|
|
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
|
const recipients = effect.scope === "party"
|
|
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)]
|
|
: holyAuraFriendly && ability.name === "Holy Aura" && targetId
|
|
? [targetId]
|
|
: effect.recipient === "caster"
|
|
? [PLAYER_AURA_ENTITY_ID]
|
|
: effectiveTarget === "hostile"
|
|
? affectedMobIds
|
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
|
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
|
|
const previousAuras = work.auras;
|
|
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
|
|
if (previousAuras.some((aura) => aura.targetId === recipientId
|
|
&& aura.definition.exclusiveGroup === `gm-party-buff:${family}`)) continue;
|
|
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
|
const auraResult = applyCombatAura(previousAuras, {
|
|
definition: auraDefinition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
});
|
|
work.auras = [...auraResult.auras];
|
|
if (ability.name === "Power Word: Shield") {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: WEAKENED_SOUL_AURA,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
|
if (grantedAbsorb > 0) {
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: recipientId,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: grantedAbsorb,
|
|
effectiveAmount: grantedAbsorb,
|
|
overheal: 0,
|
|
absorbed: grantedAbsorb,
|
|
critical: false,
|
|
});
|
|
}
|
|
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
|
if (auraDefinition.controlImmunities?.length) {
|
|
if (
|
|
recipientId === PLAYER_AURA_ENTITY_ID
|
|
&& state.controlMechanic
|
|
&& auraDefinition.controlImmunities.includes(state.controlMechanic)
|
|
) clearedPlayerControl = true;
|
|
else if (recipientId !== PLAYER_AURA_ENTITY_ID) {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === recipientId);
|
|
if (member?.controlMechanic && auraDefinition.controlImmunities.includes(member.controlMechanic)) {
|
|
clearCompanionControlInWork(work, recipientId);
|
|
}
|
|
}
|
|
}
|
|
if (effect.control && work.mobs[recipientId]) {
|
|
const durationMs = auraDefinition.durationMs ?? 1_000;
|
|
const previousStatuses = work.mobs[recipientId].statuses;
|
|
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${auraDefinition.id}`, now);
|
|
if (work.mobs[recipientId].characterDefender) {
|
|
const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`);
|
|
if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras];
|
|
else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura);
|
|
}
|
|
}
|
|
}
|
|
} else if (effect.kind === "dispel") {
|
|
const primaryRecipientId = effectiveTarget === "hostile"
|
|
? targetId
|
|
: targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const recipientIds = effect.scope === "party"
|
|
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)]
|
|
: primaryRecipientId ? [primaryRecipientId] : [];
|
|
for (const recipientId of recipientIds) {
|
|
const relationship = effect.relationship === "target"
|
|
? (work.mobs[recipientId] ? "hostile" : "friendly")
|
|
: effect.relationship;
|
|
const targeted = work.auras.filter((aura) => aura.targetId === recipientId);
|
|
const untouched = work.auras.filter((aura) => aura.targetId !== recipientId);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship,
|
|
categories: effect.categories,
|
|
maxCount: effect.maxCount,
|
|
});
|
|
work.auras = [...untouched, ...result.auras];
|
|
if (result.removed.some((removed) => removed.definition.name.startsWith("Living Bomb"))) {
|
|
for (const timed of work.effects.filter((entry) => entry.targetId === recipientId
|
|
&& result.removed.some((removed) => removed.sourceId === entry.sourceActorId
|
|
&& abilityById(entry.sourceAbilityId)?.name === "Living Bomb"))) {
|
|
detonateLivingBombInWork(work, timed, now);
|
|
work.effects = work.effects.filter((entry) => entry.id !== timed.id);
|
|
work.auras = work.auras.filter((aura) => !(aura.targetId === recipientId
|
|
&& aura.sourceId === timed.sourceActorId && aura.definition.name.startsWith("Living Bomb")));
|
|
}
|
|
}
|
|
if (result.removed.length > 0 && work.mobs[recipientId]) {
|
|
const removedTokens = new Set(result.removed.map((removed) => `aura:${removed.definition.id}`));
|
|
work.mobs[recipientId] = {
|
|
...work.mobs[recipientId],
|
|
statuses: work.mobs[recipientId].statuses.filter((status) => !removedTokens.has(status.sourceAbilityId)),
|
|
auras: work.mobs[recipientId].auras.filter((legacy) => !result.removed.some((removed) => (
|
|
removed.definition.id === `enemy:${recipientId}:${legacy.id}`
|
|
))),
|
|
};
|
|
}
|
|
if (
|
|
recipientId === PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) clearedPlayerControl = true;
|
|
if (
|
|
recipientId !== PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) clearCompanionControlInWork(work, recipientId);
|
|
if (effect.mode === "steal") {
|
|
for (const removed of result.removed) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: { ...removed.definition, disposition: "buff" },
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
}
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "pull") {
|
|
if (ability.target === "friendly" && targetId && effect.toward === "caster") {
|
|
updatePartyRuntimePosition(
|
|
targetId,
|
|
state.playerPosition[0],
|
|
state.playerPosition[1],
|
|
state.playerPosition[2],
|
|
);
|
|
}
|
|
for (const id of affectedMobIds) {
|
|
const currentPosition = getMobPosition(id);
|
|
if (!currentPosition) continue;
|
|
if (effect.toward === "caster") {
|
|
const dx = currentPosition[0] - state.playerPosition[0];
|
|
const dz = currentPosition[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [
|
|
state.playerPosition[0] + dx / length * 1.5,
|
|
currentPosition[1],
|
|
state.playerPosition[2] + dz / length * 1.5,
|
|
]);
|
|
}
|
|
}
|
|
} else if (effect.kind === "execute") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
// DBC instant-kill effects include pet sacrifices such as Death Pact.
|
|
// They must never bypass a boss encounter's health and mechanics.
|
|
if (!mob || mob.dead || mob.boss) continue;
|
|
damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
Math.max(mob.health, mob.maxHealth * effect.coefficient),
|
|
now,
|
|
castThreatSource,
|
|
damageSchool,
|
|
);
|
|
}
|
|
} else if (effect.kind === "resource-drain") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
if (ability.target === "hostile") {
|
|
let drainedFromPlayers = 0;
|
|
let hasPlayerTarget = false;
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob?.characterResources) continue;
|
|
hasPlayerTarget = true;
|
|
const current = mob.characterResources[resource] ?? 0;
|
|
const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount;
|
|
const drained = Math.min(current, requested);
|
|
drainedFromPlayers += drained;
|
|
work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } };
|
|
if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool);
|
|
}
|
|
const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount;
|
|
if (returned > 0) {
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else {
|
|
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "threat") {
|
|
const threatTargets = affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs);
|
|
for (const id of threatTargets) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
const threatActorId = effect.recipient === "ability-target" && targetId && !work.mobs[targetId]
|
|
? targetId
|
|
: castThreatSource.actorId;
|
|
const currentThreat = mob.threatByActor[threatActorId] ?? 0;
|
|
const nextThreat = effect.mode === "redirect"
|
|
? 0
|
|
: effect.mode === "percent"
|
|
? currentThreat * Math.max(0, 1 + effect.amount / 100)
|
|
: Math.max(0, currentThreat + effect.amount);
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: {
|
|
...mob.threatByActor,
|
|
[threatActorId]: nextThreat,
|
|
...(effect.mode === "redirect" && targetId && !work.mobs[targetId]
|
|
? { [targetId]: (mob.threatByActor[targetId] ?? 0) + currentThreat }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
} else if (effect.kind === "movement") {
|
|
if (ability.dbcSpellId === 490344 && targetId) {
|
|
const target = getMobRuntimeTransform(targetId);
|
|
if (target) playerPositionAfterEffects = [
|
|
target.position[0] - Math.sin(target.yaw) * 1.5,
|
|
target.position[1],
|
|
target.position[2] - Math.cos(target.yaw) * 1.5,
|
|
];
|
|
} else if (ability.dbcSpellId === 490150 && targetPosition) {
|
|
const dx = targetPosition[0] - state.playerPosition[0];
|
|
const dz = targetPosition[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
playerPositionAfterEffects = [
|
|
targetPosition[0] - dx / length * 1.5,
|
|
targetPosition[1],
|
|
targetPosition[2] - dz / length * 1.5,
|
|
];
|
|
} else if (effect.movement === "sanctuary") {
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const threatByActor = { ...mob.threatByActor, [castThreatSource.actorId]: 0 };
|
|
work.mobs[id] = { ...mob, threatByActor };
|
|
}
|
|
} else if (effect.movement === "knockback") {
|
|
for (const id of affectedMobIds) {
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const dx = position[0] - state.playerPosition[0];
|
|
const dz = position[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [position[0] + dx / length * 4, position[1], position[2] + dz / length * 4]);
|
|
}
|
|
} else if (effect.toward === "away") {
|
|
const referenceId = state.selectedTargetId
|
|
?? nearbyMobRuntimeIds(state.playerPosition, 40).find((id) => !work.mobs[id]?.dead)
|
|
?? null;
|
|
const reference = referenceId ? getMobPosition(referenceId) : null;
|
|
const dx = reference ? state.playerPosition[0] - reference[0] : 0;
|
|
const dz = reference ? state.playerPosition[2] - reference[2] : 1;
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
playerPositionAfterEffects = [
|
|
state.playerPosition[0] + dx / length * 8,
|
|
state.playerPosition[1],
|
|
state.playerPosition[2] + dz / length * 8,
|
|
];
|
|
} else if (targetPosition) {
|
|
playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]];
|
|
}
|
|
} else if (effect.kind === "dispel-mechanic") {
|
|
const mechanicStatus: Partial<Record<number, MobStatusKind>> = {
|
|
1: "charm",
|
|
3: "disarm",
|
|
5: "fear",
|
|
7: "root",
|
|
9: "silence",
|
|
10: "sleep",
|
|
11: "slow",
|
|
12: "stun",
|
|
};
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob) continue;
|
|
const wanted = mechanicStatus[effect.mechanic];
|
|
let removed = 0;
|
|
work.mobs[id] = {
|
|
...mob,
|
|
statuses: mob.statuses.filter((status) => {
|
|
if (removed >= effect.maxCount || (wanted && status.kind !== wanted)) return true;
|
|
removed += 1;
|
|
return false;
|
|
}),
|
|
};
|
|
}
|
|
} else if (effect.kind === "pet") {
|
|
if (["summon", "call", "recall", "resurrect", "tame"].includes(effect.action)) {
|
|
summonSequence += 1;
|
|
const existingPet = work.summons.find((summon) => summon.id.startsWith("player-pet-"));
|
|
if (!existingPet) {
|
|
work.summons.push({
|
|
id: `player-pet-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId ?? 1,
|
|
targetId,
|
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
kind: "pet",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: Math.max(1, Math.round(work.maxHealth * 0.5)),
|
|
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.5)),
|
|
position: playerOrigin,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: 2_000,
|
|
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)),
|
|
...initialSummonCombatRuntime(),
|
|
exclusiveGroup: "combat-pet",
|
|
});
|
|
}
|
|
} else if (effect.action === "dismiss") {
|
|
work.summons = work.summons.filter((summon) => !summon.id.startsWith("player-pet-"));
|
|
}
|
|
} else if (effect.kind === "totem") {
|
|
work.summons = work.summons.filter((summon) => !/totem/i.test(abilityById(summon.sourceAbilityId)?.name ?? ""));
|
|
} else if (effect.kind === "rune") {
|
|
work.deathKnightRunes = activateDeathKnightRunes(work.deathKnightRunes, effect.count);
|
|
} else if (effect.kind === "cooldown-reset") {
|
|
cooldownAdjustments.push(effect);
|
|
} else if (effect.kind === "trigger-spell") {
|
|
if (!effect.expanded && effect.spellId !== ability.dbcSpellId) {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
effect.spellId,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
targetId,
|
|
now,
|
|
0,
|
|
);
|
|
}
|
|
} else if (effect.kind === "utility") {
|
|
if (effect.action === "create-item") {
|
|
if (/water|mana|spellstone/i.test(ability.name)) {
|
|
const restored = Math.max(1, Math.round(work.maxResource * 0.25));
|
|
work.resource = clamp(work.resource + restored, 0, work.maxResource);
|
|
work.resourcePools[activeResourceType] = work.resource;
|
|
} else if (/food|refreshment|health|firestone/i.test(ability.name)) {
|
|
work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25)));
|
|
}
|
|
} else if (effect.action === "portal") {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-utility-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.miscValue,
|
|
targetId: null,
|
|
expiresAt: now + Math.max(30_000, effect.durationMs),
|
|
kind: "summon",
|
|
name: ability.name,
|
|
ownerActorId: PLAYER_AGGRO_ID,
|
|
health: 1,
|
|
maxHealth: 1,
|
|
position: playerOrigin,
|
|
nextActionAt: Number.MAX_SAFE_INTEGER,
|
|
pulseIntervalMs: 0,
|
|
attackDamage: 0,
|
|
...initialSummonCombatRuntime(),
|
|
});
|
|
} else if (effect.action === "class-script" && ability.name === "Divine Intervention") {
|
|
work.health = 1;
|
|
work.progression.health = 1;
|
|
} else if (effect.action === "class-script" && ability.id === "rom-rogue-rom-warden-elite-60") {
|
|
const revival = work.auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("rom-revival-charge")
|
|
));
|
|
work.totalHealing += healPlayerInWork(work, Math.round(work.maxHealth * 0.05 * (revival?.stacks ?? 0)));
|
|
if (revival) work.auras = work.auras.filter((aura) => aura.instanceId !== revival.instanceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const consumedRuneWakerTags = ability.id === "rom-mage-primary-55"
|
|
? ["rom-charged"]
|
|
: ability.id === "rom-mage-primary-56"
|
|
? ["rom-static-field-charge"]
|
|
: holyAuraFriendly && ability.name === "Holy Aura"
|
|
? ["rom-holy-salvation-candle"]
|
|
: [];
|
|
if (consumedRuneWakerTags.length) {
|
|
work.auras = work.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag))
|
|
));
|
|
}
|
|
if (hasHostileEffect && ability.name !== "Cold Blood") {
|
|
work.auras = work.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("next-offensive-critical")
|
|
));
|
|
}
|
|
|
|
evaluateProcsInWork(work, {
|
|
trigger: "cast",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: targetId ?? undefined,
|
|
abilityId: ability.id,
|
|
}, now);
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "cast",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: ability.id,
|
|
school: spellSchool,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "completed",
|
|
});
|
|
|
|
// Judgement of Light restores 2% health when the marked target is struck.
|
|
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
|
const healed = healPlayerInWork(work,
|
|
talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability));
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, castThreatSource, healed);
|
|
}
|
|
|
|
const cooldowns: Record<string, number> = { ...work.cooldowns };
|
|
if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs;
|
|
if (ability.cooldownGroup && completingCast?.mode !== "channel") {
|
|
for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) {
|
|
cooldowns[grouped.id] = now + Math.max(cooldownMs, talentAdjustedCooldown(grouped, modifiers));
|
|
}
|
|
}
|
|
for (const adjustment of cooldownAdjustments) {
|
|
const candidates = cooldownResetCandidates(state.abilities, ability.id, adjustment.abilityName);
|
|
for (const candidate of candidates) {
|
|
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
|
|
else {
|
|
const readyAt = cooldowns[candidate.id] ?? now;
|
|
const reduction = adjustment.amountPercent !== undefined
|
|
? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, adjustment.amountPercent))
|
|
: adjustment.amountMs ?? 0;
|
|
cooldowns[candidate.id] = Math.max(now, readyAt - reduction);
|
|
}
|
|
}
|
|
}
|
|
const burstReset = romFireLightningBurstReset(
|
|
ability,
|
|
Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0),
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID).some((aura) =>
|
|
aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)),
|
|
);
|
|
if (burstReset.reset) {
|
|
cooldowns[ability.id] = 0;
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: {
|
|
id: "rom-fire-lightning-burst-reset-guard",
|
|
name: "Fire Lightning Burst reset guard",
|
|
disposition: "buff",
|
|
durationMs: burstReset.guardDurationMs,
|
|
utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
|
|
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
|
const levelMessage = work.progression.levelsGained > 0
|
|
? levelUpMessage(state.classId, state.level, work.progression.level)
|
|
: primaryCombatResult
|
|
? `${ability.name} ${combatResultDescription(primaryCombatResult)}.`
|
|
: `${ability.name}${work.totalDamage ? ` dealt ${work.totalDamage}` : ""}${work.totalHealing ? ` healed ${work.totalHealing}` : ""}.`;
|
|
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
|
|
for (const [actorId, actor] of Object.entries(work.actors)) {
|
|
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) delete work.actors[actorId];
|
|
}
|
|
for (const summon of work.summons) {
|
|
work.actors[summon.id] = combatActorFromSummon(summon, work.progression.level, work.auras);
|
|
}
|
|
set({
|
|
level: work.progression.level,
|
|
xp: work.progression.xp,
|
|
xpToNext: work.progression.xpToNext,
|
|
health: work.progression.levelsGained > 0 ? work.progression.health : work.health,
|
|
maxHealth: work.progression.maxHealth,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [activeResourceType]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
lastResourceSpentAt: work.lastResourceSpentAt,
|
|
deathKnightRunes: work.deathKnightRunes,
|
|
talentModifiers: work.talentModifiers,
|
|
comboPoints: work.comboPoints,
|
|
comboTargetId: work.comboTargetId,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
areaEffects: work.areaEffects,
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
cooldowns,
|
|
globalCooldownEndsAt: completingCast || gcdMs <= 0 ? state.globalCooldownEndsAt : now + gcdMs,
|
|
activeCast: null,
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
|
selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId,
|
|
playerPosition: playerPositionAfterEffects,
|
|
...(clearedPlayerControl ? { controlledUntil: 0, controlMechanic: null } : {}),
|
|
feedback: nextFeedback(
|
|
work.progression.levelsGained > 0 ? "level-up" : "success",
|
|
levelMessage,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
|
|
),
|
|
});
|
|
emitPlayerAbilityResolutionPresentation(
|
|
ability,
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
!completingCast,
|
|
);
|
|
|
|
const chimeraDamage = ability.name === "Chimera Shot" && targetId && work.totalDamage > 0
|
|
? get().triggerChimeraShot(PLAYER_AGGRO_ID, targetId, ability.id, now)
|
|
: 0;
|
|
return {
|
|
ok: true,
|
|
abilityId: ability.id,
|
|
affectedMobIds,
|
|
damage: work.totalDamage + chimeraDamage,
|
|
healing: work.totalHealing,
|
|
levelsGained: work.progression.levelsGained,
|
|
};
|
|
},
|
|
|
|
castSlot: (index, origin, now) => {
|
|
const ability = get().abilities[Math.trunc(index)];
|
|
if (!ability) {
|
|
set({ feedback: nextFeedback("error", "That action-bar slot is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
return get().castAbility(ability.id, origin, now);
|
|
},
|
|
|
|
castActionBinding: (layer, control, origin, now) => {
|
|
const abilityId = get().actionBindings[actionBindingId(layer, control)];
|
|
if (!abilityId) {
|
|
set({ feedback: nextFeedback("error", "That controller binding is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
const ability = abilityById(abilityId);
|
|
if (ability && targetingRulesForAbility(ability).shape === "ground") {
|
|
const pending = get().pendingGroundTarget;
|
|
if (pending?.abilityId !== abilityId) {
|
|
get().beginGroundTarget(abilityId);
|
|
return { ...emptyCastResult(), ok: true, abilityId };
|
|
}
|
|
return get().confirmGroundTarget(undefined, now);
|
|
}
|
|
return get().castAbility(abilityId, origin, now);
|
|
},
|
|
|
|
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
|
|
const state = get();
|
|
const threatSource = source ?? playerThreatSource(state);
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools },
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const result = rollContext
|
|
? resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
rollContext.attackKind,
|
|
school,
|
|
rollContext.seed,
|
|
rollContext.abilityId ? abilityById(rollContext.abilityId) ?? undefined : undefined,
|
|
rollContext.periodic,
|
|
rollContext.rules,
|
|
rollContext.criticalChanceBonusPercent,
|
|
)
|
|
: damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
school,
|
|
state.gearStats.spellPenetration,
|
|
);
|
|
set({
|
|
mobs: work.mobs,
|
|
effects: work.effects,
|
|
auras: work.auras,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
cooldowns: work.cooldowns,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
health: progression.health,
|
|
maxHealth: progression.maxHealth,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
talentModifiers: work.talentModifiers,
|
|
lastCombatResult: result.combatResult ?? state.lastCombatResult,
|
|
selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null : state.comboTargetId,
|
|
feedback: result.levelsGained > 0
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: result.combatResult
|
|
? nextFeedback("damage", `${state.mobs[id]?.name ?? "Enemy"} ${combatResultDescription(result.combatResult)}.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
})
|
|
: nextFeedback("damage", result.killed ? `${state.mobs[id]?.name ?? "Enemy"} defeated.` : `${result.amount} damage.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
}),
|
|
});
|
|
return result;
|
|
},
|
|
|
|
consumeActorAuraTag: (actorId, tag) => {
|
|
set((state) => ({
|
|
auras: state.auras.filter((aura) => !(aura.targetId === actorId
|
|
&& aura.definition.utilityTags?.includes(tag))),
|
|
}));
|
|
},
|
|
|
|
consumeActorEnrage: (actorId, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.auras.some((aura) => consumableEnrage(aura, actorId, now))) return false;
|
|
set({ auras: state.auras.filter((aura) => !consumableEnrage(aura, actorId, now)) });
|
|
return true;
|
|
},
|
|
|
|
triggerChimeraShot: (sourceActorId, targetId, abilityId, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.mobs[targetId] || state.mobs[targetId].dead) return 0;
|
|
const stings = ["Serpent Sting", "Viper Sting", "Scorpid Sting"];
|
|
const stingEffect = state.effects.find((effect) => effect.kind === "dot"
|
|
&& effect.sourceActorId === sourceActorId && effect.targetId === targetId
|
|
&& abilityById(effect.sourceAbilityId)?.name === "Serpent Sting");
|
|
const stingAura = state.auras.find((aura) => aura.sourceId === sourceActorId
|
|
&& aura.targetId === targetId && stings.some((name) => aura.definition.name.startsWith(name))
|
|
&& (aura.expiresAt === null || aura.expiresAt > now));
|
|
if (!stingAura && !stingEffect) return 0;
|
|
const stingName = stingAura
|
|
? stings.find((name) => stingAura.definition.name.startsWith(name))!
|
|
: "Serpent Sting";
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const stingAbility = stingEffect
|
|
? abilityById(stingEffect.sourceAbilityId)
|
|
: abilitiesForClass(sourceMember?.classId ?? state.classId).find((candidate) => candidate.name === stingName) ?? null;
|
|
const activeSting = stingAbility
|
|
? abilityAtLevel(stingAbility, sourceMember?.level ?? state.level)
|
|
: null;
|
|
const dot = activeSting?.effects.find((effect) => effect.kind === "dot");
|
|
set({
|
|
auras: state.auras.map((aura) => aura === stingAura
|
|
? { ...aura, expiresAt: now + (aura.definition.durationMs ?? 15_000) }
|
|
: aura),
|
|
effects: stingEffect && dot
|
|
? state.effects.map((effect) => effect === stingEffect
|
|
? { ...effect, remainingTicks: dot.ticks, nextTickAt: now + dot.intervalMs }
|
|
: effect)
|
|
: state.effects,
|
|
});
|
|
if (stingName === "Serpent Sting" && stingEffect && dot) {
|
|
const tick = stingEffect.fixedAmount ?? equipmentAdjustedCombatAmount(
|
|
stingEffect,
|
|
sourceMember?.level ?? state.level,
|
|
sourceMember?.gearStats ?? state.gearStats,
|
|
sourceMember?.classId ?? state.classId,
|
|
activeSting ?? stingAbility!,
|
|
);
|
|
return get().damageMob(targetId, Math.max(1, Math.round(tick * dot.ticks * 0.4)), now,
|
|
threatSourceForActor(get(), sourceActorId, abilityId), "nature").amount;
|
|
}
|
|
if (stingName === "Viper Sting") {
|
|
const drain = activeSting?.effects.find((effect) => effect.kind === "resource-drain");
|
|
if (drain) {
|
|
const current = get();
|
|
const actor = current.actors[sourceActorId];
|
|
const maximum = sourceActorId === PLAYER_AURA_ENTITY_ID
|
|
? current.maxResourcePools.mana ?? current.maxResource
|
|
: actor?.resources.mana?.maximum ?? 0;
|
|
const amount = drain.percentage
|
|
? maximum * drain.amount / 100
|
|
: drain.amount;
|
|
if (sourceActorId === PLAYER_AURA_ENTITY_ID) {
|
|
const before = current.resourcePools.mana ?? 0;
|
|
const restored = Math.min(Math.max(0, amount * 0.6), Math.max(0, maximum - before));
|
|
if (restored > 0) {
|
|
set({
|
|
resourcePools: { ...current.resourcePools, mana: before + restored },
|
|
...(resourceProfileForClass(current.classId).type === "mana" ? { resource: before + restored } : {}),
|
|
actors: actor ? { ...current.actors, [sourceActorId]: {
|
|
...actor,
|
|
resources: { ...actor.resources, mana: { ...(actor.resources.mana
|
|
?? normalizeActorResourcePool("mana", before, maximum)), current: before + restored } },
|
|
} } : current.actors,
|
|
});
|
|
}
|
|
} else get().restoreActorResource(sourceActorId, "mana", Math.max(0, amount * 0.6), false,
|
|
sourceActorId, abilityId, now);
|
|
}
|
|
} else if (stingName === "Scorpid Sting" && !get().auras.some((aura) => aura.targetId === sourceActorId
|
|
&& aura.definition.utilityTags?.includes("chimera-scorpid-cooldown")
|
|
&& (aura.expiresAt === null || aura.expiresAt > now))) {
|
|
get().controlMob(targetId, abilityId, "disarm", 10_000, now);
|
|
const ability = abilityById(abilityId);
|
|
if (ability) get().applyActorAura(sourceActorId, sourceActorId, ability, {
|
|
kind: "apply-aura", recipient: "caster",
|
|
aura: {
|
|
id: `wow335:chimera-scorpid-cooldown:${sourceActorId}`, name: "Chimera Shot", disposition: "buff",
|
|
durationMs: 60_000, maxStacks: 1, stackBehavior: "refresh",
|
|
utilityTags: ["chimera-scorpid-cooldown"],
|
|
},
|
|
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
|
|
}, now);
|
|
}
|
|
return 0;
|
|
},
|
|
|
|
triggerRogueWeaponPoisons: (
|
|
sourceActorId,
|
|
targetId,
|
|
sourceAbilityId,
|
|
hitCount = 1,
|
|
now = combatNow(),
|
|
) => {
|
|
const state = get();
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools },
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const result = applyRogueWeaponPoisonsInWork(
|
|
work,
|
|
sourceActorId,
|
|
targetId,
|
|
sourceAbilityId,
|
|
hitCount,
|
|
now,
|
|
);
|
|
set({
|
|
mobs: work.mobs,
|
|
effects: work.effects,
|
|
auras: work.auras,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
cooldowns: work.cooldowns,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
health: progression.health,
|
|
maxHealth: progression.maxHealth,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
selectedTargetId: work.mobs[targetId]?.dead && state.selectedTargetId === targetId
|
|
? null
|
|
: state.selectedTargetId,
|
|
autoAttackTargetId: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
|
|
? null
|
|
: state.autoAttackTargetId,
|
|
nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
|
|
? 0
|
|
: state.nextAutoAttackAt,
|
|
});
|
|
return result;
|
|
},
|
|
|
|
consumeRogueDeadlyPoison: (
|
|
sourceActorId,
|
|
targetId,
|
|
maximumDoses,
|
|
now = combatNow(),
|
|
) => {
|
|
const state = get();
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
areaEffects: [...state.areaEffects],
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools },
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression: progressionFromState(state),
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const consumed = consumeRogueDeadlyPoisonInWork(
|
|
work,
|
|
sourceActorId,
|
|
targetId,
|
|
maximumDoses,
|
|
now,
|
|
);
|
|
if (consumed > 0) set({ auras: work.auras, effects: work.effects });
|
|
return consumed;
|
|
},
|
|
|
|
addHealingThreat: (source, effectiveHealing) => set((state) => {
|
|
const engagedIds = Object.values(state.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length)
|
|
* (source.threatScale ?? 1)
|
|
* resolveAuraStatValue(1, aurasForEntity(state.auras, source.actorId), "threat");
|
|
if (perMob <= 0) return state;
|
|
const mobs = { ...state.mobs };
|
|
for (const id of engagedIds) {
|
|
const mob = mobs[id];
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
return { mobs };
|
|
}),
|
|
|
|
applyActorThreatEffect: (actorId, effect, redirectTargetActorId = null, now = combatNow()) => set((state) => {
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let changed = false;
|
|
for (const [id, mob] of Object.entries(mobs)) {
|
|
if (!mob.engaged || mob.dead) continue;
|
|
const currentThreat = mob.threatByActor[actorId] ?? 0;
|
|
const nextThreat = effect.mode === "redirect"
|
|
? 0
|
|
: effect.mode === "percent"
|
|
? currentThreat * Math.max(0, 1 + effect.amount / 100)
|
|
: Math.max(0, currentThreat + effect.amount);
|
|
const threatByActor = {
|
|
...mob.threatByActor,
|
|
[actorId]: nextThreat,
|
|
...(effect.mode === "redirect" && redirectTargetActorId
|
|
? { [redirectTargetActorId]: (mob.threatByActor[redirectTargetActorId] ?? 0) + currentThreat }
|
|
: {}),
|
|
};
|
|
const resolution = resolveAggroTarget(
|
|
threatByActor,
|
|
eligibleAggroActorIds(state, mob),
|
|
mob.targetActorId,
|
|
mob.forcedTarget,
|
|
now,
|
|
);
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor,
|
|
targetActorId: resolution.targetActorId,
|
|
forcedTarget: resolution.forcedTarget,
|
|
};
|
|
changed = true;
|
|
}
|
|
return changed ? { mobs } : state;
|
|
}),
|
|
|
|
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
|
const target = get().mobs[id];
|
|
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
|
|
const previous = target.tauntDiminishingReturns;
|
|
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
|
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
|
const effectiveDurationMs = Math.round(durationMs * multiplier);
|
|
const nextDiminishing = target.boss
|
|
? { applications: Math.min(3, applications + 1), resetAt: now + 15_000 }
|
|
: target.tauntDiminishingReturns;
|
|
set((state) => {
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
if (effectiveDurationMs <= 0) {
|
|
mobs[id] = { ...mobs[id], tauntDiminishingReturns: nextDiminishing };
|
|
return { mobs };
|
|
}
|
|
const groupId = mobGroupId(id);
|
|
const groupWasEngaged = Object.entries(mobs).some(([mobId, mob]) => (
|
|
mobGroupId(mobId) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
for (const [mobId, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
|
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
|
const seeded = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, source.actorId);
|
|
mobs[mobId] = {
|
|
...mob,
|
|
...(mobId === id ? { tauntDiminishingReturns: nextDiminishing } : {}),
|
|
threatByActor: mobId === id ? tauntThreat(seeded, source.actorId) : seeded,
|
|
targetActorId: mobId === id
|
|
? source.actorId
|
|
: (mob.targetActorId ?? (groupWasEngaged ? null : source.actorId)),
|
|
forcedTarget: mobId === id
|
|
? { actorId: source.actorId, endsAt: now + Math.max(1, effectiveDurationMs) }
|
|
: mob.forcedTarget,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(mobId))
|
|
: (getMobPosition(mobId) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
}
|
|
return { mobs };
|
|
});
|
|
return effectiveDurationMs > 0;
|
|
},
|
|
|
|
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
|
|
let interrupted = false;
|
|
set((state) => {
|
|
const mob = state.mobs[id];
|
|
if (!mob?.activeCast?.interruptible) return state;
|
|
const next = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: Math.max(1, lockoutMs),
|
|
}, sourceAbilityId, now);
|
|
interrupted = next.activeCast === null;
|
|
return interrupted ? { mobs: { ...state.mobs, [id]: next } } : state;
|
|
});
|
|
return interrupted;
|
|
},
|
|
|
|
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow(), breakOnDamage = false, sourceActorId) => {
|
|
let controlled = false;
|
|
set((state) => {
|
|
const mob = state.mobs[id];
|
|
if (!mob || mob.dead) return state;
|
|
const exclusive = romSingleTargetControl(abilityById(sourceAbilityId));
|
|
const next = applyStatus(mob, {
|
|
kind: "status",
|
|
status,
|
|
durationMs: Math.max(1, durationMs),
|
|
breakOnDamage,
|
|
}, sourceAbilityId, now, sourceActorId);
|
|
controlled = next.statuses.some((entry) => (
|
|
entry.sourceAbilityId === sourceAbilityId
|
|
&& entry.kind === status
|
|
&& entry.endsAt > now
|
|
));
|
|
if (!controlled) return state;
|
|
const mobs = exclusive && sourceActorId ? Object.fromEntries(Object.entries(state.mobs).map(([mobId, candidate]) => [
|
|
mobId,
|
|
mobId === id ? next : { ...candidate, statuses: candidate.statuses.filter((entry) => !(
|
|
entry.sourceAbilityId === sourceAbilityId && entry.sourceActorId === sourceActorId
|
|
)) },
|
|
])) : { ...state.mobs, [id]: next };
|
|
return { mobs };
|
|
});
|
|
return controlled;
|
|
},
|
|
|
|
dispelActor: (id, categories, maxCount = 1, now = combatNow()) => {
|
|
let removedCount = 0;
|
|
let clearedCompanionActor: CombatActorState | null = null;
|
|
let removedBombs: TimedCombatEffect[] = [];
|
|
set((state) => {
|
|
const targeted = state.auras.filter((aura) => aura.targetId === id);
|
|
const untouched = state.auras.filter((aura) => aura.targetId !== id);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship: "friendly",
|
|
categories,
|
|
maxCount: Math.max(1, Math.trunc(maxCount)),
|
|
});
|
|
removedCount = result.removed.length;
|
|
if (!removedCount) return state;
|
|
removedBombs = state.effects.filter((effect) => effect.targetId === id
|
|
&& abilityById(effect.sourceAbilityId)?.name === "Living Bomb"
|
|
&& result.removed.some((aura) => aura.sourceId === effect.sourceActorId
|
|
&& aura.definition.name.startsWith("Living Bomb")));
|
|
const effects = state.effects.filter((effect) => !removedBombs.includes(effect));
|
|
const auras = [...untouched, ...result.auras].filter((aura) => !removedBombs.some((bomb) => (
|
|
aura.targetId === id && aura.sourceId === bomb.sourceActorId
|
|
&& aura.definition.name.startsWith("Living Bomb")
|
|
)));
|
|
if (id === PLAYER_AURA_ENTITY_ID && result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
|
|
return { auras, effects, controlledUntil: 0, controlMechanic: null };
|
|
}
|
|
if (result.removed.some((aura) => aura.definition.id.startsWith("enemy-control:"))) {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (member) {
|
|
const actor = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, id),
|
|
state.actors[id],
|
|
);
|
|
clearedCompanionActor = { ...actor, controlledUntil: 0 };
|
|
return {
|
|
auras, effects,
|
|
actors: { ...state.actors, [id]: clearedCompanionActor },
|
|
};
|
|
}
|
|
}
|
|
return { auras, effects };
|
|
});
|
|
if (clearedCompanionActor) mirrorCompanionActor(clearedCompanionActor);
|
|
for (const bomb of removedBombs) detonateLivingBombFromStore(bomb, now);
|
|
return removedCount;
|
|
},
|
|
|
|
healActor: (id, amount, request) => {
|
|
const now = request.now ?? combatNow();
|
|
const state = get();
|
|
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
|
|
const targetAuras = aurasForEntity(state.auras, id);
|
|
const modified = request.amountAlreadyModified
|
|
? amount
|
|
: resolveAuraHealingValue(
|
|
resolveAuraHealingValue(amount, sourceAuras, "done"),
|
|
targetAuras,
|
|
"taken",
|
|
);
|
|
const partyMember = usePartyStore.getState().members.find((member) => member.id === id);
|
|
const summon = state.summons.find((candidate) => candidate.id === id);
|
|
const actor = partyMember
|
|
? companionActorFromMember(partyMember, entityAbsorbAmount(state.auras, id), state.actors[id])
|
|
: state.actors[id];
|
|
const currentHealth = id === PLAYER_AURA_ENTITY_ID
|
|
? state.health
|
|
: summon?.health ?? actor?.health ?? 0;
|
|
const maximumHealth = id === PLAYER_AURA_ENTITY_ID
|
|
? state.maxHealth
|
|
: summon?.maxHealth ?? actor?.maxHealth ?? 0;
|
|
if (currentHealth <= 0 || maximumHealth <= 0) return resolveHealing(0, currentHealth, maximumHealth);
|
|
const resolution = resolveHealing(
|
|
modified,
|
|
currentHealth,
|
|
maximumHealth,
|
|
request.criticalChance ?? 0,
|
|
stableCombatUnit(request.seed ?? `${request.sourceActorId}:${request.abilityId ?? "heal"}:${id}:${now}`),
|
|
request.criticalMultiplier ?? 1.5,
|
|
);
|
|
let healedCompanion: CombatActorState | null = null;
|
|
set((current) => {
|
|
const combatEvents = recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: request.sourceActorId,
|
|
targetActorId: id,
|
|
...(request.abilityId ? { abilityId: request.abilityId } : {}),
|
|
...(request.school ? { school: request.school } : {}),
|
|
rawAmount: resolution.rawAmount,
|
|
effectiveAmount: resolution.effectiveAmount,
|
|
overheal: resolution.overheal,
|
|
absorbed: 0,
|
|
critical: resolution.critical,
|
|
});
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
const playerActor = current.actors[id];
|
|
return {
|
|
health: current.health + resolution.effectiveAmount,
|
|
actors: playerActor ? {
|
|
...current.actors,
|
|
[id]: { ...playerActor, health: current.health + resolution.effectiveAmount, alive: true },
|
|
} : current.actors,
|
|
combatEvents,
|
|
};
|
|
}
|
|
const activeSummon = current.summons.find((candidate) => candidate.id === id);
|
|
if (activeSummon) {
|
|
const nextHealth = activeSummon.health + resolution.effectiveAmount;
|
|
const summonActor = current.actors[id];
|
|
return {
|
|
summons: current.summons.map((candidate) => candidate.id === id
|
|
? { ...candidate, health: nextHealth }
|
|
: candidate),
|
|
actors: summonActor ? {
|
|
...current.actors,
|
|
[id]: { ...summonActor, health: nextHealth, alive: true },
|
|
} : current.actors,
|
|
combatEvents,
|
|
};
|
|
}
|
|
if (!partyMember || !actor) return { combatEvents };
|
|
healedCompanion = { ...actor, health: actor.health + resolution.effectiveAmount, alive: true };
|
|
return {
|
|
actors: { ...current.actors, [id]: healedCompanion },
|
|
combatEvents,
|
|
};
|
|
});
|
|
if (healedCompanion) mirrorCompanionActor(healedCompanion);
|
|
return resolution;
|
|
},
|
|
|
|
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
|
|
const state = get();
|
|
const fraction = clamp(percentMaxHealth, 0.01, 1);
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
const restored = state.health > 0 ? 0 : Math.max(1, Math.round(state.maxHealth * fraction));
|
|
if (restored <= 0) return 0;
|
|
const playerActor = state.actors[id];
|
|
set({
|
|
health: restored,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
actors: playerActor ? { ...state.actors, [id]: {
|
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
|
} } : state.actors,
|
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored, abilityId }),
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return restored;
|
|
}
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
|
|
if (!member) {
|
|
const onlineActor = onlinePlayerRuntimeActor(id);
|
|
if (!onlineActor || onlineActor.health > 0) return 0;
|
|
const restored = Math.max(1, Math.round(onlineActor.maxHealth * fraction));
|
|
set({
|
|
actors: {
|
|
...state.actors,
|
|
[id]: { ...onlineActor, health: restored, alive: true, controlledUntil: 0, cast: null },
|
|
},
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return restored;
|
|
}
|
|
const actor = companionActorFromMember(member, entityAbsorbAmount(state.auras, id), state.actors[id]);
|
|
if (actor.alive || actor.health > 0) return 0;
|
|
const restored = Math.max(1, Math.round(actor.maxHealth * fraction));
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
health: restored,
|
|
alive: true,
|
|
controlledUntil: 0,
|
|
cast: null,
|
|
};
|
|
set({
|
|
actors: { ...state.actors, [id]: nextActor },
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resurrection",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: restored,
|
|
effectiveAmount: restored,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
});
|
|
mirrorCompanionActor(nextActor);
|
|
return restored;
|
|
},
|
|
|
|
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
|
|
const state = get();
|
|
const actor = state.actors[id];
|
|
if (!actor) return 0;
|
|
const pool = actor.resources[resource] ?? normalizeActorResourcePool(resource, 0, defaultResourceMaximum(resource));
|
|
const requested = Math.max(0, percentage ? pool.maximum * amount / 100 : amount);
|
|
const restored = Math.min(requested, pool.maximum - pool.current);
|
|
if (restored <= 0) return 0;
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
resources: {
|
|
...actor.resources,
|
|
[resource]: { ...pool, current: pool.current + restored },
|
|
},
|
|
};
|
|
const patch: Partial<CombatState> = {
|
|
actors: { ...state.actors, [id]: nextActor },
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "resource",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
...(abilityId ? { abilityId } : {}),
|
|
rawAmount: requested,
|
|
effectiveAmount: restored,
|
|
overheal: requested - restored,
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
};
|
|
if (id === PLAYER_AURA_ENTITY_ID) {
|
|
patch.resourcePools = { ...state.resourcePools, [resource]: pool.current + restored };
|
|
if (resource === resourceProfileForClass(state.classId).type) patch.resource = pool.current + restored;
|
|
}
|
|
set(patch);
|
|
if (nextActor.kind === "companion") mirrorCompanionActor(nextActor);
|
|
return restored;
|
|
},
|
|
|
|
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
|
|
const state = get();
|
|
const absorb = Math.max(0, Math.round(amount));
|
|
if (absorb <= 0 || !state.actors[id]) return 0;
|
|
const result = applyCombatAura(state.auras, {
|
|
definition: {
|
|
id: `shield:${sourceActorId}:${ability.id}`,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
icon: ability.icon,
|
|
disposition: "buff",
|
|
durationMs: Math.max(1, durationMs),
|
|
stackBehavior: "replace",
|
|
absorbs: [{ id: `${ability.id}:absorb`, amount: absorb }],
|
|
},
|
|
sourceId: sourceActorId,
|
|
targetId: id,
|
|
now,
|
|
});
|
|
set({
|
|
auras: result.auras,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId,
|
|
targetActorId: id,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: absorb,
|
|
effectiveAmount: absorb,
|
|
overheal: 0,
|
|
absorbed: absorb,
|
|
critical: false,
|
|
}),
|
|
});
|
|
return absorb;
|
|
},
|
|
|
|
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
|
|
if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false;
|
|
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
|
|
const gmGroup = `gm-party-buff:${family}`;
|
|
if (effect.aura.exclusiveGroup !== gmGroup && state.auras.some((aura) => (
|
|
aura.targetId === targetActorId && aura.definition.exclusiveGroup === gmGroup
|
|
))) return true;
|
|
const durationMs = effect.aura.durationMs;
|
|
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
|
const applied = applyCombatAura(state.auras, {
|
|
definition,
|
|
sourceId: sourceActorId,
|
|
targetId: targetActorId,
|
|
now,
|
|
});
|
|
const result = ability.name === "Power Word: Shield"
|
|
? { ...applied, auras: applyCombatAura(applied.auras, {
|
|
definition: WEAKENED_SOUL_AURA, sourceId: sourceActorId, targetId: targetActorId, now,
|
|
}).auras }
|
|
: applied;
|
|
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
|
let health = state.health;
|
|
let maxHealth = state.maxHealth;
|
|
const actors = { ...state.actors };
|
|
let companionActor: CombatActorState | null = null;
|
|
if (targetActorId === PLAYER_AURA_ENTITY_ID) {
|
|
const nextMaxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
result.auras,
|
|
);
|
|
const delta = nextMaxHealth - maxHealth;
|
|
maxHealth = nextMaxHealth;
|
|
health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth);
|
|
const actor = actors[targetActorId];
|
|
if (actor) actors[targetActorId] = { ...actor, health, maxHealth };
|
|
} else {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId);
|
|
const actor = member ? companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(state.auras, targetActorId),
|
|
state.actors[targetActorId],
|
|
) : null;
|
|
if (member && actor) {
|
|
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
|
const delta = auraMaximumHealthBonus(base, result.auras, targetActorId)
|
|
- auraMaximumHealthBonus(base, state.auras, targetActorId);
|
|
if (delta) {
|
|
const actorMaxHealth = Math.max(1, actor.maxHealth + delta);
|
|
const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth);
|
|
companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth });
|
|
actors[targetActorId] = companionActor;
|
|
}
|
|
}
|
|
}
|
|
let combatEvents = recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "aura",
|
|
sourceActorId,
|
|
targetActorId,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: 0,
|
|
effectiveAmount: 1,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
if (grantedAbsorb > 0) {
|
|
combatEvents = recordCombatEvent(combatEvents, {
|
|
occurredAt: now,
|
|
kind: "absorb",
|
|
sourceActorId,
|
|
targetActorId,
|
|
abilityId: ability.id,
|
|
school: presentationSchoolForAbility(ability),
|
|
rawAmount: grantedAbsorb,
|
|
effectiveAmount: grantedAbsorb,
|
|
overheal: 0,
|
|
absorbed: grantedAbsorb,
|
|
critical: false,
|
|
});
|
|
}
|
|
set({
|
|
auras: result.auras,
|
|
health,
|
|
maxHealth,
|
|
actors,
|
|
combatEvents,
|
|
});
|
|
if (companionActor) mirrorCompanionActor(companionActor);
|
|
return true;
|
|
},
|
|
|
|
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
|
|
const state = get();
|
|
if (!state.actors[sourceActorId]) return false;
|
|
const selfDrainPercent = effect.kind === "dot" ? romSelfDrainPercent(ability.dbcSpellId) : 0;
|
|
if (effect.kind === "dot" && selfDrainPercent === 0
|
|
&& (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
|
|
if (selfDrainPercent > 0 && sourceActorId !== targetActorId) return false;
|
|
if (effect.kind === "hot") {
|
|
const targetHealth = targetActorId === PLAYER_AURA_ENTITY_ID ? state.health
|
|
: state.summons.find((summon) => summon.id === targetActorId)?.health
|
|
?? state.actors[targetActorId]?.health ?? 0;
|
|
if (!state.actors[targetActorId] || targetHealth <= 0) return false;
|
|
}
|
|
const sourceActor = state.actors[sourceActorId];
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const amount = selfDrainPercent > 0
|
|
? Math.max(1, Math.round((sourceMember?.maxHealth ?? sourceActor.maxHealth) * selfDrainPercent / 100))
|
|
: sourceMember
|
|
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
|
|
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
|
|
const next = state.effects.filter((entry) => !(
|
|
entry.kind === effect.kind
|
|
&& entry.sourceAbilityId === ability.id
|
|
&& entry.targetId === targetActorId
|
|
));
|
|
next.push({ ...timedEffect(
|
|
effect.kind,
|
|
ability.id,
|
|
sourceActorId,
|
|
targetActorId,
|
|
{ ...effect, coefficient: amount },
|
|
effect.ticks,
|
|
effect.intervalMs,
|
|
now,
|
|
selfDrainPercent > 0 ? "rom-self-health-drain" : effect.kind === "dot" ? effect.tag : undefined,
|
|
effect.kind === "dot" ? presentationSchoolForAbility(ability) : undefined,
|
|
sourceMember?.gearStats.spellPenetration,
|
|
), fixedAmount: amount });
|
|
set({ effects: next });
|
|
return true;
|
|
},
|
|
|
|
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
|
|
const state = get();
|
|
const owner = state.actors[ownerActorId];
|
|
if (!owner?.alive) return null;
|
|
summonSequence += 1;
|
|
const isTotem = /totem/i.test(ability.name);
|
|
const id = `${ownerActorId}:${isTotem ? "totem" : "summon"}-${summonSequence}`;
|
|
const maxHealth = Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3)));
|
|
const summon: CombatSummon = {
|
|
id,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
kind: isTotem ? "totem" : "summon",
|
|
name: ability.name,
|
|
ownerActorId,
|
|
health: maxHealth,
|
|
maxHealth,
|
|
position,
|
|
nextActionAt: now + 1_000,
|
|
pulseIntervalMs: isTotem ? 2_000 : 2_200,
|
|
attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))),
|
|
...initialSummonCombatRuntime(),
|
|
...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}),
|
|
};
|
|
const summons = isTotem
|
|
? [...state.summons.filter((candidate) => candidate.exclusiveGroup !== summon.exclusiveGroup), summon]
|
|
: [...state.summons, summon];
|
|
set({ summons });
|
|
get().synchronizeActors();
|
|
return id;
|
|
},
|
|
|
|
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
|
|
const state = get();
|
|
const sourceActor = state.actors[sourceActorId];
|
|
if (!sourceActor?.alive) return null;
|
|
const targeting = targetingRulesForAbility(ability);
|
|
if (targeting.shape !== "ground") return null;
|
|
const sourcePosition = actorPosition(state, sourceActorId) ?? state.playerPosition;
|
|
const projected = projectCombatGroundPoint(position, targeting.groundRange ?? ability.range.max, sourcePosition);
|
|
if (!projected || !combatHasLineOfSight(sourcePosition, projected)) return null;
|
|
const periodic = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "dot" | "hot" }> => (
|
|
effect.kind === "dot" || effect.kind === "hot"
|
|
));
|
|
if (!periodic.length) return null;
|
|
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
|
|
const power = sourceMember
|
|
? sourceMember.level * 4 + sourceMember.gearStats.spellPower
|
|
+ sourceMember.gearStats.attackPower * 0.35 + sourceMember.gearStats.rangedAttackPower * 0.35
|
|
: sourceActor.level * 4;
|
|
const scaled = (effect: Extract<AbilityEffect, { kind: "dot" | "hot" }>) => Math.max(
|
|
1,
|
|
Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * Math.max(8, power)),
|
|
);
|
|
areaEffectSequence += 1;
|
|
const id = `${sourceActorId}:area-${areaEffectSequence}`;
|
|
const interval = Math.max(100, Math.min(...periodic.map((effect) => effect.intervalMs)));
|
|
const duration = Math.max(...periodic.map((effect) => effect.intervalMs * effect.ticks));
|
|
const area: CombatAreaEffect = {
|
|
id,
|
|
sourceActorId,
|
|
sourceAbilityId: ability.id,
|
|
position: projected,
|
|
radius: targeting.radius ?? ability.radius ?? 8,
|
|
createdAt: now,
|
|
expiresAt: now + duration,
|
|
tickIntervalMs: interval,
|
|
nextTickAt: now + interval,
|
|
fixedDamagePerTick: periodic.filter((effect) => effect.kind === "dot").reduce((sum, effect) => sum + scaled(effect), 0),
|
|
fixedHealingPerTick: periodic.filter((effect) => effect.kind === "hot").reduce((sum, effect) => sum + scaled(effect), 0),
|
|
};
|
|
set({ areaEffects: [...state.areaEffects, area] });
|
|
return id;
|
|
},
|
|
|
|
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
|
const state = get();
|
|
const now = combatNow();
|
|
const mitigated = damageAfterEquipmentMitigation(
|
|
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
|
state.gearStats,
|
|
state.level,
|
|
school,
|
|
attackerLevel ?? state.level,
|
|
);
|
|
const incoming = resolveAuraDamageValue(
|
|
mitigated,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(state.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: incoming,
|
|
school,
|
|
now,
|
|
});
|
|
const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed);
|
|
const remodeledBody = namedAbsorb.auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("rom-remodeled-body")
|
|
));
|
|
const preventedDeath = Boolean(remodeledBody && healthDamage >= state.health);
|
|
const health = preventedDeath ? state.maxHealth : state.health - healthDamage;
|
|
const aurasAfterCheatDeath = preventedDeath
|
|
? namedAbsorb.auras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId)
|
|
: namedAbsorb.auras;
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
healthDamage > 0 && (
|
|
hasRomWarriorRagePool(state.classId, state.secondaryClassId)
|
|
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && state.maxResourcePools.rage !== undefined)
|
|
)
|
|
? RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
state.resource,
|
|
state.maxResource,
|
|
{ ...state.resourcePools },
|
|
{ ...state.maxResourcePools },
|
|
);
|
|
const procResult = evaluateAuraProcs(aurasAfterCheatDeath, {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healthDamage,
|
|
school,
|
|
}, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`));
|
|
let nextAuras = procResult.auras;
|
|
if (
|
|
healthDamage > 0
|
|
&& state.abilities.some((ability) => ability.id === "rom-rogue-rom-warden-elite-60" && isAbilityUnlocked(ability, state.level))
|
|
) {
|
|
nextAuras = applyCombatAura(nextAuras, {
|
|
definition: {
|
|
id: "rom-state-revival-charge",
|
|
name: "Revival",
|
|
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
maxStacks: 10,
|
|
stackBehavior: "add",
|
|
utilityTags: ["rom-revival-charge"],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras;
|
|
}
|
|
set({
|
|
auras: nextAuras,
|
|
shield: state.shield - absorbed,
|
|
health,
|
|
resource: rage.resource,
|
|
resourcePools: rage.resourcePools,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: "external",
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
school,
|
|
rawAmount: incoming,
|
|
effectiveAmount: healthDamage,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed + absorbed,
|
|
critical: false,
|
|
outcome: healthDamage > 0 ? "hit" : "absorb",
|
|
}),
|
|
activeCast: health <= 0
|
|
? null
|
|
: healthDamage > 0
|
|
? applyWrathCastPushback(state.activeCast, now, `external:${school}:${state.health}`)
|
|
: state.activeCast,
|
|
autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(health <= 0 ? "death" : "wound")
|
|
: state.playerAnimationEvent,
|
|
feedback: nextFeedback("damage", preventedDeath ? "Remodeled Body prevented defeat and restored all health." : health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }),
|
|
});
|
|
return healthDamage;
|
|
},
|
|
|
|
damageSummon: (id, amount) => {
|
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
|
if (requested <= 0) return 0;
|
|
let applied = 0;
|
|
set((state) => {
|
|
const summon = state.summons.find((candidate) => candidate.id === id);
|
|
if (!summon) return state;
|
|
const absorbed = absorbAuraDamage(state.auras, {
|
|
targetId: id,
|
|
amount: requested,
|
|
school: "physical",
|
|
now: combatNow(),
|
|
});
|
|
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
const health = summon.health - applied;
|
|
const summons = health > 0
|
|
? state.summons.map((candidate) => candidate.id === id ? { ...candidate, health } : candidate)
|
|
: state.summons.filter((candidate) => candidate.id !== id);
|
|
const actors = { ...state.actors };
|
|
if (health > 0) actors[id] = combatActorFromSummon({ ...summon, health }, state.level, absorbed.auras);
|
|
else delete actors[id];
|
|
return {
|
|
auras: absorbed.auras,
|
|
summons,
|
|
actors,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "damage",
|
|
sourceActorId: "external",
|
|
targetActorId: id,
|
|
rawAmount: requested,
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical: false,
|
|
outcome: applied > 0 ? "hit" : "absorb",
|
|
}),
|
|
};
|
|
});
|
|
return applied;
|
|
},
|
|
|
|
healPlayer: (amount) => {
|
|
const state = get();
|
|
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
|
// Resurrection is an explicit combat action handled by revivePlayer.
|
|
if (state.health <= 0) return 0;
|
|
const modified = resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
"taken",
|
|
);
|
|
const healed = Math.min(Math.max(0, Math.round(modified)), state.maxHealth - state.health);
|
|
const procResult = evaluateAuraProcs(state.auras, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healed,
|
|
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
|
set({
|
|
auras: procResult.auras,
|
|
health: state.health + healed,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "healing",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
rawAmount: Math.max(0, Math.round(modified)),
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, Math.round(modified) - healed),
|
|
absorbed: 0,
|
|
critical: false,
|
|
}),
|
|
feedback: nextFeedback("heal", `${healed} health restored.`, { amount: healed }),
|
|
});
|
|
return healed;
|
|
},
|
|
|
|
revivePlayer: (percentMaxHealth = 0.35) => {
|
|
const state = get();
|
|
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
|
const restored = Math.max(1, Math.round(
|
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
|
));
|
|
const playerActor = state.actors[PLAYER_AURA_ENTITY_ID];
|
|
set({
|
|
health: restored,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
actors: playerActor ? { ...state.actors, [PLAYER_AURA_ENTITY_ID]: {
|
|
...playerActor, health: restored, alive: true, controlledUntil: 0, cast: null,
|
|
} } : state.actors,
|
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
|
});
|
|
return restored;
|
|
},
|
|
|
|
respawnPlayerOnly: (position, now = combatNow()) => {
|
|
const state = get();
|
|
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|
|
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
|
|
const auras = syncPassiveAuras(state.auras.filter((aura) => (
|
|
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
|
|
)), state.abilities, state.level, state.talentRanks, now);
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
|
|
state.gearStats, state.raceId, state.secondaryClassId, auras);
|
|
const patch: Partial<CombatState> = {
|
|
health: maxHealth,
|
|
maxHealth,
|
|
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
|
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
|
type, type === resourceProfileForClass(state.classId).type
|
|
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
|
|
])),
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
schoolLockedUntil: {},
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
activeCast: null,
|
|
pendingGroundTarget: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
playerPosition: [...position],
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
|
|
auras,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
|
|
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
|
|
}),
|
|
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
|
|
};
|
|
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
|
|
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
|
|
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
|
|
return true;
|
|
},
|
|
|
|
cancelCast: (message = "Casting interrupted.") => {
|
|
const cast = get().activeCast;
|
|
if (!cast) return false;
|
|
set((state) => ({
|
|
activeCast: null,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: combatNow(),
|
|
kind: "interrupt",
|
|
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: cast.abilityId,
|
|
school: cast.school,
|
|
rawAmount: 0,
|
|
effectiveAmount: 0,
|
|
overheal: 0,
|
|
absorbed: 0,
|
|
critical: false,
|
|
outcome: "cancelled",
|
|
}),
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
|
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
|
}));
|
|
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
|
|
return true;
|
|
},
|
|
|
|
engageNearbyMobs: (now = combatNow(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let engagedGroups = 0;
|
|
for (const id of Object.keys(mobs)) {
|
|
const mob = mobs[id];
|
|
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|
|
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
|
if (!actorId) continue;
|
|
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
|
|
}
|
|
if (engagedGroups > 0) set({ mobs });
|
|
return engagedGroups;
|
|
},
|
|
|
|
resetMobForNavigation: (id, atHome, now = combatNow()) => {
|
|
const state = get();
|
|
const mob = state.mobs[id];
|
|
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
|
|
set({
|
|
mobs: { ...state.mobs, [id]: {
|
|
...mob,
|
|
engaged: false,
|
|
combatPhase: atHome ? "idle" : "returning",
|
|
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
|
|
health: mob.maxHealth,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
nextAttackAt: 0,
|
|
nextAttackIndex: 0,
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
} },
|
|
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
|
|
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
|
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let namedAuras = [...state.auras];
|
|
const incomingHits: Array<{
|
|
readonly actorId: AggroActorId;
|
|
readonly amount: number;
|
|
readonly mob: MobCombatState;
|
|
readonly attack: EnemyAttackDefinition;
|
|
readonly attackRevision: number;
|
|
readonly instantCast: boolean;
|
|
}> = [];
|
|
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
|
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
|
const memberControls: Array<{
|
|
readonly memberId: string;
|
|
readonly mechanic: EnemyControlMechanic;
|
|
readonly endsAt: number;
|
|
}> = [];
|
|
let changed = false;
|
|
|
|
for (const [id, mob] of Object.entries(state.mobs)) {
|
|
if (!mob.engaged || mob.dead) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const home = mob.homePosition ?? position;
|
|
const eligible = eligibleAggroActorIds(runtimeState, { ...mob, homePosition: home });
|
|
const threatByActor = pruneThreatTable(mob.threatByActor, eligible);
|
|
const resolution = resolveAggroTarget(
|
|
threatByActor,
|
|
eligible,
|
|
mob.targetActorId,
|
|
mob.forcedTarget,
|
|
now,
|
|
);
|
|
const targetPosition = resolution.targetActorId
|
|
? actorPosition(runtimeState, resolution.targetActorId)
|
|
: null;
|
|
if (!resolution.targetActorId || !targetPosition) {
|
|
mobs[id] = {
|
|
...mob,
|
|
engaged: false,
|
|
combatPhase: "returning",
|
|
nextAttackAt: 0,
|
|
health: mob.maxHealth,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
};
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
const activeStatuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const cannotAttack = activeStatuses.some((status) => [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(status.kind));
|
|
const silenced = activeStatuses.some((status) => status.kind === "silence");
|
|
const disarmed = activeStatuses.some((status) => status.kind === "disarm");
|
|
const rooted = activeStatuses.some((status) => status.kind === "root");
|
|
const dx = position[0] - targetPosition[0];
|
|
const dy = position[1] - targetPosition[1];
|
|
const dz = position[2] - targetPosition[2];
|
|
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
|
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
|
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
|
? { ...mob.abilityReadyAt }
|
|
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
|
const abilityCastCounts = { ...mob.abilityCastCounts };
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
|
? null
|
|
: mob.activeCast;
|
|
if (enemyActiveCast) {
|
|
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
|
|
const pendingPosition = enemyActiveCast.targetActorId
|
|
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
|
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
|
|
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|
|
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
|
|
position[2] - pendingPosition[2]) > pendingAttack.range)) {
|
|
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
|
|
enemyActiveCast.targetActorId, pendingPosition);
|
|
abilityReadyAt[pendingAttack.id] = now + 250;
|
|
enemyActiveCast = null;
|
|
}
|
|
}
|
|
let completingEnemyCast = false;
|
|
let selectedAttackIndex = -1;
|
|
let selectedAttack: EnemyAttackDefinition | null = null;
|
|
if (enemyActiveCast && now >= enemyActiveCast.completesAt) {
|
|
selectedAttackIndex = mob.attacks.findIndex((attack) => attack.id === enemyActiveCast!.attackId);
|
|
selectedAttack = selectedAttackIndex >= 0 ? mob.attacks[selectedAttackIndex] : null;
|
|
completingEnemyCast = selectedAttack !== null;
|
|
if (!selectedAttack) enemyActiveCast = null;
|
|
} else if (!enemyActiveCast && !cannotAttack) {
|
|
for (let offset = 0; offset < mob.attacks.length; offset += 1) {
|
|
const index = (mob.nextAttackIndex + offset) % mob.attacks.length;
|
|
const attack = mob.attacks[index];
|
|
const attackKind = enemyAttackKind(attack);
|
|
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
|
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
|
if (
|
|
inAbilityRange
|
|
&& !(silenced && attackKind === "spell")
|
|
&& !(disarmed && attackKind === "melee")
|
|
&& now >= (abilityReadyAt[attack.id] ?? 0)
|
|
&& now >= (schoolLockedUntil[attack.school] ?? 0)
|
|
&& (!attack.maximumCasts || (abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts)
|
|
&& enemyAbilityConditionMet(mob, mobs, attack)
|
|
) {
|
|
selectedAttackIndex = index;
|
|
selectedAttack = attack;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
|
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
|
const inRange = targetVisible && (hostileAttacks.length
|
|
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
|
|
: distanceSquared <= mob.attackRange * mob.attackRange);
|
|
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
|
|
const castTargetPosition = enemyActiveCast?.targetActorId
|
|
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
|
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|
|
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
|
|
position[2] - castTargetPosition[2]) > selectedAttack.range) {
|
|
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
|
|
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
|
|
abilityReadyAt[selectedAttack.id] = now + 250;
|
|
enemyActiveCast = null;
|
|
selectedAttack = null;
|
|
}
|
|
}
|
|
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
|
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
let attackRevision = mob.attackRevision;
|
|
let attackAnimation = mob.attackAnimation;
|
|
let nextAttackIndex = mob.nextAttackIndex;
|
|
let lastAttackId = mob.lastAttackId;
|
|
let activeAuras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const startingEnemyCast = Boolean(
|
|
selectedAttack
|
|
&& !completingEnemyCast
|
|
&& (selectedAttack.castTimeMs ?? 0) > 0,
|
|
);
|
|
if (selectedAttack && startingEnemyCast) {
|
|
const attack = selectedAttack;
|
|
const sourceTransform = getMobRuntimeTransform(id);
|
|
enemyActiveCast = {
|
|
attackId: attack.id,
|
|
name: attack.name,
|
|
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
|
school: attack.school,
|
|
interruptible: attack.interruptible !== false,
|
|
targetActorId: resolution.targetActorId,
|
|
sourcePosition: [...position],
|
|
targetPosition: [...targetPosition],
|
|
sourceYaw: sourceTransform?.yaw
|
|
?? Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
|
startedAt: now,
|
|
completesAt: now + (attack.castTimeMs ?? 0),
|
|
};
|
|
attackAnimation = "cast";
|
|
lastAttackId = attack.id;
|
|
nextAttackAt = enemyActiveCast.completesAt;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"cast-start",
|
|
now,
|
|
position,
|
|
resolution.targetActorId,
|
|
targetPosition,
|
|
);
|
|
} else if (selectedAttack && !cannotAttack) {
|
|
const attack = selectedAttack!;
|
|
const resolvedEnemyCast = enemyActiveCast;
|
|
enemyActiveCast = null;
|
|
const effect = attack.effect ?? {
|
|
kind: "damage" as const,
|
|
multiplier: attack.damageMultiplier,
|
|
};
|
|
const eligibleActors = [...eligible].sort();
|
|
const randomActor = eligibleActors.length
|
|
? eligibleActors[Math.floor(
|
|
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
|
)]
|
|
: resolution.targetActorId;
|
|
const telegraphPlacement = resolvedEnemyCast
|
|
? {
|
|
sourcePosition: resolvedEnemyCast.sourcePosition,
|
|
targetPosition: resolvedEnemyCast.targetPosition,
|
|
sourceYaw: resolvedEnemyCast.sourceYaw,
|
|
}
|
|
: {
|
|
sourcePosition: position,
|
|
targetPosition,
|
|
sourceYaw: Math.atan2(targetPosition[0] - position[0], targetPosition[2] - position[2]),
|
|
};
|
|
const unvalidatedTargets = attack.target === "nearby-party"
|
|
? eligibleActors.filter((actorId) => {
|
|
const candidate = actorPosition(runtimeState, actorId);
|
|
if (!candidate) return false;
|
|
if (attack.telegraph) {
|
|
return enemyTelegraphContainsPosition(attack.telegraph, telegraphPlacement, candidate);
|
|
}
|
|
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
|
const targetDx = candidate[0] - position[0];
|
|
const targetDy = candidate[1] - position[1];
|
|
const targetDz = candidate[2] - position[2];
|
|
return targetDx * targetDx + targetDy * targetDy + targetDz * targetDz
|
|
<= radius * radius;
|
|
})
|
|
: attack.target === "random-party"
|
|
? [randomActor]
|
|
: attack.target === "primary"
|
|
? [resolution.targetActorId]
|
|
: [];
|
|
const enemySpatial = combatSpatialActor(id);
|
|
const targets = unvalidatedTargets.filter((actorId) => {
|
|
const candidate = actorPosition(runtimeState, actorId);
|
|
if (!candidate || !combatHasLineOfSight(position, candidate)) return false;
|
|
return attack.target === "nearby-party"
|
|
|| !enemySpatial
|
|
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
|
|
});
|
|
const presentationTargetId = attack.target === "self"
|
|
? id
|
|
: targets[0] ?? resolution.targetActorId;
|
|
const presentationTargetPosition = attack.target === "self"
|
|
? position
|
|
: actorPosition(runtimeState, presentationTargetId);
|
|
const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"release",
|
|
now,
|
|
position,
|
|
presentationTargetId,
|
|
presentationTargetPosition,
|
|
instantSourceCast,
|
|
);
|
|
const effects = effect.kind === "composite" ? effect.effects : [effect];
|
|
let hasDamageResolution = false;
|
|
for (const effect of effects) {
|
|
if (effect.kind === "damage") {
|
|
hasDamageResolution = true;
|
|
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
|
|
const attackDamage = attack.runewakerFormula
|
|
? adaptRunewakerEnemyDamage({
|
|
formula: attack.runewakerFormula,
|
|
baselineDamage: mob.attackDamage,
|
|
}).amount
|
|
: baselineDamage;
|
|
for (const actorId of targets) {
|
|
incomingHits.push({
|
|
actorId,
|
|
amount: attackDamage,
|
|
mob,
|
|
attack,
|
|
attackRevision,
|
|
instantCast: instantSourceCast,
|
|
});
|
|
}
|
|
} else if (effect.kind === "leech") {
|
|
hasDamageResolution = true;
|
|
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier));
|
|
for (const actorId of targets) {
|
|
incomingHits.push({
|
|
actorId,
|
|
amount: attackDamage,
|
|
mob,
|
|
attack,
|
|
attackRevision,
|
|
instantCast: instantSourceCast,
|
|
});
|
|
}
|
|
if (targets.length) {
|
|
enemyHealing.push({
|
|
targetId: id,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * effect.healMultiplier)),
|
|
});
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.amount
|
|
: 1;
|
|
const friendlyRange = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.range
|
|
: attack.range;
|
|
const friendly = Object.values(mobs)
|
|
.filter((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - requiredMissingHealth) {
|
|
return false;
|
|
}
|
|
const candidatePosition = getMobPosition(candidate.id);
|
|
if (!candidatePosition) return false;
|
|
const friendlyDx = candidatePosition[0] - position[0];
|
|
const friendlyDy = candidatePosition[1] - position[1];
|
|
const friendlyDz = candidatePosition[2] - position[2];
|
|
return friendlyDx * friendlyDx + friendlyDy * friendlyDy + friendlyDz * friendlyDz
|
|
<= friendlyRange * friendlyRange
|
|
&& combatHasLineOfSight(position, candidatePosition);
|
|
})
|
|
.sort((left, right) => (
|
|
(left.health / Math.max(1, left.maxHealth))
|
|
- (right.health / Math.max(1, right.maxHealth))
|
|
|| left.id.localeCompare(right.id)
|
|
))[0];
|
|
if (friendly) {
|
|
enemyHealing.push({
|
|
targetId: friendly.id,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * effect.multiplier)),
|
|
});
|
|
}
|
|
} else if (effect.kind === "aura") {
|
|
const endsAt = effect.durationMs > 0 ? now + effect.durationMs : 0;
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => aura.id !== effect.aura),
|
|
{
|
|
id: effect.aura,
|
|
sourceAbilityId: attack.id,
|
|
endsAt,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
...(effect.lifeStealPercent === undefined
|
|
? {}
|
|
: { lifeStealPercent: effect.lifeStealPercent }),
|
|
...(effect.meleeAutoCriticalProc === undefined
|
|
? {}
|
|
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
|
|
},
|
|
];
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy:${id}:${effect.aura}`,
|
|
name: `${attack.name}: ${effect.aura}`,
|
|
disposition: "buff",
|
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
|
dispelCategory: effect.aura === "enrage" ? "enrage" : "magic",
|
|
...(effect.damageTakenMultiplier === undefined
|
|
? {}
|
|
: {
|
|
modifiers: [{
|
|
kind: "damage" as const,
|
|
direction: "taken" as const,
|
|
operation: "percent" as const,
|
|
value: effect.damageTakenMultiplier - 1,
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}],
|
|
}),
|
|
...(effect.absorbMultiplier === undefined
|
|
? {}
|
|
: {
|
|
absorbs: [{
|
|
id: `${attack.id}:absorb`,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * effect.absorbMultiplier)),
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}],
|
|
}),
|
|
...(effect.reflectSpellChance === undefined
|
|
? {}
|
|
: { reflectSpellChance: effect.reflectSpellChance }),
|
|
},
|
|
sourceId: id,
|
|
targetId: id,
|
|
now,
|
|
}).auras];
|
|
} else if (effect.kind === "target-aura") {
|
|
for (const actorId of targets) {
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`,
|
|
name: `${attack.name}: ${effect.aura}`,
|
|
disposition: "debuff",
|
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
|
dispelCategory: "magic",
|
|
...(effect.damageTakenMultiplier === undefined
|
|
? {}
|
|
: {
|
|
modifiers: [{
|
|
kind: "damage" as const,
|
|
direction: "taken" as const,
|
|
operation: "percent" as const,
|
|
value: effect.damageTakenMultiplier - 1,
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}],
|
|
}),
|
|
},
|
|
sourceId: id,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "control") {
|
|
if ((effect.damageMultiplier ?? 0) > 0) {
|
|
hasDamageResolution = true;
|
|
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
|
|
const attackDamage = attack.runewakerFormula
|
|
? adaptRunewakerEnemyDamage({
|
|
formula: attack.runewakerFormula,
|
|
baselineDamage: mob.attackDamage,
|
|
}).amount
|
|
: baselineDamage;
|
|
for (const actorId of targets) {
|
|
incomingHits.push({
|
|
actorId,
|
|
amount: attackDamage,
|
|
mob,
|
|
attack,
|
|
attackRevision,
|
|
instantCast: instantSourceCast,
|
|
});
|
|
}
|
|
}
|
|
for (const actorId of targets) {
|
|
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
|
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
|
|
if (actorId === PLAYER_AGGRO_ID) playerControl = control;
|
|
else memberControls.push({ memberId: actorId, ...control });
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
|
name: `${attack.name}: ${effect.mechanic}`,
|
|
description: effect.mechanic === "slow"
|
|
? `${attack.name} reduces movement speed until the effect ends.`
|
|
: effect.mechanic === "root"
|
|
? `${attack.name} prevents movement until the effect ends.`
|
|
: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
|
|
disposition: "debuff",
|
|
durationMs: effect.durationMs,
|
|
dispelCategory: "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "transform") {
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => !aura.id.startsWith("form:")),
|
|
{
|
|
id: `form:${effect.form}`,
|
|
sourceAbilityId: attack.id,
|
|
endsAt: 0,
|
|
},
|
|
];
|
|
} else if (effect.kind === "pull") {
|
|
const pulledPosition: CombatPosition = [position[0], position[1], position[2]];
|
|
for (const actorId of targets) {
|
|
if (actorId === PLAYER_AGGRO_ID) {
|
|
set({ playerPosition: pulledPosition });
|
|
} else {
|
|
updatePartyRuntimePosition(
|
|
actorId,
|
|
pulledPosition[0],
|
|
pulledPosition[1],
|
|
pulledPosition[2],
|
|
);
|
|
}
|
|
}
|
|
} else if (effect.kind === "call-for-help") {
|
|
for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) {
|
|
const helper = mobs[helperId];
|
|
if (!helper || helper.dead) continue;
|
|
mobs[helperId] = {
|
|
...helper,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
threatByActor: seedActorThreat(helper.threatByActor, resolution.targetActorId),
|
|
targetActorId: helper.targetActorId ?? resolution.targetActorId,
|
|
homePosition: helper.homePosition ?? getMobPosition(helperId),
|
|
abilityReadyAt: Object.keys(helper.abilityReadyAt).length
|
|
? helper.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(helper, now),
|
|
};
|
|
}
|
|
} else if (effect.kind === "summon") {
|
|
const castNumber = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
for (let summonIndex = 0; summonIndex < effect.count; summonIndex += 1) {
|
|
const summonNamespace = effect.entityId?.trim() || `${id}:${attack.id}:summon`;
|
|
const summonId = `${summonNamespace}:${castNumber}:${summonIndex + 1}`;
|
|
if (mobs[summonId]) continue;
|
|
const angle = summonIndex * 2.399963229728653;
|
|
const summonPosition: CombatPosition = [
|
|
position[0] + Math.cos(angle) * 2.2,
|
|
position[1],
|
|
position[2] + Math.sin(angle) * 2.2,
|
|
];
|
|
setMobPosition(summonId, summonPosition);
|
|
mobs[summonId] = summonedMobCombatState(
|
|
summonId,
|
|
mob,
|
|
effect.creatureEntry,
|
|
resolution.targetActorId,
|
|
summonPosition,
|
|
effect.spawnGroupId,
|
|
);
|
|
}
|
|
} else if (effect.kind === "encounter-event") {
|
|
dispatchDungeonEncounterSignal(effect.eventId, now);
|
|
}
|
|
}
|
|
if (hasDamageResolution && targets.length) {
|
|
const lifeStealPercent = activeAuras.reduce(
|
|
(maximum, aura) => Math.max(maximum, aura.lifeStealPercent ?? 0),
|
|
0,
|
|
);
|
|
if (lifeStealPercent > 0) {
|
|
enemyHealing.push({
|
|
targetId: id,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * lifeStealPercent)),
|
|
});
|
|
}
|
|
}
|
|
if (!hasDamageResolution) {
|
|
const auraResolution = effects.some((entry) => (
|
|
entry.kind === "aura"
|
|
|| entry.kind === "target-aura"
|
|
|| entry.kind === "control"
|
|
|| entry.kind === "transform"
|
|
));
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
auraResolution ? "aura-start" : "impact",
|
|
now,
|
|
position,
|
|
presentationTargetId,
|
|
presentationTargetPosition,
|
|
instantSourceCast,
|
|
);
|
|
}
|
|
attackRevision += 1;
|
|
abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
attackAnimation = attack.animation;
|
|
nextAttackIndex = (selectedAttackIndex + 1) % mob.attacks.length;
|
|
lastAttackId = attack.id;
|
|
const slowAttack = activeStatuses.find((status) => status.kind === "attack-speed")?.magnitude ?? 0;
|
|
const cooldownRange = attack.repeatCooldownMs
|
|
?? [attack.cooldownMs, attack.cooldownMs] as const;
|
|
abilityReadyAt[attack.id] = attack.maximumCasts
|
|
&& abilityCastCounts[attack.id] >= attack.maximumCasts
|
|
? Number.POSITIVE_INFINITY
|
|
: now + Math.round(
|
|
deterministicEnemyCooldown(id, attack.id, attackRevision, cooldownRange) * (1 + slowAttack),
|
|
);
|
|
nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
}
|
|
if (enemyActiveCast) nextAttackAt = enemyActiveCast.completesAt;
|
|
if (
|
|
phase !== mob.combatPhase
|
|
|| nextAttackAt !== mob.nextAttackAt
|
|
|| attackRevision !== mob.attackRevision
|
|
|| attackAnimation !== mob.attackAnimation
|
|
|| nextAttackIndex !== mob.nextAttackIndex
|
|
|| lastAttackId !== mob.lastAttackId
|
|
|| activeStatuses.length !== mob.statuses.length
|
|
|| activeAuras.length !== mob.auras.length
|
|
|| Object.entries(abilityReadyAt).some(([abilityId, readyAt]) => (
|
|
mob.abilityReadyAt[abilityId] !== readyAt
|
|
))
|
|
|| Object.entries(abilityCastCounts).some(([abilityId, count]) => (
|
|
mob.abilityCastCounts[abilityId] !== count
|
|
))
|
|
|| enemyActiveCast !== mob.activeCast
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| Object.entries(schoolLockedUntil).some(([school, lockedUntil]) => (
|
|
mob.schoolLockedUntil[school as EnemyDamageSchool] !== lockedUntil
|
|
))
|
|
|| threatByActor !== mob.threatByActor
|
|
|| resolution.targetActorId !== mob.targetActorId
|
|
|| resolution.forcedTarget !== mob.forcedTarget
|
|
) {
|
|
mobs[id] = {
|
|
...mob,
|
|
combatPhase: phase,
|
|
nextAttackAt,
|
|
abilityReadyAt,
|
|
abilityCastCounts,
|
|
activeCast: enemyActiveCast,
|
|
schoolLockedUntil,
|
|
attackRevision,
|
|
attackAnimation,
|
|
nextAttackIndex,
|
|
lastAttackId,
|
|
statuses: activeStatuses,
|
|
auras: activeAuras,
|
|
threatByActor,
|
|
targetActorId: resolution.targetActorId,
|
|
forcedTarget: resolution.forcedTarget,
|
|
homePosition: home,
|
|
};
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
for (const healing of enemyHealing) {
|
|
const target = mobs[healing.targetId];
|
|
if (!target || target.dead) continue;
|
|
mobs[healing.targetId] = {
|
|
...target,
|
|
health: Math.min(target.maxHealth, target.health + healing.amount),
|
|
};
|
|
changed = true;
|
|
}
|
|
if (changed || playerControl) {
|
|
set({
|
|
mobs,
|
|
auras: namedAuras,
|
|
...(playerControl
|
|
? {
|
|
controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt),
|
|
controlMechanic: playerControl.mechanic,
|
|
schoolLockedUntil: state.activeCast && playerControl.mechanic === "silence"
|
|
? {
|
|
...state.schoolLockedUntil,
|
|
[state.activeCast.school]: Math.max(state.schoolLockedUntil[state.activeCast.school] ?? 0, playerControl.endsAt),
|
|
}
|
|
: state.schoolLockedUntil,
|
|
activeCast: null,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
let appliedDamage = 0;
|
|
for (const hit of incomingHits) {
|
|
const impactOrigin = getMobPosition(hit.mob.id);
|
|
const impactTarget = actorPosition(get(), hit.actorId);
|
|
if (impactOrigin && impactTarget) {
|
|
emitEnemyAttackPresentation(
|
|
hit.mob,
|
|
hit.attack,
|
|
"impact",
|
|
now,
|
|
impactOrigin,
|
|
hit.actorId,
|
|
impactTarget,
|
|
hit.instantCast,
|
|
);
|
|
}
|
|
const defendingAuras = aurasForEntity(get().auras, hit.actorId);
|
|
const reflectChance = enemyAttackKind(hit.attack) === "spell"
|
|
? Math.min(1, defendingAuras.reduce((chance, aura) => Math.max(chance, aura.definition.reflectSpellChance ?? 0), 0))
|
|
: 0;
|
|
if (reflectChance > 0 && stableCombatUnit(`${hit.actorId}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:reflect`) < reflectChance) {
|
|
get().damageMob(
|
|
hit.mob.id,
|
|
hit.amount,
|
|
now,
|
|
playerThreatSource(get(), `reflect:${hit.attack.id}`),
|
|
hit.attack.school,
|
|
);
|
|
continue;
|
|
}
|
|
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
|
|
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
|
|
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
|
|
const commitMeleeAutoProc = (landed: boolean): void => {
|
|
const current = get();
|
|
const currentMob = current.mobs[hit.mob.id];
|
|
if (!currentMob) return;
|
|
const auras = advanceEnemyMeleeAutoProcAuras(
|
|
currentMob,
|
|
hit.attack,
|
|
now,
|
|
hit.attackRevision,
|
|
landed,
|
|
meleeCriticalCharge?.id,
|
|
);
|
|
if (auras === currentMob.auras) return;
|
|
set({
|
|
mobs: {
|
|
...current.mobs,
|
|
[currentMob.id]: { ...currentMob, auras },
|
|
},
|
|
});
|
|
};
|
|
if (hit.actorId === PLAYER_AGGRO_ID) {
|
|
const current = get();
|
|
const defender = applyAuraStats(
|
|
playerCharacterStats(
|
|
current.classId,
|
|
current.level,
|
|
current.gearStats,
|
|
current.raceId,
|
|
current.secondaryClassId,
|
|
current.talentModifiers,
|
|
),
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
bonusCriticalChancePercent,
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
commitMeleeAutoProc(combatResult.landed);
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(current.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(current.health, namedAbsorb.remainingDamage - absorbed);
|
|
const health = current.health - healthDamage;
|
|
const rage = gainRomRage(
|
|
current.classId,
|
|
current.secondaryClassId,
|
|
healthDamage > 0 && (
|
|
hasRomWarriorRagePool(current.classId, current.secondaryClassId)
|
|
|| (!hasRomRagePool(current.classId, current.secondaryClassId) && current.maxResourcePools.rage !== undefined)
|
|
)
|
|
? RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
current.resource,
|
|
current.maxResource,
|
|
{ ...current.resourcePools },
|
|
{ ...current.maxResourcePools },
|
|
);
|
|
const avoidTrigger = combatResult.dodged
|
|
? "dodge"
|
|
: combatResult.parried
|
|
? "parry"
|
|
: combatResult.blocked
|
|
? "block"
|
|
: null;
|
|
const procEvent: ProcEvent | null = avoidTrigger
|
|
? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id }
|
|
: healthDamage > 0
|
|
? {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: hit.mob.id,
|
|
abilityId: hit.attack.id,
|
|
amount: healthDamage,
|
|
school: hit.attack.school,
|
|
attackKind: enemyAttackKind(hit.attack),
|
|
}
|
|
: null;
|
|
const procProgression = progressionFromState(current);
|
|
procProgression.health = health;
|
|
const procWork: MutableCastWork = {
|
|
classId: current.classId,
|
|
abilities: current.abilities,
|
|
mobs: { ...get().mobs },
|
|
effects: [...current.effects],
|
|
auras: [...namedAbsorb.auras],
|
|
summons: [...current.summons],
|
|
areaEffects: [...current.areaEffects],
|
|
combatEvents: [...current.combatEvents],
|
|
actors: { ...current.actors },
|
|
cooldowns: { ...current.cooldowns },
|
|
health,
|
|
maxHealth: current.maxHealth,
|
|
shield: current.shield - absorbed,
|
|
shieldExpiresAt: current.shieldExpiresAt,
|
|
resource: rage.resource,
|
|
maxResource: current.maxResource,
|
|
resourcePools: { ...rage.resourcePools },
|
|
maxResourcePools: { ...current.maxResourcePools },
|
|
lastResourceSpentAt: { ...current.lastResourceSpentAt },
|
|
comboPoints: current.comboPoints,
|
|
comboTargetId: current.comboTargetId,
|
|
deathKnightRunes: current.deathKnightRunes,
|
|
progression: procProgression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: current.talentRanks,
|
|
talentModifiers: current.talentModifiers,
|
|
gearStats: current.gearStats,
|
|
raceId: current.raceId,
|
|
secondaryClassId: current.secondaryClassId,
|
|
};
|
|
if (healthDamage > 0) {
|
|
procWork.auras = procWork.auras.filter((aura) => !(
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("stealth")
|
|
));
|
|
}
|
|
procWork.auras = consumeInnerFireCharges(
|
|
procWork.auras, PLAYER_AURA_ENTITY_ID, combatResult.landed, enemyAttackKind(hit.attack),
|
|
);
|
|
if (procEvent) evaluateProcsInWork(procWork, procEvent, now);
|
|
recordCombatEventInWork(procWork, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: combatResult.rawDamage,
|
|
effectiveAmount: healthDamage,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed + absorbed,
|
|
critical: combatResult.critical,
|
|
outcome: combatResult.outcome,
|
|
});
|
|
set({
|
|
auras: procWork.auras,
|
|
mobs: procWork.mobs,
|
|
shield: procWork.shield,
|
|
health: procWork.health,
|
|
resource: procWork.resource,
|
|
resourcePools: procWork.resourcePools,
|
|
cooldowns: procWork.cooldowns,
|
|
combatEvents: procWork.combatEvents,
|
|
actors: procWork.actors,
|
|
lastCombatResult: combatResult,
|
|
activeCast: procWork.health <= 0
|
|
? null
|
|
: healthDamage > 0
|
|
? applyWrathCastPushback(current.activeCast, now, `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`)
|
|
: current.activeCast,
|
|
autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId,
|
|
nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound")
|
|
: current.playerAnimationEvent,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
procWork.health <= 0
|
|
? "You have been defeated."
|
|
: `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)}.`,
|
|
{ amount: healthDamage, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
appliedDamage += healthDamage;
|
|
} else {
|
|
const partyState = usePartyStore.getState();
|
|
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
|
|
if (summon) {
|
|
const current = get();
|
|
const critical = bonusCriticalChancePercent > 0;
|
|
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
|
commitMeleeAutoProc(true);
|
|
const adjusted = resolveAuraDamageValue(
|
|
incomingAmount,
|
|
aurasForEntity(current.auras, summon.id),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const absorbed = absorbAuraDamage(current.auras, {
|
|
targetId: summon.id,
|
|
amount: adjusted,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
set({
|
|
auras: absorbed.auras,
|
|
summons: current.summons.flatMap((candidate) => candidate.id !== summon.id
|
|
? [candidate]
|
|
: candidate.health - applied > 0
|
|
? [{ ...candidate, health: candidate.health - applied }]
|
|
: []),
|
|
combatEvents: recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: summon.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: incomingAmount,
|
|
effectiveAmount: applied,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical,
|
|
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
|
|
}),
|
|
});
|
|
appliedDamage += applied;
|
|
continue;
|
|
}
|
|
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
|
|
if (onlineActor) {
|
|
const current = get();
|
|
const critical = bonusCriticalChancePercent > 0;
|
|
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
|
commitMeleeAutoProc(true);
|
|
const adjusted = resolveAuraDamageValue(
|
|
incomingAmount,
|
|
aurasForEntity(current.auras, onlineActor.id),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const absorbed = absorbAuraDamage(current.auras, {
|
|
targetId: onlineActor.id,
|
|
amount: adjusted,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage)));
|
|
const nextActor: CombatActorState = {
|
|
...onlineActor,
|
|
health: onlineActor.health - damaged,
|
|
alive: onlineActor.health - damaged > 0,
|
|
cast: onlineActor.health - damaged > 0 ? onlineActor.cast : null,
|
|
};
|
|
set({
|
|
actors: { ...current.actors, [nextActor.id]: nextActor },
|
|
auras: absorbed.auras,
|
|
combatEvents: recordCombatEvent(current.combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: onlineActor.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: incomingAmount,
|
|
effectiveAmount: damaged,
|
|
overheal: 0,
|
|
absorbed: absorbed.absorbed,
|
|
critical,
|
|
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
|
|
}),
|
|
});
|
|
appliedDamage += damaged;
|
|
continue;
|
|
}
|
|
const storedMember = partyState.members.find((candidate) => candidate.id === hit.actorId);
|
|
if (!storedMember) continue;
|
|
const currentBeforeHit = get();
|
|
const authoritativeActor = companionActorFromMember(
|
|
storedMember,
|
|
entityAbsorbAmount(currentBeforeHit.auras, storedMember.id),
|
|
currentBeforeHit.actors[storedMember.id],
|
|
);
|
|
const member = memberFromCompanionActor(storedMember, authoritativeActor);
|
|
if (member.health <= 0) continue;
|
|
const defender = applyAuraStats(
|
|
deriveCharacterStats(
|
|
member.classId,
|
|
member.level,
|
|
member.gearStats,
|
|
member.raceId,
|
|
),
|
|
aurasForEntity(get().auras, member.id),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
hit.amount,
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
bonusCriticalChancePercent,
|
|
aurasForEntity(get().auras, member.id),
|
|
);
|
|
commitMeleeAutoProc(combatResult.landed);
|
|
const currentAuras = get().auras;
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(currentAuras, hit.actorId),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(currentAuras, {
|
|
targetId: hit.actorId,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const redirectAura = aurasForEntity(namedAbsorb.auras, hit.actorId)
|
|
.find((aura) => (aura.definition.redirectDamagePercent ?? 0) > 0);
|
|
const redirectFraction = Math.min(1, Math.max(0, redirectAura?.definition.redirectDamagePercent ?? 0));
|
|
const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID
|
|
? Math.round(namedAbsorb.remainingDamage * redirectFraction)
|
|
: 0;
|
|
const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")
|
|
? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId)
|
|
: namedAbsorb.auras;
|
|
const damaged = Math.min(
|
|
authoritativeActor.health,
|
|
Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)),
|
|
);
|
|
let nextCast = authoritativeActor.cast;
|
|
if (damaged > 0 && nextCast && nextCast.pushbackHits < 2) {
|
|
const castingAbility = abilityById(nextCast.abilityId);
|
|
const resistance = castingAbility ? abilityPushbackResistance(castingAbility) : 0;
|
|
if (stableCombatUnit(`${member.id}:${hit.attack.id}:${hit.attackRevision}:pushback`) >= resistance) {
|
|
nextCast = {
|
|
...nextCast,
|
|
pushbackHits: nextCast.pushbackHits + 1,
|
|
completesAt: nextCast.mode === "channel"
|
|
? Math.max(now, nextCast.completesAt - nextCast.originalDurationMs * 0.25)
|
|
: nextCast.completesAt + 500,
|
|
};
|
|
}
|
|
}
|
|
const ragePool = authoritativeActor.resources.rage;
|
|
const nextActor: CombatActorState = {
|
|
...authoritativeActor,
|
|
health: authoritativeActor.health - damaged,
|
|
alive: authoritativeActor.health - damaged > 0,
|
|
cast: authoritativeActor.health - damaged > 0 ? nextCast : null,
|
|
resources: damaged > 0 && ragePool
|
|
? {
|
|
...authoritativeActor.resources,
|
|
rage: {
|
|
...ragePool,
|
|
current: Math.min(ragePool.maximum, ragePool.current + RAGE_PER_HIT_TAKEN),
|
|
},
|
|
}
|
|
: authoritativeActor.resources,
|
|
};
|
|
mirrorCompanionActor(nextActor);
|
|
if (redirected > 0) {
|
|
const player = get();
|
|
const transferred = Math.min(player.health, redirected);
|
|
set({ health: player.health - transferred, activeCast: player.health - transferred <= 0 ? null : player.activeCast });
|
|
appliedDamage += transferred;
|
|
}
|
|
appliedDamage += damaged;
|
|
set({
|
|
actors: { ...get().actors, [nextActor.id]: nextActor },
|
|
auras: consumeInnerFireCharges(
|
|
aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack),
|
|
),
|
|
combatEvents: recordCombatEvent(get().combatEvents, {
|
|
occurredAt: now,
|
|
kind: "damage",
|
|
sourceActorId: hit.mob.id,
|
|
targetActorId: member.id,
|
|
abilityId: hit.attack.id,
|
|
school: hit.attack.school,
|
|
rawAmount: combatResult.rawDamage,
|
|
effectiveAmount: damaged,
|
|
overheal: 0,
|
|
absorbed: namedAbsorb.absorbed,
|
|
critical: combatResult.critical,
|
|
outcome: combatResult.outcome,
|
|
}),
|
|
lastCombatResult: combatResult,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
`${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)} against ${member.name}.`,
|
|
{ amount: damaged, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
}
|
|
}
|
|
for (const control of memberControls) {
|
|
const current = get();
|
|
const summon = current.summons.find((candidate) => candidate.id === control.memberId);
|
|
if (summon) {
|
|
const nextSummon = {
|
|
...summon,
|
|
controlledUntil: Math.max(summon.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
schoolLockedUntil: control.mechanic === "silence" && summon.cast
|
|
? {
|
|
...summon.schoolLockedUntil,
|
|
[summon.cast.school]: Math.max(summon.schoolLockedUntil[summon.cast.school] ?? 0, control.endsAt),
|
|
}
|
|
: summon.schoolLockedUntil,
|
|
};
|
|
set({
|
|
summons: current.summons.map((candidate) => candidate.id === summon.id ? nextSummon : candidate),
|
|
actors: {
|
|
...current.actors,
|
|
[summon.id]: combatActorFromSummon(nextSummon, current.level, current.auras),
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
const partyState = usePartyStore.getState();
|
|
const storedMember = partyState.members.find((member) => member.id === control.memberId);
|
|
if (!storedMember) {
|
|
const onlineActor = onlinePlayerRuntimeActor(control.memberId);
|
|
if (onlineActor) {
|
|
set({
|
|
actors: {
|
|
...current.actors,
|
|
[onlineActor.id]: {
|
|
...onlineActor,
|
|
controlledUntil: Math.max(onlineActor.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const actor = companionActorFromMember(
|
|
storedMember,
|
|
entityAbsorbAmount(current.auras, storedMember.id),
|
|
current.actors[storedMember.id],
|
|
);
|
|
const nextActor: CombatActorState = {
|
|
...actor,
|
|
controlledUntil: Math.max(actor.controlledUntil, control.endsAt),
|
|
cast: null,
|
|
schoolLockedUntil: control.mechanic === "silence" && actor.cast
|
|
? {
|
|
...actor.schoolLockedUntil,
|
|
[actor.cast.school]: Math.max(actor.schoolLockedUntil[actor.cast.school] ?? 0, control.endsAt),
|
|
}
|
|
: actor.schoolLockedUntil,
|
|
};
|
|
set({ actors: { ...current.actors, [nextActor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
partyState.updateMemberCombat(control.memberId, {
|
|
controlMechanic: control.mechanic,
|
|
});
|
|
}
|
|
return appliedDamage;
|
|
},
|
|
|
|
tick: (deltaSeconds, now = combatNow()) => {
|
|
const partySelfDrainTicks: Array<{ memberId: string; amount: number; sourceAbilityId: string }> = [];
|
|
const partyHotTicks: Array<{
|
|
memberId: string;
|
|
amount: number;
|
|
sourceActorId: AggroActorId;
|
|
sourceAbilityId: string;
|
|
}> = [];
|
|
const shadowfiendManaRestores: Array<{ ownerActorId: string; summonId: string; abilityId: string }> = [];
|
|
set((state) => {
|
|
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
|
const resourceProfile = resourceProfileForClass(state.classId);
|
|
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
|
|
const auraExpiration = expireAuras(state.auras, now);
|
|
const activeAuras = [...auraExpiration.active];
|
|
const baseRegeneration = applyTalentModifierValue(
|
|
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
|
|
? 0
|
|
: resourceProfile.regenerationPerSecond
|
|
+ state.gearStats.mp5 / 5
|
|
+ state.gearStats.spirit * 0.02,
|
|
state.talentModifiers,
|
|
"resource-regeneration-percent",
|
|
);
|
|
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
|
|
const regeneration = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
|
playerAuras,
|
|
"power",
|
|
"regeneration",
|
|
);
|
|
const activePool = advanceCombatResourcePool(normalizeActorResourcePool(
|
|
resourceProfile.type,
|
|
state.resource,
|
|
state.maxResource,
|
|
regeneration,
|
|
state.lastResourceSpentAt[resourceProfile.type] ?? 0,
|
|
), {
|
|
inCombat,
|
|
actorLevel: state.level,
|
|
now,
|
|
deltaSeconds: delta,
|
|
manaSpiritRegenerationPerSecond: state.gearStats.spirit * 0.02,
|
|
});
|
|
let resource = activePool.current;
|
|
const resourcePools: Partial<Record<ResourceType, number>> = { ...state.resourcePools };
|
|
const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId);
|
|
const passiveRates: Partial<Record<ResourceType, number>> = {
|
|
mana: 4,
|
|
rage: 0,
|
|
energy: 10,
|
|
focus: 6,
|
|
"runic-power": 0,
|
|
};
|
|
for (const [type, maximum] of Object.entries(state.maxResourcePools) as [ResourceType, number][]) {
|
|
if (type === resourceProfile.type) continue;
|
|
resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
|
|
type,
|
|
resourcePools[type] ?? 0,
|
|
maximum,
|
|
passiveRates[type] ?? 0,
|
|
state.lastResourceSpentAt[type] ?? 0,
|
|
), {
|
|
inCombat,
|
|
actorLevel: state.level,
|
|
now,
|
|
deltaSeconds: delta,
|
|
manaSpiritRegenerationPerSecond: 0,
|
|
}).current;
|
|
}
|
|
resourcePools[resourceProfile.type] = resource;
|
|
let shield = state.shield;
|
|
let shieldExpiresAt = state.shieldExpiresAt;
|
|
if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) {
|
|
shield = 0;
|
|
shieldExpiresAt = 0;
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [],
|
|
auras: activeAuras,
|
|
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
|
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
|
|
combatEvents: [...state.combatEvents],
|
|
actors: { ...state.actors },
|
|
cooldowns: { ...state.cooldowns },
|
|
health: state.health <= 0 ? 0 : Math.min(
|
|
state.maxHealth,
|
|
state.health + resolveAuraStatValue(
|
|
state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01,
|
|
playerAuras,
|
|
"health-regeneration",
|
|
) * delta,
|
|
),
|
|
maxHealth: state.maxHealth,
|
|
shield,
|
|
shieldExpiresAt,
|
|
resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
lastResourceSpentAt: { ...state.lastResourceSpentAt },
|
|
comboPoints: state.comboPoints,
|
|
comboTargetId: state.comboTargetId,
|
|
deathKnightRunes: state.deathKnightRunes,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) {
|
|
applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras);
|
|
}
|
|
|
|
let autoAttackTargetId = state.autoAttackTargetId;
|
|
let nextAutoAttackAt = state.nextAutoAttackAt;
|
|
let autoAttackEvent: CharacterCombatAnimationEvent | null = null;
|
|
const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null;
|
|
if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
} else {
|
|
const autoAttackTargetPosition = getMobPosition(autoAttackTargetId!);
|
|
const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID);
|
|
const canAutoAttack = (
|
|
!state.activeCast
|
|
&& (
|
|
state.controlledUntil <= now
|
|
|| !enemyControlPreventsActions(state.controlMechanic)
|
|
)
|
|
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
|
|
&& Boolean(autoAttackTargetPosition)
|
|
&& combatHasLineOfSight(state.playerPosition, autoAttackTargetPosition ?? state.playerPosition)
|
|
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
|
|
);
|
|
if (canAutoAttack) {
|
|
const profile = playerAutoAttackProfile(state);
|
|
if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
ROM_CHAMPION_RAGE_PER_SECOND * profile.hasteMultiplier * delta,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (now >= nextAutoAttackAt) {
|
|
const offHandId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.["off-hand"];
|
|
const isDualWielding = Boolean(
|
|
offHandId && state.inventory.some((item) => item.instanceId === offHandId && item.weaponType),
|
|
);
|
|
const autoAttackSchool = state.gearStats.meleeWeapon?.damageSchool ?? "physical";
|
|
const result = resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
autoAttackTargetId!,
|
|
profile.damage,
|
|
now,
|
|
playerThreatSource(state),
|
|
"melee",
|
|
autoAttackSchool,
|
|
`${PLAYER_AGGRO_ID}:auto:${autoAttackTargetId}:${now}`,
|
|
undefined,
|
|
false,
|
|
{ isAutoAttack: true, isDualWielding },
|
|
);
|
|
if (result.amount > 0 && (
|
|
hasNativeRomWarriorRage
|
|
|| (!hasRomRagePool(state.classId, state.secondaryClassId) && work.maxResourcePools.rage !== undefined)
|
|
)) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
RAGE_PER_AUTO_ATTACK,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
|
|
if (result.amount > 0) {
|
|
const targetPosition = getMobPosition(autoAttackTargetId!);
|
|
emitCombatPresentation({
|
|
occurredAt: now,
|
|
phase: "release",
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: autoAttackTargetId!,
|
|
abilityId: "player-auto-attack",
|
|
name: "Auto Attack",
|
|
source: "fallback",
|
|
school: autoAttackSchool,
|
|
delivery: "melee",
|
|
origin: state.playerPosition,
|
|
targetPosition: targetPosition ?? state.playerPosition,
|
|
});
|
|
}
|
|
nextAutoAttackAt = now + profile.intervalMs;
|
|
if (result.killed) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const timedEffectTargetAlive = (effect: TimedCombatEffect): boolean => {
|
|
if (effect.kind === "dot") {
|
|
if (effect.tag === "rom-self-health-drain") {
|
|
const casterBuffActive = work.auras.some((aura) => aura.targetId === effect.targetId
|
|
&& aura.sourceId === effect.sourceActorId
|
|
&& aura.definition.utilityTags?.includes("rom-self-health-drain"));
|
|
if (!casterBuffActive) return false;
|
|
if (effect.targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
|
return Boolean(effect.targetId && work.actors[effect.targetId]?.alive);
|
|
}
|
|
const mob = effect.targetId ? work.mobs[effect.targetId] : null;
|
|
return Boolean(mob && !mob.dead && mob.health > 0);
|
|
}
|
|
const targetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
if (targetId === PLAYER_AURA_ENTITY_ID) return work.health > 0;
|
|
const summon = work.summons.find((candidate) => candidate.id === targetId);
|
|
if (summon) return summon.health > 0;
|
|
const actor = ensureCompanionActorInWork(work, targetId) ?? work.actors[targetId];
|
|
return Boolean(actor?.alive && actor.health > 0);
|
|
};
|
|
for (const effect of state.effects) {
|
|
// Check before due ticks/procs, not only after applying their health changes.
|
|
if (!timedEffectTargetAlive(effect)) continue;
|
|
let remainingTicks = effect.remainingTicks;
|
|
let nextTickAt = effect.nextTickAt;
|
|
while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) {
|
|
const ability = abilityById(effect.sourceAbilityId);
|
|
const baseAmount = effect.fixedAmount ?? (ability
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
progression.level,
|
|
state.gearStats,
|
|
state.classId,
|
|
ability,
|
|
)
|
|
: Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level))));
|
|
const amount = ability && effect.fixedAmount === undefined
|
|
? talentAdjustedAmount(
|
|
baseAmount,
|
|
state.talentModifiers,
|
|
effect.kind === "dot" ? "damage-percent" : "healing-percent",
|
|
ability,
|
|
true,
|
|
)
|
|
: baseAmount;
|
|
if (effect.kind === "dot" && effect.tag === "rom-self-health-drain" && effect.targetId) {
|
|
if (effect.targetId === PLAYER_AURA_ENTITY_ID) {
|
|
const drained = Math.min(work.health, amount);
|
|
work.health -= drained;
|
|
work.progression.health = work.health;
|
|
work.totalDamage += drained;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: nextTickAt, kind: "damage", sourceActorId: effect.sourceActorId,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: effect.sourceAbilityId,
|
|
rawAmount: drained, effectiveAmount: drained, overheal: 0, absorbed: 0, critical: false,
|
|
});
|
|
} else {
|
|
partySelfDrainTicks.push({ memberId: effect.targetId, amount, sourceAbilityId: effect.sourceAbilityId });
|
|
}
|
|
} else if (effect.kind === "dot" && effect.targetId) {
|
|
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
|
|
const result = resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
effect.targetId,
|
|
amount,
|
|
nextTickAt,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
dotAttackKind,
|
|
effect.damageSchool ?? "physical",
|
|
`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}`,
|
|
ability ?? undefined,
|
|
true,
|
|
{
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
},
|
|
);
|
|
grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt);
|
|
} else if (effect.kind === "hot") {
|
|
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const modifiedHealing = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(work.auras, effect.sourceActorId),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, hotTargetId),
|
|
"taken",
|
|
));
|
|
if (effect.targetId) {
|
|
const target = ensureCompanionActorInWork(work, hotTargetId)
|
|
?? work.actors[hotTargetId]
|
|
?? (work.summons.some((summon) => summon.id === hotTargetId)
|
|
? combatActorFromSummon(work.summons.find((summon) => summon.id === hotTargetId)!, progression.level, work.auras)
|
|
: null);
|
|
if (target?.alive) partyHotTicks.push({
|
|
memberId: target.id,
|
|
amount: modifiedHealing,
|
|
sourceActorId: effect.sourceActorId,
|
|
sourceAbilityId: effect.sourceAbilityId,
|
|
});
|
|
} else {
|
|
const healed = healPlayerInWork(work, modifiedHealing);
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(
|
|
work,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
evaluateProcsInWork(work, {
|
|
trigger: "periodic",
|
|
subjectId: effect.sourceActorId,
|
|
otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: effect.sourceAbilityId,
|
|
amount,
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}, nextTickAt);
|
|
if (ability) {
|
|
const sourceActorId = effect.sourceActorId;
|
|
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
|
|
const origin = sourceActorId === PLAYER_AGGRO_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
|
|
const targetPosition = effect.targetId
|
|
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
|
|
: state.playerPosition;
|
|
emitCombatPresentation({
|
|
occurredAt: nextTickAt,
|
|
phase: "tick",
|
|
actorGroup,
|
|
sourceActorId,
|
|
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery: presentationDeliveryForAbility(ability),
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
});
|
|
}
|
|
remainingTicks -= 1;
|
|
nextTickAt += effect.intervalMs;
|
|
}
|
|
if (effect.kind === "dot" && remainingTicks === 0 && abilityById(effect.sourceAbilityId)?.name === "Living Bomb") {
|
|
detonateLivingBombInWork(work, effect, nextTickAt - effect.intervalMs);
|
|
}
|
|
if (remainingTicks > 0 && timedEffectTargetAlive(effect)) {
|
|
work.effects.push(
|
|
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
|
? effect
|
|
: { ...effect, remainingTicks, nextTickAt },
|
|
);
|
|
}
|
|
}
|
|
|
|
work.areaEffects = work.areaEffects.map((area) => {
|
|
let nextTickAt = area.nextTickAt;
|
|
const catalogAbility = abilityById(area.sourceAbilityId);
|
|
const ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level) : null;
|
|
while (ability && nextTickAt <= now && nextTickAt < area.expiresAt) {
|
|
const resolvedFixedKinds = new Set<"dot" | "hot">();
|
|
for (const periodic of ability.effects) {
|
|
if (periodic.kind !== "dot" && periodic.kind !== "hot") continue;
|
|
const fixedAmount = periodic.kind === "dot" ? area.fixedDamagePerTick : area.fixedHealingPerTick;
|
|
if (fixedAmount !== undefined && resolvedFixedKinds.has(periodic.kind)) continue;
|
|
if (fixedAmount !== undefined) resolvedFixedKinds.add(periodic.kind);
|
|
const amount = fixedAmount ?? talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(periodic, state.level, state.gearStats, state.classId, ability),
|
|
state.talentModifiers,
|
|
periodic.kind === "dot" ? "damage-percent" : "healing-percent",
|
|
ability,
|
|
true,
|
|
);
|
|
if (periodic.kind === "dot") {
|
|
for (const id of nearbyMobRuntimeIds(area.position, area.radius)) {
|
|
const position = getMobPosition(id);
|
|
if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue;
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
nextTickAt,
|
|
threatSourceForActor(state, area.sourceActorId, ability.id, true),
|
|
abilityAttackKind(state.classId, ability),
|
|
abilityDamageSchool(state.classId, ability),
|
|
`${area.id}:${id}:${nextTickAt}`,
|
|
ability,
|
|
true,
|
|
{ canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
|
|
);
|
|
}
|
|
} else {
|
|
const partyMembers = usePartyStore.getState().members;
|
|
for (const friendlyId of [PLAYER_AURA_ENTITY_ID, ...partyMembers.map((member) => member.id)]) {
|
|
const position = friendlyId === PLAYER_AURA_ENTITY_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(friendlyId);
|
|
if (!position
|
|
|| Math.sqrt(distanceSquared(area.position, position)) > area.radius
|
|
|| !combatHasLineOfSight(area.position, position)) continue;
|
|
const member = partyMembers.find((candidate) => candidate.id === friendlyId);
|
|
if (member ? member.health <= 0 : work.health <= 0) continue;
|
|
const healed = member
|
|
? healCompanionActorInWork(work, friendlyId, amount)
|
|
: healPlayerInWork(work, amount);
|
|
work.totalHealing += healed;
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: nextTickAt,
|
|
kind: "healing",
|
|
sourceActorId: area.sourceActorId,
|
|
targetActorId: friendlyId,
|
|
abilityId: ability.id,
|
|
rawAmount: amount,
|
|
effectiveAmount: healed,
|
|
overheal: Math.max(0, amount - healed),
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
nextTickAt += area.tickIntervalMs;
|
|
}
|
|
return nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt };
|
|
});
|
|
|
|
work.summons = work.summons.map((summon) => {
|
|
if (
|
|
summon.health <= 0
|
|
|| summon.controlledUntil > now
|
|
|| summon.cast
|
|
|| summon.attackDamage <= 0
|
|
) return summon;
|
|
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
|
|
const summonAbility = abilityById(summon.sourceAbilityId);
|
|
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
|
if (!readyToAct) return summon;
|
|
const partyMembers = usePartyStore.getState().members;
|
|
const injured = [
|
|
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
|
|
...partyMembers.map((member) => ({
|
|
id: member.id,
|
|
health: member.health,
|
|
maxHealth: member.maxHealth,
|
|
position: getPartyRuntimePosition(member.id),
|
|
})),
|
|
].filter((candidate) => candidate.health > 0
|
|
&& candidate.health < candidate.maxHealth
|
|
&& candidate.position
|
|
&& Math.sqrt(distanceSquared(summon.position, candidate.position)) <= 30
|
|
&& combatHasLineOfSight(summon.position, candidate.position))
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
|
|
const target = injured[0];
|
|
if (target) {
|
|
const healed = target.id === PLAYER_AURA_ENTITY_ID
|
|
? healPlayerInWork(work, summon.attackDamage)
|
|
: healCompanionActorInWork(work, target.id, summon.attackDamage);
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, { actorId: summon.ownerActorId, role: "healer", abilityId: summon.sourceAbilityId }, healed);
|
|
recordCombatEventInWork(work, {
|
|
occurredAt: now,
|
|
kind: "healing",
|
|
sourceActorId: summon.id,
|
|
targetActorId: target.id,
|
|
abilityId: summon.sourceAbilityId,
|
|
rawAmount: summon.attackDamage,
|
|
effectiveAmount: healed,
|
|
overheal: summon.attackDamage - healed,
|
|
absorbed: 0,
|
|
critical: false,
|
|
});
|
|
}
|
|
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
|
}
|
|
const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
|
|
? summon.targetId
|
|
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
|
|
? state.selectedTargetId
|
|
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
|
if (!targetId) return readyToAct
|
|
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
|
|
: summon;
|
|
const targetPosition = getMobPosition(targetId);
|
|
let position = summon.position;
|
|
if (targetPosition) {
|
|
const dx = targetPosition[0] - position[0];
|
|
const dz = targetPosition[2] - position[2];
|
|
const length = Math.hypot(dx, dz);
|
|
if (summon.kind !== "totem" && length > 4) {
|
|
const step = Math.min(length - 3, 4 * delta);
|
|
const requested: CombatPosition = [position[0] + dx / length * step, position[1], position[2] + dz / length * step];
|
|
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
|
}
|
|
if (
|
|
readyToAct
|
|
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
|
&& combatHasLineOfSight(position, targetPosition)
|
|
) {
|
|
const result = damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
targetId,
|
|
summon.attackDamage,
|
|
now,
|
|
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
|
|
"physical",
|
|
);
|
|
if (summonAbility?.name === "Shadowfiend" && result.amount > 0) {
|
|
shadowfiendManaRestores.push({
|
|
ownerActorId: summon.ownerActorId, summonId: summon.id, abilityId: summon.sourceAbilityId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
...summon,
|
|
targetId,
|
|
position,
|
|
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
|
|
};
|
|
});
|
|
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const statuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const auras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
const corpseExpired = mob.dead
|
|
&& !mob.despawned
|
|
&& mob.corpseDespawnAt !== null
|
|
&& now >= mob.corpseDespawnAt;
|
|
if (
|
|
statuses.length !== mob.statuses.length
|
|
|| auras.length !== mob.auras.length
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| corpseExpired
|
|
) {
|
|
work.mobs[id] = {
|
|
...mob,
|
|
statuses,
|
|
auras,
|
|
schoolLockedUntil,
|
|
activeCast: mob.dead ? null : mob.activeCast,
|
|
despawned: mob.despawned || corpseExpired,
|
|
};
|
|
}
|
|
}
|
|
|
|
const activeSummonIds = new Set(work.summons.map((summon) => summon.id));
|
|
for (const [actorId, actor] of Object.entries(work.actors)) {
|
|
if (["pet", "totem", "summon"].includes(actor.kind) && !activeSummonIds.has(actorId)) {
|
|
delete work.actors[actorId];
|
|
}
|
|
}
|
|
for (const summon of work.summons) {
|
|
work.actors[summon.id] = combatActorFromSummon(summon, progression.level, work.auras);
|
|
}
|
|
|
|
const levelled = progression.levelsGained > 0;
|
|
if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
return {
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
lastResourceSpentAt: work.lastResourceSpentAt,
|
|
deathKnightRunes: work.deathKnightRunes,
|
|
talentModifiers: work.talentModifiers,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
health: progression.levelsGained > 0 ? progression.health : work.health,
|
|
maxHealth: progression.maxHealth,
|
|
controlledUntil: state.controlledUntil > now ? state.controlledUntil : 0,
|
|
controlMechanic: state.controlledUntil > now ? state.controlMechanic : null,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
areaEffects: work.areaEffects,
|
|
combatEvents: work.combatEvents,
|
|
actors: work.actors,
|
|
cooldowns: work.cooldowns,
|
|
schoolLockedUntil: Object.fromEntries(
|
|
Object.entries(state.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
),
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId,
|
|
selectedFriendlyActorId: state.selectedFriendlyActorId && !work.actors[state.selectedFriendlyActorId]
|
|
? null
|
|
: state.selectedFriendlyActorId,
|
|
autoAttackTargetId,
|
|
nextAutoAttackAt,
|
|
playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent,
|
|
comboPoints: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? 0 : work.comboPoints,
|
|
comboTargetId: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? null : work.comboTargetId,
|
|
feedback: levelled
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: work.combatResults.length > 0
|
|
&& work.combatResults.at(-1)?.outcome !== "hit"
|
|
? nextFeedback("damage", `Attack ${combatResultDescription(work.combatResults.at(-1)!)}.`, {
|
|
amount: work.combatResults.at(-1)!.finalDamage,
|
|
})
|
|
: state.feedback,
|
|
};
|
|
});
|
|
|
|
for (const restore of shadowfiendManaRestores) {
|
|
get().restoreActorResource(
|
|
restore.ownerActorId, "mana", 1, true, restore.summonId, restore.abilityId, now,
|
|
);
|
|
}
|
|
|
|
for (const tick of partySelfDrainTicks) {
|
|
const damaged = usePartyStore.getState().damageMemberResolved(tick.memberId, tick.amount);
|
|
if (damaged > 0) {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === tick.memberId);
|
|
set((state) => ({
|
|
actors: member && state.actors[tick.memberId] ? {
|
|
...state.actors,
|
|
[tick.memberId]: companionActorWithMemberRuntime(state.actors[tick.memberId], member),
|
|
} : state.actors,
|
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
occurredAt: now, kind: "damage", sourceActorId: tick.memberId,
|
|
targetActorId: tick.memberId, abilityId: tick.sourceAbilityId,
|
|
rawAmount: damaged, effectiveAmount: damaged, overheal: 0, absorbed: 0, critical: false,
|
|
}),
|
|
}));
|
|
}
|
|
}
|
|
|
|
for (const tick of partyHotTicks) {
|
|
const healed = get().healActor(tick.memberId, tick.amount, {
|
|
sourceActorId: tick.sourceActorId,
|
|
abilityId: tick.sourceAbilityId,
|
|
amountAlreadyModified: true,
|
|
now,
|
|
}).effectiveAmount;
|
|
if (healed > 0) {
|
|
get().addHealingThreat(
|
|
threatSourceForActor(get(), tick.sourceActorId, tick.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
|
|
const cast = get().activeCast;
|
|
if (cast?.mode === "channel") {
|
|
const catalogAbility = abilityById(cast.abilityId);
|
|
const ability = catalogAbility
|
|
? learnedAbilityForBuild(catalogAbility, get().level, get().talentRanks)
|
|
: null;
|
|
let ticksCompleted = cast.ticksCompleted;
|
|
let nextTickAt = cast.nextTickAt;
|
|
while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now && nextTickAt <= cast.completesAt) {
|
|
const channelTargeting = targetingRulesForAbility(ability);
|
|
const groundChannel = channelTargeting.shape === "ground";
|
|
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 = groundChannel ? 0 : 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 = groundChannel ? !cast.groundPosition : channelHostile
|
|
? !target || target.dead || !targetPosition
|
|
: (ability.target === "friendly" || ability.target === "any") && cast.targetId
|
|
? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition
|
|
: false;
|
|
if (targetUnavailable || distance < minimumRange || (cast.targetId !== null && distance > maximumRange)) {
|
|
get().cancelCast((target?.dead || friendlyTarget?.health === 0)
|
|
? "Channel ended: the target was defeated."
|
|
: "Channel interrupted: target unavailable.");
|
|
return;
|
|
}
|
|
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
|
|
const damageAmount = channelEffects.reduce(
|
|
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
) * multiplier
|
|
: 0),
|
|
0,
|
|
);
|
|
const healingAmount = channelEffects.reduce(
|
|
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
) * multiplier
|
|
: 0),
|
|
0,
|
|
);
|
|
if (damageAmount > 0) {
|
|
const channelCenter = groundChannel
|
|
? cast.groundPosition!
|
|
: channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
|
|
const channelRadius = groundChannel ? channelTargeting.radius ?? ability.radius ?? 8 : ability.radius;
|
|
const channelTargets = channelRadius === undefined
|
|
? (cast.targetId && channelHostile ? [cast.targetId] : [])
|
|
: nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(channelRadius, ability, get().talentModifiers))
|
|
.filter((id) => !get().mobs[id]?.dead && Boolean(getMobPosition(id)
|
|
&& combatHasLineOfSight(channelCenter, getMobPosition(id)!)));
|
|
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 && !(ability.target === "any" && channelHostile)) {
|
|
const perTargetHealing = talentAdjustedAmount(
|
|
healingAmount,
|
|
get().talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
let healed = 0;
|
|
if (!channelHostile && (ability.target === "friendly" || ability.target === "any") && cast.targetId) {
|
|
const targetMember = usePartyStore.getState().members.find((member) => member.id === cast.targetId);
|
|
if (targetMember) {
|
|
const current = get();
|
|
const actor = companionActorFromMember(
|
|
targetMember,
|
|
entityAbsorbAmount(current.auras, targetMember.id),
|
|
current.actors[targetMember.id],
|
|
);
|
|
const effective = actor.alive
|
|
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
|
|
: 0;
|
|
if (effective > 0) {
|
|
const nextActor = { ...actor, health: actor.health + effective };
|
|
set({ actors: { ...current.actors, [actor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
healed += effective;
|
|
}
|
|
}
|
|
} else if (ability.name === "Divine Hymn") {
|
|
const origin = cast.origin;
|
|
const candidates = [
|
|
{ id: PLAYER_AURA_ENTITY_ID, health: get().health, maxHealth: get().maxHealth },
|
|
...usePartyStore.getState().members.flatMap((member) => {
|
|
const position = getPartyRuntimePosition(member.id);
|
|
const current = get();
|
|
const actor = companionActorFromMember(member,
|
|
entityAbsorbAmount(current.auras, member.id), current.actors[member.id]);
|
|
return position && actor?.alive && actor.health > 0
|
|
&& Math.sqrt(distanceSquared(origin, position)) <= ability.radius!
|
|
&& combatHasLineOfSight(origin, position)
|
|
? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }]
|
|
: [];
|
|
}),
|
|
].filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth)
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth
|
|
|| left.id.localeCompare(right.id))
|
|
.slice(0, 3);
|
|
for (const candidate of candidates) {
|
|
if (candidate.id === PLAYER_AURA_ENTITY_ID) healed += get().healPlayer(perTargetHealing);
|
|
else {
|
|
const current = get();
|
|
const member = usePartyStore.getState().members.find((entry) => entry.id === candidate.id);
|
|
const actor = member ? companionActorFromMember(member,
|
|
entityAbsorbAmount(current.auras, member.id), current.actors[member.id]) : null;
|
|
if (!actor?.alive) continue;
|
|
const effective = Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)));
|
|
if (effective > 0) {
|
|
const nextActor = { ...actor, health: actor.health + effective };
|
|
set({ actors: { ...current.actors, [actor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
healed += effective;
|
|
}
|
|
}
|
|
get().applyActorAura(PLAYER_AURA_ENTITY_ID, candidate.id, ability, {
|
|
kind: "apply-aura",
|
|
recipient: "ability-target",
|
|
aura: {
|
|
id: "wow335:priest:divine-hymn-healing", name: "Divine Hymn", disposition: "buff",
|
|
durationMs: 8_000, maxStacks: 1, stackBehavior: "refresh",
|
|
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03 }],
|
|
},
|
|
sourceEffectType: 6, sourceAuraType: 118, miscValue: 0, triggerSpellId: 0,
|
|
}, nextTickAt);
|
|
}
|
|
} else {
|
|
healed += get().healPlayer(perTargetHealing);
|
|
if (ability.radius !== undefined) {
|
|
for (const member of usePartyStore.getState().members) {
|
|
const current = get();
|
|
const actor = companionActorFromMember(
|
|
member,
|
|
entityAbsorbAmount(current.auras, member.id),
|
|
current.actors[member.id],
|
|
);
|
|
const effective = actor.alive
|
|
? Math.min(actor.maxHealth - actor.health, Math.max(0, Math.round(perTargetHealing)))
|
|
: 0;
|
|
if (effective <= 0) continue;
|
|
const nextActor = { ...actor, health: actor.health + effective };
|
|
set({ actors: { ...current.actors, [actor.id]: nextActor } });
|
|
mirrorCompanionActor(nextActor);
|
|
healed += effective;
|
|
}
|
|
}
|
|
}
|
|
if (healed > 0) get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
|
|
}
|
|
const drainedResource = ability.effects.reduce((total, effect) => (
|
|
effect.kind === "resource-drain" && !effect.burn ? total + effect.amount : total
|
|
), 0) / cast.totalTicks;
|
|
if (drainedResource > 0) {
|
|
const activeResource = resourceProfileForClass(get().classId).type;
|
|
const maximum = get().maxResourcePools[activeResource] ?? get().maxResource;
|
|
const amount = ability.effects.some((effect) => effect.kind === "resource-drain" && effect.percentage)
|
|
? maximum * drainedResource / 100
|
|
: drainedResource;
|
|
const pools = { ...get().resourcePools };
|
|
pools[activeResource] = clamp((pools[activeResource] ?? get().resource) + amount, 0, maximum);
|
|
set({ resource: pools[activeResource]!, resourcePools: pools });
|
|
}
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"tick",
|
|
nextTickAt,
|
|
get().playerPosition,
|
|
cast.targetId,
|
|
cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition),
|
|
);
|
|
ticksCompleted += 1;
|
|
nextTickAt = channelPulseAt(ability, cast.startedAt, cast.originalDurationMs, ticksCompleted);
|
|
}
|
|
if (get().activeCast?.id !== cast.id) return;
|
|
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
|
|
if (ability) get().castAbility(cast.abilityId, cast.origin, now, cast);
|
|
else set({ activeCast: null });
|
|
} else if (ticksCompleted !== cast.ticksCompleted) {
|
|
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
|
|
}
|
|
return;
|
|
}
|
|
if (cast && now >= cast.completesAt) {
|
|
set({ activeCast: null });
|
|
get().castAbility(cast.abilityId, cast.origin, now, cast);
|
|
}
|
|
},
|
|
|
|
resetEncounter: (options = {}) => set((state) => {
|
|
resetCombatPresentationSession();
|
|
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
|
|
for (const mob of Object.values(state.mobs)) {
|
|
if (mob.summonedBy) removeMobPosition(mob.id);
|
|
}
|
|
const mobs = Object.fromEntries(Object.entries(state.mobs)
|
|
.filter(([, mob]) => !mob.summonedBy)
|
|
.map(([id, mob]) => {
|
|
const preserveDefeated = mob.dead && (
|
|
options.preserveDefeated === true || preserveDefeatedIds.has(id)
|
|
);
|
|
return [id, {
|
|
...mob,
|
|
health: preserveDefeated ? 0 : mob.maxHealth,
|
|
dead: preserveDefeated,
|
|
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
|
|
bossCardDrop: preserveDefeated ? mob.bossCardDrop : undefined,
|
|
looted: preserveDefeated ? mob.looted : false,
|
|
despawned: preserveDefeated ? mob.despawned : false,
|
|
diedAt: preserveDefeated ? mob.diedAt : null,
|
|
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
|
|
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
|
|
statuses: [],
|
|
controlDiminishingReturns: {},
|
|
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle" as const,
|
|
homePosition: null,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
nextWoundAnimationAt: 0,
|
|
}] as const;
|
|
}));
|
|
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
auras,
|
|
);
|
|
return {
|
|
health: maxHealth,
|
|
maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
|
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
|
|
type,
|
|
type === resourceProfileForClass(state.classId).type
|
|
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource)
|
|
: 0,
|
|
])),
|
|
lastResourceSpentAt: {},
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [],
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs,
|
|
selectedTargetId: null,
|
|
selectedFriendlyActorId: null,
|
|
effects: [],
|
|
auras,
|
|
summons: [],
|
|
areaEffects: [],
|
|
pendingGroundTarget: null,
|
|
combatEvents: [],
|
|
actors: {},
|
|
lastCombatResult: null,
|
|
feedback: nextFeedback(
|
|
"success",
|
|
options.preserveDefeated || preserveDefeatedIds.size > 0
|
|
? "The current pull has been reset; defeated enemies remain cleared."
|
|
: "The encounter has been reset.",
|
|
),
|
|
};
|
|
}),
|
|
|
|
allocateTalent: (treeId) => {
|
|
const state = get();
|
|
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
|
|
if (!result.ok) {
|
|
const message = result.reason === "no-points"
|
|
? "No talent points are available."
|
|
: result.reason === "ability-essence"
|
|
? "Not enough Ability Essence."
|
|
: result.reason === "talent-essence"
|
|
? "Not enough Talent Essence."
|
|
: result.reason === "point-gate"
|
|
? "Invest the required Ability and Talent Essence in this tab first."
|
|
: result.reason === "level-locked"
|
|
? "That node requires a higher level."
|
|
: result.reason === "max-rank"
|
|
? "That talent is already at maximum rank."
|
|
: result.reason === "tier-locked"
|
|
? "Spend five points per earlier tier in this tree first."
|
|
: result.reason === "prerequisite"
|
|
? "The prerequisite talent must be completed first."
|
|
: "That talent is unavailable.";
|
|
set({ feedback: nextFeedback("error", message) });
|
|
return false;
|
|
}
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
|
|
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow());
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
auras,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
talentRanks: result.ranks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras,
|
|
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
resetTalents: () => set((state) => {
|
|
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
|
|
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow());
|
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
auras,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
return {
|
|
talentRanks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras,
|
|
actionBindings: withoutUnlearnedTalentBindings(state.actionBindings, state.abilities, talentRanks),
|
|
feedback: nextFeedback("talent", "Talent points reset."),
|
|
};
|
|
}),
|
|
|
|
setActionBinding: (layer, control, abilityId) => {
|
|
const state = get();
|
|
if (abilityId !== null && !state.abilities.some((ability) => (
|
|
ability.id === abilityId && !ability.passive
|
|
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, state.talentRanks)))
|
|
))) return false;
|
|
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
|
|
return true;
|
|
},
|
|
resetActionBindingLayer: (layer) => set((state) => ({
|
|
actionBindings: restoreActionBindingLayer(
|
|
state.actionBindings,
|
|
layer,
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
resetActionBindings: () => set((state) => ({
|
|
actionBindings: defaultActionBindings(
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
|
|
updateSettings: (patch) => set((state) => {
|
|
const settings = normalizedSettings(state.settings, patch);
|
|
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
|
|
}),
|
|
resetSettings: () => set((state) => (
|
|
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
|
|
? state
|
|
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
|
|
)),
|
|
}));
|
|
|
|
export function selectedMob(state: CombatState): MobCombatState | null {
|
|
return state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null;
|
|
}
|
|
|
|
export function cooldownRemainingMs(
|
|
state: Pick<CombatState, "cooldowns">,
|
|
abilityId: string,
|
|
now = combatNow(),
|
|
): number {
|
|
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
|
}
|
|
|
|
export function globalCooldownRemainingMs(
|
|
state: Pick<CombatState, "globalCooldownEndsAt">,
|
|
now = combatNow(),
|
|
): number {
|
|
return Math.max(0, state.globalCooldownEndsAt - now);
|
|
}
|
|
|
|
let collectingBossLoot = false;
|
|
useCombatStore.subscribe((state) => {
|
|
if (collectingBossLoot) return;
|
|
if (useGameStore.getState().gameMode === "manastorm") return;
|
|
const lootableBosses = Object.values(state.mobs).filter((mob) => (
|
|
mob.boss
|
|
&& mob.dead
|
|
&& mob.hasLoot
|
|
&& !mob.looted
|
|
&& !mob.despawned
|
|
));
|
|
if (!lootableBosses.length) return;
|
|
collectingBossLoot = true;
|
|
try {
|
|
for (const boss of lootableBosses) {
|
|
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
|
|
}
|
|
} finally {
|
|
collectingBossLoot = false;
|
|
}
|
|
});
|
|
|
|
export function combatPercent(current: number, maximum: number): number {
|
|
if (maximum <= 0) return 0;
|
|
return clamp((current / maximum) * 100, 0, 100);
|
|
}
|