5252 lines
191 KiB
TypeScript
5252 lines
191 KiB
TypeScript
import { create } from "zustand";
|
|
import { useGameStore } from "./store";
|
|
import {
|
|
baseClassFor,
|
|
type ClassId,
|
|
type RaceId,
|
|
} from "../app/characterCatalog";
|
|
import {
|
|
abilitiesForClass,
|
|
abilitiesForCharacter,
|
|
abilitiesUnlockedBetweenLevels,
|
|
abilityAtLevel,
|
|
abilityById,
|
|
abilityRankForLevel,
|
|
defaultActionBarForClass,
|
|
defaultActionBarForCharacter,
|
|
isAbilityUnlocked,
|
|
resourceProfileForClass,
|
|
type AbilityAmountScaling,
|
|
type AbilityDefinition,
|
|
type AbilityEffect,
|
|
type MobStatusKind,
|
|
type ResourceType,
|
|
} from "./abilityCatalog";
|
|
import {
|
|
awardExperience,
|
|
healerManExperienceAward,
|
|
normalizeProgression,
|
|
playerCombatPower,
|
|
xpRequiredForNextLevel,
|
|
} from "./progression";
|
|
import {
|
|
runtimeAllocateTalentPoint as allocateTalentPoint,
|
|
runtimeNormalizeTalentRanks as normalizeTalentRanks,
|
|
runtimeResetTalentRanks as resetTalentRanks,
|
|
runtimeTalentNodeById as talentNodeById,
|
|
runtimeTalentPointsForLevel as talentPointsForLevel,
|
|
type RuntimeTalentRanks as TalentRanks,
|
|
} from "./talentRuntimeCatalog";
|
|
import {
|
|
applyTalentModifierValue,
|
|
parseTalentModifiers,
|
|
talentModifiersForRanks,
|
|
type TalentModifier,
|
|
type TalentModifierContext,
|
|
} from "./talentModifiers";
|
|
import {
|
|
getMobPosition,
|
|
mobRuntimeDistanceSquared,
|
|
nearbyMobRuntimeIds,
|
|
removeMobPosition,
|
|
setMobPosition,
|
|
type CombatPosition,
|
|
} from "./mobRuntimeRegistry";
|
|
import {
|
|
getPartyRuntimePosition,
|
|
partyRuntimeDistanceSquared,
|
|
updatePartyRuntimePosition,
|
|
} from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import {
|
|
actionBindingId,
|
|
defaultActionBindings,
|
|
normalizeActionBindings,
|
|
resetActionBindingLayer as restoreActionBindingLayer,
|
|
withActionBinding,
|
|
type ActionBindingControl,
|
|
type ActionBindingLayer,
|
|
type ActionBindings,
|
|
} from "./actionBindings";
|
|
import type {
|
|
CharacterCombatAnimationEvent,
|
|
CharacterCombatAnimationKind,
|
|
} from "./combatAnimation";
|
|
import {
|
|
absorbDamage as absorbAuraDamage,
|
|
applyAura as applyCombatAura,
|
|
dispelAuras as dispelCombatAuras,
|
|
evaluateAuraProcs,
|
|
expireAuras,
|
|
resolveDamageValue as resolveAuraDamageValue,
|
|
resolveHealingValue as resolveAuraHealingValue,
|
|
resolveResourceValue as resolveAuraResourceValue,
|
|
resolveStatValue as resolveAuraStatValue,
|
|
type ActiveAura,
|
|
type AuraDefinition,
|
|
type ProcEvent,
|
|
type TriggeredAuraProc,
|
|
} from "./combatAuras";
|
|
import { rollBossLoot } from "./lootCatalog";
|
|
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
|
import {
|
|
PLAYER_EQUIPMENT_OWNER_ID,
|
|
equipmentStats,
|
|
activeEquipmentStatsForClass,
|
|
equipInventoryItem,
|
|
normalizeEquipment,
|
|
unequipInventorySlot,
|
|
type EquipmentAssignments,
|
|
type EquipmentSlot,
|
|
} from "./equipment";
|
|
import {
|
|
EMPTY_ITEM_STATS,
|
|
itemStatSummary,
|
|
type DamageSchool,
|
|
type ItemStats,
|
|
} from "./itemStats";
|
|
import {
|
|
damageAfterEquipmentMitigation,
|
|
deriveCharacterStats,
|
|
equipmentAttackPower,
|
|
hastedDuration,
|
|
ratingPercent,
|
|
type CharacterStats,
|
|
type WotlkAttackKind,
|
|
} from "./wotlkStats";
|
|
import {
|
|
combatAttackerFromCharacterStats,
|
|
combatDefenderFromCharacterStats,
|
|
resolveCombatResult,
|
|
type CombatAttackerStats,
|
|
type CombatDefenderStats,
|
|
type CombatResult,
|
|
type CombatResultRules,
|
|
} from "./combatResults";
|
|
import {
|
|
DEFAULT_ENEMY_ATTACK,
|
|
maximumEnemyAttackRange,
|
|
normalizeEnemyAttacks,
|
|
type EnemyAttackAnimation,
|
|
type EnemyAttackDefinition,
|
|
type EnemyControlMechanic,
|
|
type EnemyDamageSchool,
|
|
} from "./enemyAttacks";
|
|
import {
|
|
creatureRuntimeBaseStats,
|
|
creatureTemplateReference,
|
|
type AzerothCoreMechanic,
|
|
type AzerothCoreSchool,
|
|
} from "./azerothCoreReference";
|
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
|
import {
|
|
PLAYER_AGGRO_ID,
|
|
addActorThreat,
|
|
pruneThreatTable,
|
|
resolveAggroTarget,
|
|
seedActorThreat,
|
|
tauntThreat,
|
|
threatFromDamage,
|
|
threatFromHealing,
|
|
type AggroActorId,
|
|
type ForcedTarget,
|
|
type ThreatSource,
|
|
type ThreatTable,
|
|
} from "./aggro";
|
|
import { distanceSquared, planarDistanceSquared } from "./mobAi";
|
|
import {
|
|
emitCombatPresentation,
|
|
presentationDeliveryForAbility,
|
|
presentationSchoolForAbility,
|
|
presentationSourceForAbility,
|
|
presentationSourceForSpellId,
|
|
resetCombatPresentationSession,
|
|
type CombatPresentationPhase,
|
|
} from "./combatPresentation";
|
|
|
|
/** Stable target id used by named player auras and HUD consumers. */
|
|
export const PLAYER_AURA_ENTITY_ID = PLAYER_AGGRO_ID;
|
|
import {
|
|
DEFAULT_THREAT_METER_POSITION,
|
|
normalizeHudPosition,
|
|
type NormalizedHudPosition,
|
|
} from "./hudLayout";
|
|
|
|
export type MinimapRotation = "north-up" | "player-up";
|
|
|
|
export interface GameplaySettings {
|
|
readonly showMobHealthBars: boolean;
|
|
readonly showMobAggroRanges: boolean;
|
|
readonly showTargetFrame: boolean;
|
|
readonly showThreatMeter: boolean;
|
|
readonly threatMeterPosition: NormalizedHudPosition;
|
|
readonly showFloatingCombatText: boolean;
|
|
readonly autoTargetNearest: boolean;
|
|
readonly enableScreenShake: boolean;
|
|
readonly masterVolume: number;
|
|
readonly uiScale: number;
|
|
readonly minimapRotation: MinimapRotation;
|
|
}
|
|
|
|
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
|
|
showMobHealthBars: true,
|
|
showMobAggroRanges: false,
|
|
showTargetFrame: true,
|
|
showThreatMeter: false,
|
|
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
|
|
showFloatingCombatText: true,
|
|
autoTargetNearest: true,
|
|
enableScreenShake: true,
|
|
masterVolume: 0.8,
|
|
uiScale: 1,
|
|
minimapRotation: "player-up",
|
|
});
|
|
|
|
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
|
|
export const MOB_WOUND_ANIMATION_COOLDOWN_MS = 650;
|
|
/** Empty corpses remain visible long enough to read the death, then clean themselves up. */
|
|
export const EMPTY_CORPSE_DESPAWN_MS = 10_000;
|
|
/** Looting cannot hide a corpse before its one-shot death animation has had time to land. */
|
|
export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
|
|
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
|
|
export const DEFAULT_MOB_AGGRO_RANGE = 16;
|
|
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
|
|
|
|
export interface MobStatus {
|
|
readonly kind: MobStatusKind;
|
|
readonly sourceAbilityId: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
}
|
|
|
|
export interface MobAura {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly endsAt: number;
|
|
readonly magnitude?: number;
|
|
}
|
|
|
|
export interface MobMagicResistances {
|
|
readonly arcane: number;
|
|
readonly fire: number;
|
|
readonly frost: number;
|
|
readonly nature: number;
|
|
readonly shadow: number;
|
|
}
|
|
|
|
/** Public enemy cast state used by combat logic now and cast-bar UI later. */
|
|
export interface EnemyActiveCast {
|
|
readonly attackId: string;
|
|
readonly name: string;
|
|
readonly spellId?: number;
|
|
readonly school: EnemyDamageSchool;
|
|
readonly interruptible: boolean;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
}
|
|
|
|
export interface MobCombatState {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly serverEntry: number | null;
|
|
readonly summonedBy?: string;
|
|
readonly boss: boolean;
|
|
readonly lootSourceId: string | null;
|
|
readonly level: number;
|
|
readonly armor: number;
|
|
readonly maxMana: number;
|
|
readonly attackPower: number;
|
|
readonly mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities: readonly AzerothCoreSchool[];
|
|
readonly resistances: MobMagicResistances;
|
|
readonly health: number;
|
|
readonly maxHealth: number;
|
|
readonly healthMultiplier: number;
|
|
readonly damageMultiplier: number;
|
|
readonly bonusLootChance: number;
|
|
readonly dead: boolean;
|
|
readonly hasLoot: boolean;
|
|
readonly pendingLoot: readonly InventoryItem[];
|
|
readonly looted: boolean;
|
|
readonly despawned: boolean;
|
|
readonly diedAt: number | null;
|
|
readonly corpseDespawnAt: number | null;
|
|
readonly xpReward: number;
|
|
readonly experienceGranted: boolean;
|
|
readonly statuses: readonly MobStatus[];
|
|
readonly auras: readonly MobAura[];
|
|
readonly threatByActor: ThreatTable;
|
|
readonly targetActorId: AggroActorId | null;
|
|
readonly forcedTarget: ForcedTarget | null;
|
|
readonly lastDamageAt: number;
|
|
readonly engaged: boolean;
|
|
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
|
readonly homePosition: CombatPosition | null;
|
|
readonly nextAttackAt: number;
|
|
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
|
readonly abilityCastCounts: Readonly<Record<string, number>>;
|
|
readonly activeCast: EnemyActiveCast | null;
|
|
readonly schoolLockedUntil: Readonly<Partial<Record<EnemyDamageSchool, number>>>;
|
|
readonly attackRange: number;
|
|
readonly attackDamage: number;
|
|
readonly swingMs: number;
|
|
readonly aggroRange: number;
|
|
readonly leashRange: number;
|
|
readonly moveSpeed: number;
|
|
readonly attackRevision: number;
|
|
readonly attackAnimation: EnemyAttackAnimation;
|
|
readonly attacks: readonly EnemyAttackDefinition[];
|
|
readonly nextAttackIndex: number;
|
|
readonly lastAttackId: string | null;
|
|
readonly woundRevision: number;
|
|
readonly nextWoundAnimationAt: number;
|
|
readonly deathRevision: number;
|
|
}
|
|
|
|
export interface ActiveCast {
|
|
readonly id: number;
|
|
readonly abilityId: string;
|
|
readonly targetId: string | null;
|
|
readonly mode: "cast" | "channel";
|
|
readonly startedAt: number;
|
|
readonly completesAt: number;
|
|
readonly origin: CombatPosition;
|
|
readonly totalTicks: number;
|
|
readonly ticksCompleted: number;
|
|
readonly nextTickAt: number;
|
|
}
|
|
|
|
export interface TimedCombatEffect {
|
|
readonly id: string;
|
|
readonly kind: "dot" | "hot";
|
|
readonly sourceAbilityId: string;
|
|
readonly sourceActorId: AggroActorId;
|
|
readonly targetId: string | null;
|
|
readonly coefficient: number;
|
|
readonly basePoints?: number;
|
|
readonly dieSides?: number;
|
|
readonly pointsPerLevel?: number;
|
|
readonly bonusPowerCoefficient?: number;
|
|
readonly sourceLevel?: number;
|
|
readonly maxScalingLevel?: number;
|
|
readonly remainingTicks: number;
|
|
readonly intervalMs: number;
|
|
readonly nextTickAt: number;
|
|
readonly damageSchool?: DamageSchool;
|
|
readonly spellPenetration?: number;
|
|
readonly tag?: string;
|
|
}
|
|
|
|
export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot";
|
|
|
|
export interface CombatFeedback {
|
|
readonly id: number;
|
|
readonly kind: CombatFeedbackKind;
|
|
readonly message: string;
|
|
readonly abilityId?: string;
|
|
readonly targetId?: string;
|
|
readonly amount?: number;
|
|
}
|
|
|
|
export interface BossLootReward {
|
|
readonly id: number;
|
|
readonly bossId: string;
|
|
readonly bossName: string;
|
|
readonly items: readonly InventoryItem[];
|
|
readonly acquiredAt: number;
|
|
}
|
|
|
|
export type CastFailureReason =
|
|
| "unknown-ability"
|
|
| "wrong-class"
|
|
| "not-learned"
|
|
| "passive"
|
|
| "unsupported"
|
|
| "level-locked"
|
|
| "player-dead"
|
|
| "player-controlled"
|
|
| "already-casting"
|
|
| "global-cooldown"
|
|
| "cooldown"
|
|
| "not-enough-resource"
|
|
| "no-target"
|
|
| "target-dead"
|
|
| "target-unavailable"
|
|
| "too-close"
|
|
| "out-of-range";
|
|
|
|
export interface CastResult {
|
|
readonly ok: boolean;
|
|
readonly abilityId?: string;
|
|
readonly reason?: CastFailureReason;
|
|
readonly affectedMobIds: readonly string[];
|
|
readonly damage: number;
|
|
readonly healing: number;
|
|
readonly levelsGained: number;
|
|
}
|
|
|
|
export interface CharacterCombatSeed {
|
|
readonly classId: ClassId;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly raceId?: RaceId;
|
|
readonly level?: number;
|
|
readonly xp?: number;
|
|
/** Backward-compatible profile field accepted by the integration bridge. */
|
|
readonly experience?: number;
|
|
readonly talentRanks?: TalentRanks;
|
|
readonly actionBindings?: unknown;
|
|
readonly inventory?: unknown;
|
|
readonly equipment?: unknown;
|
|
readonly settings?: Partial<GameplaySettings>;
|
|
}
|
|
|
|
export interface MobRegistration {
|
|
readonly name: string;
|
|
readonly serverEntry?: number;
|
|
readonly boss?: boolean;
|
|
readonly level?: number;
|
|
readonly resistances?: Partial<MobMagicResistances>;
|
|
readonly maxHealth?: number;
|
|
readonly xpReward?: number;
|
|
readonly healthMultiplier?: number;
|
|
readonly damageMultiplier?: number;
|
|
readonly bonusLootChance?: number;
|
|
readonly moveSpeed?: number;
|
|
readonly aggroRange?: number;
|
|
readonly leashRange?: number;
|
|
readonly attacks?: readonly EnemyAttackDefinition[];
|
|
readonly mechanicImmunities?: readonly AzerothCoreMechanic[];
|
|
readonly schoolImmunities?: readonly AzerothCoreSchool[];
|
|
readonly hasLoot?: boolean;
|
|
/** Stable SQL boss key, independent from this spawned mob's runtime id. */
|
|
readonly lootSourceId?: string;
|
|
}
|
|
|
|
export type TargetCycleDirection = "next" | "previous" | 1 | -1;
|
|
|
|
export interface DamageResult {
|
|
readonly amount: number;
|
|
readonly killed: boolean;
|
|
readonly xpAwarded: number;
|
|
readonly levelsGained: number;
|
|
readonly combatResult?: CombatResult;
|
|
}
|
|
|
|
interface PlayerDamageRollContext {
|
|
readonly attackKind: WotlkAttackKind;
|
|
readonly seed: string;
|
|
readonly abilityId?: string;
|
|
readonly periodic?: boolean;
|
|
readonly rules?: CombatResultRules;
|
|
}
|
|
|
|
export interface CombatSummon {
|
|
readonly id: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly creatureId: number;
|
|
readonly targetId: string | null;
|
|
readonly expiresAt: number;
|
|
}
|
|
|
|
export interface CombatState {
|
|
classId: ClassId;
|
|
secondaryClassId: ClassId | null;
|
|
raceId: RaceId;
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
controlledUntil: number;
|
|
controlMechanic: EnemyControlMechanic | null;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
maxResourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
|
resourceName: string;
|
|
comboPoints: number;
|
|
abilities: readonly AbilityDefinition[];
|
|
cooldowns: Readonly<Record<string, number>>;
|
|
globalCooldownEndsAt: number;
|
|
activeCast: ActiveCast | null;
|
|
playerAnimationEvent: CharacterCombatAnimationEvent | null;
|
|
autoAttackTargetId: string | null;
|
|
nextAutoAttackAt: number;
|
|
mobs: Readonly<Record<string, MobCombatState>>;
|
|
selectedTargetId: string | null;
|
|
effects: readonly TimedCombatEffect[];
|
|
/** Named buffs/debuffs for the player, mobs, and party members. */
|
|
auras: readonly ActiveAura[];
|
|
summons: readonly CombatSummon[];
|
|
feedback: CombatFeedback | null;
|
|
/** Most recent discrete attack result, retained for proc and combat-log consumers. */
|
|
lastCombatResult: CombatResult | null;
|
|
bossLootRewards: readonly BossLootReward[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
actionBindings: ActionBindings;
|
|
inventory: readonly InventoryItem[];
|
|
equipment: EquipmentAssignments;
|
|
gearStats: ItemStats;
|
|
settings: GameplaySettings;
|
|
playerPosition: CombatPosition;
|
|
initializeCharacter: (profile: CharacterCombatSeed) => void;
|
|
registerMob: (id: string, registration: MobRegistration) => void;
|
|
removeMob: (id: string) => void;
|
|
lootMob: (id: string, now?: number) => boolean;
|
|
dismissBossLootReward: (id?: number) => void;
|
|
equipItem: (instanceId: string, ownerId?: string) => boolean;
|
|
unequipItem: (ownerId: string, slot: EquipmentSlot) => boolean;
|
|
selectMob: (id: string) => boolean;
|
|
selectNearestTarget: (origin?: CombatPosition, maxRange?: number) => string | null;
|
|
cycleTarget: (direction?: TargetCycleDirection, origin?: CombatPosition, maxRange?: number) => string | null;
|
|
clearTarget: () => void;
|
|
setPlayerPosition: (position: CombatPosition) => void;
|
|
castAbility: (abilityId: string, origin?: CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
|
|
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
|
castActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, origin?: CombatPosition, now?: number) => CastResult;
|
|
damageMob: (
|
|
id: string,
|
|
amount: number,
|
|
now?: number,
|
|
source?: ThreatSource,
|
|
school?: DamageSchool,
|
|
rollContext?: PlayerDamageRollContext,
|
|
) => DamageResult;
|
|
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
|
|
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
|
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
|
healPlayer: (amount: number) => number;
|
|
revivePlayer: (percentMaxHealth?: number) => number;
|
|
cancelCast: (message?: string) => boolean;
|
|
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
|
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
|
tick: (deltaSeconds: number, now?: number) => void;
|
|
resetEncounter: () => void;
|
|
allocateTalent: (treeId: string) => boolean;
|
|
resetTalents: () => void;
|
|
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
|
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
|
|
resetActionBindings: () => void;
|
|
updateSettings: (patch: Partial<GameplaySettings>) => void;
|
|
resetSettings: () => void;
|
|
}
|
|
|
|
interface MutableProgression {
|
|
level: number;
|
|
xp: number;
|
|
xpToNext: number;
|
|
health: number;
|
|
maxHealth: number;
|
|
levelsGained: number;
|
|
}
|
|
|
|
interface MutableCastWork {
|
|
classId: ClassId;
|
|
abilities: readonly AbilityDefinition[];
|
|
mobs: Record<string, MobCombatState>;
|
|
effects: TimedCombatEffect[];
|
|
auras: ActiveAura[];
|
|
summons: CombatSummon[];
|
|
health: number;
|
|
maxHealth: number;
|
|
shield: number;
|
|
shieldExpiresAt: number;
|
|
resource: number;
|
|
maxResource: number;
|
|
resourcePools: Partial<Record<ResourceType, number>>;
|
|
maxResourcePools: Partial<Record<ResourceType, number>>;
|
|
comboPoints: number;
|
|
progression: MutableProgression;
|
|
totalDamage: number;
|
|
totalHealing: number;
|
|
combatResults: CombatResult[];
|
|
talentRanks: TalentRanks;
|
|
talentModifiers: readonly TalentModifier[];
|
|
gearStats: ItemStats;
|
|
raceId: RaceId;
|
|
secondaryClassId: ClassId | null;
|
|
}
|
|
|
|
let feedbackSequence = 0;
|
|
let bossLootRewardSequence = 0;
|
|
let effectSequence = 0;
|
|
let castSequence = 0;
|
|
let animationSequence = 0;
|
|
let summonSequence = 0;
|
|
|
|
function nextFeedback(
|
|
kind: CombatFeedbackKind,
|
|
message: string,
|
|
details: Omit<CombatFeedback, "id" | "kind" | "message"> = {},
|
|
): CombatFeedback {
|
|
feedbackSequence += 1;
|
|
return { id: feedbackSequence, kind, message, ...details };
|
|
}
|
|
|
|
function nextAnimationEvent(
|
|
kind: CharacterCombatAnimationKind,
|
|
abilityId?: string,
|
|
): CharacterCombatAnimationEvent {
|
|
animationSequence += 1;
|
|
return { revision: animationSequence, kind, ...(abilityId ? { abilityId } : {}) };
|
|
}
|
|
|
|
function emitPlayerAbilityPresentation(
|
|
ability: AbilityDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
...(targetId ? { targetActorId: targetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery: presentationDeliveryForAbility(ability),
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
|
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
|
});
|
|
}
|
|
|
|
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
|
const ability = abilityById(abilityId);
|
|
if (!ability) return;
|
|
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
|
}
|
|
|
|
function emitPlayerAbilityResolutionPresentation(
|
|
ability: AbilityDefinition,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetId: string | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
|
occurredAt,
|
|
origin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
}
|
|
|
|
function emitEnemyAttackPresentation(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
phase: CombatPresentationPhase,
|
|
occurredAt: number,
|
|
origin: CombatPosition,
|
|
targetActorId: AggroActorId | null,
|
|
targetPosition: CombatPosition | null,
|
|
): void {
|
|
emitCombatPresentation({
|
|
occurredAt,
|
|
phase,
|
|
actorGroup: "enemy",
|
|
sourceActorId: mob.id,
|
|
...(targetActorId ? { targetActorId } : {}),
|
|
abilityId: attack.id,
|
|
...(attack.spellId === undefined ? {} : { sourceSpellId: attack.spellId }),
|
|
name: attack.name,
|
|
source: presentationSourceForSpellId(attack.spellId),
|
|
school: attack.school,
|
|
delivery: attack.delivery,
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
...(attack.radius === undefined ? {} : { radius: attack.radius }),
|
|
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
|
|
});
|
|
}
|
|
|
|
function emptyCastResult(reason?: CastFailureReason, abilityId?: string): CastResult {
|
|
return { ok: !reason, abilityId, reason, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 };
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
const ROM_RAGE_CLASSES = new Set<ClassId>(["rom-warrior", "rom-champion"]);
|
|
const ROM_RAGE_PER_AUTO_ATTACK = 6;
|
|
const ROM_RAGE_PER_HIT_TAKEN = 7;
|
|
const ROM_RAGE_DECAY_PER_SECOND = 3;
|
|
const ROM_CHAMPION_RAGE_PER_SECOND = 6;
|
|
const PLAYER_AUTO_ATTACK_RANGE = 5;
|
|
|
|
function hasRomClassPool(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
resourceClassId: ClassId,
|
|
): boolean {
|
|
return classId === resourceClassId || secondaryClassId === resourceClassId;
|
|
}
|
|
|
|
function hasRomRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return ROM_RAGE_CLASSES.has(classId)
|
|
|| Boolean(secondaryClassId && ROM_RAGE_CLASSES.has(secondaryClassId));
|
|
}
|
|
|
|
function hasRomWarriorRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-warrior");
|
|
}
|
|
|
|
function hasRomChampionRagePool(classId: ClassId, secondaryClassId?: ClassId | null): boolean {
|
|
return hasRomClassPool(classId, secondaryClassId, "rom-champion");
|
|
}
|
|
|
|
function gainRomRage(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
amount: number,
|
|
resource: number,
|
|
maxResource: number,
|
|
resourcePools: Partial<Record<ResourceType, number>>,
|
|
maxResourcePools: Partial<Record<ResourceType, number>>,
|
|
): { resource: number; resourcePools: Partial<Record<ResourceType, number>> } {
|
|
if (!hasRomRagePool(classId, secondaryClassId) || amount <= 0) return { resource, resourcePools };
|
|
const activeType = resourceProfileForClass(classId).type;
|
|
const maximum = maxResourcePools.rage ?? (activeType === "rage" ? maxResource : 100);
|
|
const rage = clamp((resourcePools.rage ?? (activeType === "rage" ? resource : 0)) + amount, 0, maximum);
|
|
return {
|
|
resource: activeType === "rage" ? rage : resource,
|
|
resourcePools: { ...resourcePools, rage },
|
|
};
|
|
}
|
|
|
|
function playerAutoAttackProfile(
|
|
state: Pick<CombatState, "classId" | "secondaryClassId" | "raceId" | "level" | "gearStats">,
|
|
): { damage: number; intervalMs: number; hasteMultiplier: number } {
|
|
const stats = deriveCharacterStats(
|
|
state.classId,
|
|
state.level,
|
|
state.gearStats,
|
|
state.raceId,
|
|
{},
|
|
state.secondaryClassId,
|
|
);
|
|
const weapon = state.gearStats.meleeWeapon;
|
|
const speedMs = weapon?.speedMs ?? 2_400;
|
|
const speedSeconds = speedMs / 1_000;
|
|
const weaponDamage = weapon
|
|
? (weapon.damageMin + weapon.damageMax) / 2
|
|
: playerCombatPower(state.level) * 0.35;
|
|
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
|
|
return {
|
|
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
|
|
intervalMs,
|
|
hasteMultiplier: speedMs / intervalMs,
|
|
};
|
|
}
|
|
|
|
function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext {
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase();
|
|
const tags = new Set<string>();
|
|
tags.add(ability.castTimeMs > 0 ? "cast-time" : "instant");
|
|
if (periodic) tags.add("periodic");
|
|
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot")) tags.add("damage");
|
|
if (ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield")) tags.add("healing");
|
|
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
|
|
if (searchable.includes(school)) tags.add(school);
|
|
}
|
|
return { abilityName: ability.name, tags: [...tags] };
|
|
}
|
|
|
|
function playerThreatSource(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
const ability = abilityId ? abilityById(abilityId) : null;
|
|
const threatScale = ability
|
|
? applyTalentModifierValue(
|
|
1,
|
|
state.talentModifiers,
|
|
"threat-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: 1;
|
|
return {
|
|
actorId: PLAYER_AGGRO_ID,
|
|
role: usePartyStore.getState().playerRole,
|
|
...(abilityId ? { abilityId } : {}),
|
|
threatScale,
|
|
};
|
|
}
|
|
|
|
function threatSourceForActor(
|
|
state: Pick<CombatState, "talentModifiers">,
|
|
actorId: AggroActorId,
|
|
abilityId?: string,
|
|
periodic = false,
|
|
): ThreatSource {
|
|
if (actorId === PLAYER_AGGRO_ID) return playerThreatSource(state, abilityId, periodic);
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
|
return {
|
|
actorId,
|
|
role: member?.role ?? "damage",
|
|
...(abilityId ? { abilityId } : {}),
|
|
};
|
|
}
|
|
|
|
function actorPosition(state: Pick<CombatState, "playerPosition">, actorId: AggroActorId): CombatPosition | null {
|
|
return actorId === PLAYER_AGGRO_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(actorId);
|
|
}
|
|
|
|
function eligibleAggroActorIds(
|
|
state: Pick<CombatState, "health" | "playerPosition">,
|
|
mob: Pick<MobCombatState, "homePosition" | "leashRange">,
|
|
): Set<AggroActorId> {
|
|
const eligible = new Set<AggroActorId>();
|
|
const home = mob.homePosition;
|
|
const withinLeash = (position: CombatPosition | null) => {
|
|
if (!position) return false;
|
|
if (!home) return true;
|
|
return planarDistanceSquared(position, home) <= mob.leashRange * mob.leashRange;
|
|
};
|
|
if (state.health > 0 && withinLeash(state.playerPosition)) eligible.add(PLAYER_AGGRO_ID);
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health > 0 && withinLeash(getPartyRuntimePosition(member.id))) eligible.add(member.id);
|
|
}
|
|
return eligible;
|
|
}
|
|
|
|
function addHealingThreatInWork(
|
|
work: MutableCastWork,
|
|
source: ThreatSource,
|
|
effectiveHealing: number,
|
|
): void {
|
|
const engagedIds = Object.values(work.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
|
if (perMob <= 0) return;
|
|
for (const id of engagedIds) {
|
|
const mob = work.mobs[id];
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
}
|
|
|
|
function talentAdjustedAmount(
|
|
baseValue: number,
|
|
modifiers: readonly TalentModifier[],
|
|
kind: "damage-percent" | "healing-percent",
|
|
ability: AbilityDefinition,
|
|
periodic = false,
|
|
): number {
|
|
const context = abilityTalentContext(ability, periodic);
|
|
const scalar = applyTalentModifierValue(
|
|
baseValue,
|
|
modifiers,
|
|
kind,
|
|
context,
|
|
);
|
|
return Math.max(0, Math.round(scalar));
|
|
}
|
|
|
|
function talentAdjustedMaxHealth(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const staminaModifiers = playerModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "stamina"
|
|
));
|
|
const staminaPercent = applyTalentModifierValue(100, staminaModifiers, "attribute-percent") - 100;
|
|
const maxHealthPercent = applyTalentModifierValue(100, playerModifiers, "max-health-percent") - 100;
|
|
return Math.max(1, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
stamina: staminaPercent,
|
|
maxHealth: maxHealthPercent,
|
|
}, secondaryClassId).health);
|
|
}
|
|
|
|
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
const healing = ability.effects.some((effect) => (
|
|
effect.kind === "heal"
|
|
|| effect.kind === "hot"
|
|
|| effect.kind === "shield"
|
|
|| effect.kind === "resurrection"
|
|
));
|
|
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
|
|
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
|
|
if (healing) return "spell";
|
|
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
|
|
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
|
|
return "spell";
|
|
}
|
|
|
|
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
|
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
|
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
|
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
|
|
if (/\b(?:frost|ice|chill|blizzard)\b/.test(searchable)) return "frost";
|
|
if (/\b(?:shadow|curse|corruption|drain|haunt)\b/.test(searchable)) return "shadow";
|
|
if (/\b(?:arcane|starfire|moonfire)\b/.test(searchable)) return "arcane";
|
|
if (/\b(?:nature|lightning|wrath|insect|earth|storm)\b/.test(searchable)) return "nature";
|
|
if (classId === "paladin" || classId === "priest") return "holy";
|
|
if (classId === "warlock") return "shadow";
|
|
if (classId === "shaman" || classId === "druid") return "nature";
|
|
return "arcane";
|
|
}
|
|
|
|
function equipmentAdjustedCombatAmount(
|
|
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
classId: ClassId,
|
|
ability: AbilityDefinition,
|
|
): number {
|
|
const kind = abilityAttackKind(classId, ability);
|
|
const attackPower = equipmentAttackPower(classId, gearStats, kind);
|
|
const weapon = kind === "ranged" ? gearStats.rangedWeapon : kind === "melee" ? gearStats.meleeWeapon : null;
|
|
const weaponSpeed = weapon?.speedMs ? weapon.speedMs / 1_000 : kind === "melee" ? 2.4 : 2.8;
|
|
const weaponDamage = weapon ? (weapon.damageMin + weapon.damageMax) / 2 : 0;
|
|
const equipmentPower = kind === "spell"
|
|
? attackPower
|
|
: attackPower / 14 * weaponSpeed + weaponDamage * 0.35;
|
|
if (typeof scaling === "number" || scaling.basePoints === undefined) {
|
|
const coefficient = typeof scaling === "number" ? scaling : scaling.coefficient;
|
|
return Math.max(
|
|
0,
|
|
Math.round(coefficient * (playerCombatPower(level) + equipmentPower)),
|
|
);
|
|
}
|
|
const sourceLevel = Math.max(1, scaling.sourceLevel ?? level);
|
|
const scalingLevel = Math.min(level, scaling.maxScalingLevel || level);
|
|
const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0);
|
|
const averageRoll = Math.abs(scaling.basePoints)
|
|
+ Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2
|
|
+ levelGrowth;
|
|
// Starter-kit spells are granted early, but their raw DBC values were tuned
|
|
// for their original client level. Scale that base down to the player's
|
|
// current combat budget; once the source level is reached, use the DBC value.
|
|
const earlyLevelScale = level < sourceLevel
|
|
? playerCombatPower(level) / playerCombatPower(sourceLevel)
|
|
: 1;
|
|
const bonusPower = equipmentPower * Math.max(
|
|
0,
|
|
scaling.bonusPowerCoefficient ?? scaling.coefficient,
|
|
);
|
|
return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower));
|
|
}
|
|
|
|
function playerCharacterStats(
|
|
classId: ClassId,
|
|
level: number,
|
|
gearStats: ItemStats,
|
|
raceId: RaceId,
|
|
secondaryClassId: ClassId | null,
|
|
modifiers: readonly TalentModifier[],
|
|
): CharacterStats {
|
|
const attributePercent = (attribute: "agility" | "intellect" | "spirit" | "stamina" | "strength") => (
|
|
applyTalentModifierValue(
|
|
100,
|
|
modifiers.filter((modifier) => modifier.kind !== "attribute-percent" || modifier.attribute === attribute),
|
|
"attribute-percent",
|
|
) - 100
|
|
);
|
|
const armorPercent = applyTalentModifierValue(100, modifiers, "armor-percent") - 100;
|
|
const stats = deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
agility: attributePercent("agility"),
|
|
intellect: attributePercent("intellect"),
|
|
spirit: attributePercent("spirit"),
|
|
stamina: attributePercent("stamina"),
|
|
strength: attributePercent("strength"),
|
|
}, secondaryClassId);
|
|
return armorPercent === 0
|
|
? stats
|
|
: Object.freeze({
|
|
...stats,
|
|
armor: Math.max(0, Math.round(stats.armor * (1 + armorPercent / 100))),
|
|
});
|
|
}
|
|
|
|
function npcCombatDefender(mob: MobCombatState, attackerLevel: number): CombatDefenderStats {
|
|
const levelAdvantage = Math.max(0, mob.level - attackerLevel);
|
|
return {
|
|
level: mob.level,
|
|
armor: mob.armor,
|
|
dodgePercent: 5 + levelAdvantage * 0.5,
|
|
parryPercent: 5 + levelAdvantage * 0.5,
|
|
blockPercent: 0,
|
|
blockValue: 0,
|
|
resistances: {
|
|
arcane: mob.resistances.arcane,
|
|
fire: mob.resistances.fire,
|
|
frost: mob.resistances.frost,
|
|
nature: mob.resistances.nature,
|
|
shadow: mob.resistances.shadow,
|
|
},
|
|
};
|
|
}
|
|
|
|
function stableCombatRoll(seed: string) {
|
|
return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`);
|
|
}
|
|
|
|
function resolvePlayerDamageResult(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
mob: MobCombatState,
|
|
amount: number,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
): CombatResult {
|
|
const baseStats = playerCharacterStats(
|
|
classId,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
work.talentModifiers,
|
|
);
|
|
const playerAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
|
|
const stats = applyAuraStats(baseStats, playerAuras);
|
|
const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind);
|
|
const criticalChanceFromTalents = ability
|
|
? applyTalentModifierValue(
|
|
0,
|
|
work.talentModifiers,
|
|
"critical-chance-percent",
|
|
abilityTalentContext(ability, periodic),
|
|
)
|
|
: applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent");
|
|
const attacker: CombatAttackerStats = {
|
|
...baseAttacker,
|
|
criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents,
|
|
};
|
|
const immune = mob.schoolImmunities.includes(school);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: school,
|
|
attacker,
|
|
defender: npcCombatDefender(mob, stats.level),
|
|
rules: immune
|
|
? {
|
|
...rules,
|
|
immune: true,
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
resistanceMode: "binary",
|
|
}
|
|
: rules,
|
|
roll: stableCombatRoll(seed),
|
|
});
|
|
}
|
|
|
|
function combatResultDescription(result: CombatResult): string {
|
|
if (result.outcome === "miss") return "missed";
|
|
if (result.outcome === "dodge") return "was dodged";
|
|
if (result.outcome === "parry") return "was parried";
|
|
if (result.outcome === "resist") return "was resisted";
|
|
if (result.outcome === "block") return `was blocked for ${result.blockedAmount}`;
|
|
if (result.outcome === "crit") return `critically hit for ${result.finalDamage}`;
|
|
if (result.partiallyResisted) return `hit for ${result.finalDamage} (${result.resistedAmount} resisted)`;
|
|
return `hit for ${result.finalDamage}`;
|
|
}
|
|
|
|
function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
|
|
if (attack.delivery === "melee") return "melee";
|
|
if (attack.delivery === "projectile" && attack.school === "physical") return "ranged";
|
|
return "spell";
|
|
}
|
|
|
|
function resolveEnemyDamageResult(
|
|
mob: MobCombatState,
|
|
attack: EnemyAttackDefinition,
|
|
defender: CharacterStats,
|
|
amount: number,
|
|
actorId: AggroActorId,
|
|
attackRevision: number,
|
|
now: number,
|
|
): CombatResult {
|
|
const attackKind = enemyAttackKind(attack);
|
|
return resolveCombatResult({
|
|
amount,
|
|
attackKind,
|
|
damageSchool: attack.school,
|
|
attacker: {
|
|
level: mob.level,
|
|
criticalChancePercent: 5,
|
|
},
|
|
defender: combatDefenderFromCharacterStats(defender),
|
|
rules: {
|
|
isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0,
|
|
canDodge: attackKind === "melee",
|
|
canParry: attackKind === "melee",
|
|
canBlock: attackKind !== "spell",
|
|
},
|
|
roll: stableCombatRoll(`${mob.id}:${attack.id}:${actorId}:${attackRevision}:${now}`),
|
|
});
|
|
}
|
|
|
|
function talentAdjustedMaxResource(
|
|
classId: ClassId,
|
|
level: number,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
raceId: RaceId = "human",
|
|
secondaryClassId?: ClassId | null,
|
|
): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
const playerModifiers = modifiers.filter((modifier) => (
|
|
!/\b(?:pet|imp|voidwalker|succubus|felhunter|felguard)\b/i.test(modifier.scope)
|
|
));
|
|
const primaryResourceModifiers = playerModifiers.filter((modifier) => {
|
|
if (modifier.kind !== "max-resource-percent") return true;
|
|
const scope = modifier.scope.toLowerCase();
|
|
const mentionsSpecificResource = /\b(?:mana|rage|energy|focus|runic power|felfury|deathfire)\b/.test(scope);
|
|
if (!mentionsSpecificResource) return true;
|
|
if (profile.type === "mana") return /\bmana\b/.test(scope);
|
|
if (profile.type === "rage") return /\b(?:rage|felfury|deathfire)\b/.test(scope);
|
|
if (profile.type === "energy") return /\b(?:energy|focus)\b/.test(scope);
|
|
return /\brunic power\b/.test(scope);
|
|
});
|
|
const maxResourcePercent = applyTalentModifierValue(
|
|
100,
|
|
primaryResourceModifiers,
|
|
"max-resource-percent",
|
|
) - 100;
|
|
if (profile.type !== "mana") {
|
|
return Math.max(0, Math.round(profile.maximum * (1 + maxResourcePercent / 100)));
|
|
}
|
|
const intellectModifiers = primaryResourceModifiers.filter((modifier) => (
|
|
modifier.kind !== "attribute-percent" || modifier.attribute === "intellect"
|
|
));
|
|
const intellectPercent = applyTalentModifierValue(100, intellectModifiers, "attribute-percent") - 100;
|
|
return Math.max(0, deriveCharacterStats(classId, level, gearStats, raceId, {
|
|
intellect: intellectPercent,
|
|
maxMana: maxResourcePercent,
|
|
}, secondaryClassId).mana);
|
|
}
|
|
|
|
const AUTOMATIC_CLASS_PASSIVE_PREFIX = "class-passive:";
|
|
|
|
function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
|
|
const statedLevel = ability.description.match(/\bLevel\s+(\d+)\s+Passive\b/i);
|
|
return Math.max(ability.unlockLevel, statedLevel ? Number(statedLevel[1]) : 1);
|
|
}
|
|
|
|
function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
|
|
if (!ability.passive) return false;
|
|
if (ability.source !== "coa-tree") return true;
|
|
const node = ability.talentEntryId === undefined
|
|
? null
|
|
: talentNodeById(`coa-entry-${ability.talentEntryId}`);
|
|
return Boolean(
|
|
node?.isPassive
|
|
&& (node.abilityEssenceCost ?? 0) === 0
|
|
&& (node.talentEssenceCost ?? 0) === 0,
|
|
);
|
|
}
|
|
|
|
function automaticClassPassiveModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
): readonly TalentModifier[] {
|
|
const modifiers: TalentModifier[] = [];
|
|
const seenNames = new Set<string>();
|
|
for (const definition of abilitiesForClass(classId)) {
|
|
const ability = abilityAtLevel(definition, level);
|
|
if (
|
|
!isAutomaticClassPassive(ability)
|
|
|| level < automaticClassPassiveUnlockLevel(ability)
|
|
|| seenNames.has(ability.name.toLowerCase())
|
|
) {
|
|
continue;
|
|
}
|
|
seenNames.add(ability.name.toLowerCase());
|
|
modifiers.push(...parseTalentModifiers(ability.description).map((modifier) => ({
|
|
...modifier,
|
|
sourceTalentId: `${AUTOMATIC_CLASS_PASSIVE_PREFIX}${ability.id}`,
|
|
sourceTalentName: ability.name,
|
|
rank: abilityRankForLevel(ability, level)?.rank ?? 1,
|
|
description: ability.description,
|
|
})));
|
|
}
|
|
return modifiers;
|
|
}
|
|
|
|
function characterStatModifiers(
|
|
classId: ClassId,
|
|
level: number,
|
|
ranks: TalentRanks,
|
|
): readonly TalentModifier[] {
|
|
return [
|
|
...automaticClassPassiveModifiers(classId, level),
|
|
...talentModifiersForRanks(ranks, classId),
|
|
];
|
|
}
|
|
|
|
function initialResourceForMaximum(classId: ClassId, maximum: number): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
return Math.round(maximum * (profile.maximum > 0 ? profile.initial / profile.maximum : 0));
|
|
}
|
|
|
|
function defaultResourceMaximum(type: ResourceType): number {
|
|
if (type === "nature-power") return 10;
|
|
if (type === "psi") return 6;
|
|
return 100;
|
|
}
|
|
|
|
function initialResourcePools(
|
|
classId: ClassId,
|
|
abilities: readonly AbilityDefinition[],
|
|
activeMaximum: number,
|
|
): { pools: Partial<Record<ResourceType, number>>; maximums: Partial<Record<ResourceType, number>> } {
|
|
const activeProfile = resourceProfileForClass(classId);
|
|
const resourceTypes = new Set<ResourceType>([activeProfile.type]);
|
|
for (const ability of abilities) for (const cost of ability.costs ?? [ability.cost]) resourceTypes.add(cost.resource);
|
|
const maximums: Partial<Record<ResourceType, number>> = {};
|
|
const pools: Partial<Record<ResourceType, number>> = {};
|
|
for (const type of resourceTypes) {
|
|
if (type === "health") continue;
|
|
const maximum = type === activeProfile.type ? activeMaximum : defaultResourceMaximum(type);
|
|
maximums[type] = maximum;
|
|
pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
|
? 0
|
|
: maximum;
|
|
}
|
|
return { pools, maximums };
|
|
}
|
|
|
|
function abilityCosts(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
|
): readonly { resource: ResourceType; amount: number }[] {
|
|
const aggregated = new Map<ResourceType, number>();
|
|
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
|
const adjusted = Math.max(0, Math.round(applyTalentModifierValue(
|
|
cost.amount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
const maximum = cost.resource === "health"
|
|
? state.maxHealth
|
|
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
|
const rawAmount = cost.percentage ? Math.round(maximum * adjusted / 100) : adjusted;
|
|
const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID);
|
|
const resourceAdjusted = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(rawAmount, playerAuras, cost.resource, "cost"),
|
|
playerAuras,
|
|
"power",
|
|
"cost",
|
|
);
|
|
const amount = Math.max(0, Math.round(resourceAdjusted));
|
|
aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount);
|
|
}
|
|
return [...aggregated].map(([resource, amount]) => ({ resource, amount }));
|
|
}
|
|
|
|
function talentAdjustedResourceCost(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
ability.cost.amount,
|
|
modifiers,
|
|
"resource-cost-percent",
|
|
abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedCastTime(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.castTimeMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.castTimeMs, modifiers, "cast-time-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cast-time-percent", context);
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", context);
|
|
duration = hastedDuration(
|
|
duration,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
);
|
|
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
|
|
if (ability.cooldownMs <= 0) return 0;
|
|
const context = abilityTalentContext(ability);
|
|
let duration = applyTalentModifierValue(ability.cooldownMs, modifiers, "cooldown-flat-ms", context);
|
|
duration = applyTalentModifierValue(duration, modifiers, "cooldown-percent", context);
|
|
return Math.max(0, Math.round(duration));
|
|
}
|
|
|
|
function talentAdjustedGcd(
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
gearStats: ItemStats = EMPTY_ITEM_STATS,
|
|
level = 1,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
if (ability.gcdMs <= 0) return 0;
|
|
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
|
|
const duration = hastedDuration(
|
|
ability.gcdMs,
|
|
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
|
1_000,
|
|
);
|
|
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
|
}
|
|
|
|
function talentAdjustedDuration(
|
|
durationMs: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, Math.round(applyTalentModifierValue(
|
|
durationMs, modifiers, "duration-percent", abilityTalentContext(ability),
|
|
)));
|
|
}
|
|
|
|
function talentAdjustedRange(
|
|
range: number,
|
|
ability: AbilityDefinition,
|
|
modifiers: readonly TalentModifier[],
|
|
): number {
|
|
return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability)));
|
|
}
|
|
|
|
function talentAdjustedIncomingDamage(amount: number, modifiers: readonly TalentModifier[]): number {
|
|
return Math.max(0, Math.round(
|
|
applyTalentModifierValue(amount, modifiers, "damage-taken-percent"),
|
|
));
|
|
}
|
|
|
|
function normalizedSettings(
|
|
current: GameplaySettings,
|
|
patch: Partial<GameplaySettings>,
|
|
): GameplaySettings {
|
|
return {
|
|
showMobHealthBars: patch.showMobHealthBars ?? current.showMobHealthBars,
|
|
showMobAggroRanges: patch.showMobAggroRanges ?? current.showMobAggroRanges,
|
|
showTargetFrame: patch.showTargetFrame ?? current.showTargetFrame,
|
|
showThreatMeter: patch.showThreatMeter ?? current.showThreatMeter,
|
|
threatMeterPosition: normalizeHudPosition(
|
|
patch.threatMeterPosition ?? current.threatMeterPosition,
|
|
),
|
|
showFloatingCombatText: patch.showFloatingCombatText ?? current.showFloatingCombatText,
|
|
autoTargetNearest: patch.autoTargetNearest ?? current.autoTargetNearest,
|
|
enableScreenShake: patch.enableScreenShake ?? current.enableScreenShake,
|
|
masterVolume: clamp(Number.isFinite(patch.masterVolume) ? patch.masterVolume! : current.masterVolume, 0, 1),
|
|
uiScale: clamp(Number.isFinite(patch.uiScale) ? patch.uiScale! : current.uiScale, 0.75, 1.25),
|
|
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
|
|
? patch.minimapRotation
|
|
: current.minimapRotation,
|
|
};
|
|
}
|
|
|
|
function defaultMobStats(
|
|
playerLevel: number,
|
|
boss: boolean,
|
|
serverEntry?: number,
|
|
): {
|
|
maxHealth: number;
|
|
xpReward: number;
|
|
armor: number;
|
|
maxMana: number;
|
|
attackPower: number;
|
|
attackDamage: number;
|
|
swingMs: number;
|
|
mechanicImmunities: readonly AzerothCoreMechanic[];
|
|
schoolImmunities: readonly AzerothCoreSchool[];
|
|
} {
|
|
const server = serverEntry ? creatureRuntimeBaseStats(serverEntry, playerLevel) : null;
|
|
const legacy = boss
|
|
? { maxHealth: 260 + playerLevel * 65, xpReward: 110 + playerLevel * 35 }
|
|
: { maxHealth: 72 + playerLevel * 22, xpReward: 28 + playerLevel * 7 };
|
|
return {
|
|
maxHealth: server?.health ?? legacy.maxHealth,
|
|
xpReward: legacy.xpReward,
|
|
armor: server?.armor ?? 0,
|
|
maxMana: server?.mana ?? 0,
|
|
attackPower: server?.attackPower ?? 0,
|
|
attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2),
|
|
swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900),
|
|
mechanicImmunities: server?.mechanicImmunities ?? [],
|
|
schoolImmunities: server?.schoolImmunities ?? [],
|
|
};
|
|
}
|
|
|
|
function stableCombatUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
export function deterministicEnemyCooldown(
|
|
runtimeId: string,
|
|
abilityId: string,
|
|
activation: number,
|
|
range: readonly [number, number],
|
|
): number {
|
|
const minimum = Math.max(0, Math.round(range[0]));
|
|
const maximum = Math.max(minimum, Math.round(range[1]));
|
|
if (maximum === minimum) return minimum;
|
|
return minimum + Math.floor(
|
|
stableCombatUnit(`${runtimeId}:${abilityId}:${activation}`) * (maximum - minimum + 1),
|
|
);
|
|
}
|
|
|
|
function enemyAbilityConditionMet(
|
|
mob: MobCombatState,
|
|
mobs: Readonly<Record<string, MobCombatState>>,
|
|
attack: EnemyAttackDefinition,
|
|
): boolean {
|
|
const condition = attack.condition;
|
|
if (!condition || condition.kind === "in-combat") return true;
|
|
if (condition.kind === "self-health-percent") {
|
|
const percent = mob.maxHealth > 0 ? mob.health / mob.maxHealth * 100 : 0;
|
|
return percent >= condition.minimum && percent <= condition.maximum;
|
|
}
|
|
if (condition.kind === "encounter-state") {
|
|
if (condition.encounterId !== "wailing-caverns:naralex") return false;
|
|
return condition.states.includes(useWailingEncounterStore.getState().phase);
|
|
}
|
|
const origin = getMobPosition(mob.id);
|
|
if (!origin) return false;
|
|
return Object.values(mobs).some((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - condition.amount) return false;
|
|
const position = getMobPosition(candidate.id);
|
|
if (!position) return false;
|
|
const dx = position[0] - origin[0];
|
|
const dy = position[1] - origin[1];
|
|
const dz = position[2] - origin[2];
|
|
return dx * dx + dy * dy + dz * dz <= condition.range * condition.range;
|
|
});
|
|
}
|
|
|
|
function enemyAbilityNeedsHostileRange(attack: EnemyAttackDefinition): boolean {
|
|
return attack.target === "primary"
|
|
|| attack.target === "random-party"
|
|
|| attack.target === "nearby-party";
|
|
}
|
|
|
|
function initialEnemyAbilityReadyAt(
|
|
mob: MobCombatState,
|
|
now: number,
|
|
): Readonly<Record<string, number>> {
|
|
return Object.fromEntries(mob.attacks.map((attack) => {
|
|
const range = attack.initialCooldownMs ?? [450, 450] as const;
|
|
return [
|
|
attack.id,
|
|
now + deterministicEnemyCooldown(mob.id, attack.id, mob.attackRevision, range),
|
|
];
|
|
}));
|
|
}
|
|
|
|
function summonedMobCombatState(
|
|
id: string,
|
|
summoner: MobCombatState,
|
|
creatureEntry: number,
|
|
targetActorId: AggroActorId,
|
|
position: CombatPosition,
|
|
): MobCombatState {
|
|
const stats = defaultMobStats(summoner.level, false, creatureEntry);
|
|
const template = creatureTemplateReference(creatureEntry);
|
|
const attack: EnemyAttackDefinition = normalizeEnemyAttacks([{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
cooldownMs: stats.swingMs,
|
|
repeatCooldownMs: [stats.swingMs, stats.swingMs],
|
|
initialCooldownMs: [450, 450],
|
|
}])[0];
|
|
return {
|
|
id,
|
|
name: template?.name ?? `Summoned Creature ${creatureEntry}`,
|
|
serverEntry: creatureEntry,
|
|
summonedBy: summoner.id,
|
|
boss: false,
|
|
lootSourceId: null,
|
|
level: summoner.level,
|
|
armor: stats.armor,
|
|
maxMana: stats.maxMana,
|
|
attackPower: stats.attackPower,
|
|
mechanicImmunities: stats.mechanicImmunities,
|
|
schoolImmunities: stats.schoolImmunities,
|
|
resistances: { arcane: 0, fire: 0, frost: 0, nature: 0, shadow: 0 },
|
|
health: stats.maxHealth,
|
|
maxHealth: stats.maxHealth,
|
|
healthMultiplier: 1,
|
|
damageMultiplier: 1,
|
|
bonusLootChance: 0,
|
|
dead: false,
|
|
hasLoot: false,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward: 0,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
auras: [],
|
|
threatByActor: seedActorThreat({}, targetActorId),
|
|
targetActorId,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: position,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange: attack.range,
|
|
attackDamage: stats.attackDamage,
|
|
swingMs: stats.swingMs,
|
|
aggroRange: 0,
|
|
leashRange: summoner.leashRange,
|
|
moveSpeed: summoner.moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks: [attack],
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
}
|
|
|
|
function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosition {
|
|
return origin ?? state.playerPosition;
|
|
}
|
|
|
|
function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveAura[] {
|
|
return auras.filter((aura) => aura.targetId === entityId);
|
|
}
|
|
|
|
function auraDefinitionForAbility(
|
|
definition: AuraDefinition,
|
|
ability: AbilityDefinition,
|
|
durationMs = definition.durationMs,
|
|
): AuraDefinition {
|
|
return {
|
|
...definition,
|
|
icon: definition.icon ?? ability.icon,
|
|
durationMs,
|
|
};
|
|
}
|
|
|
|
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
|
const stat = (value: number, ...names: string[]) => names.reduce(
|
|
(current, name) => resolveAuraStatValue(current, auras, name),
|
|
value,
|
|
);
|
|
const primary = (value: number, name: string, alias?: string) => stat(
|
|
value,
|
|
name,
|
|
...(alias ? [alias] : []),
|
|
"all-primary",
|
|
"all-primary-attributes",
|
|
);
|
|
const resistance = (value: number, school: string) => stat(
|
|
value,
|
|
`resistance:${school}`,
|
|
"resistance:all",
|
|
"all-resistance",
|
|
"magical-defense",
|
|
);
|
|
return {
|
|
...base,
|
|
strength: primary(base.strength, "strength"),
|
|
agility: primary(base.agility, "agility", "dexterity"),
|
|
stamina: primary(base.stamina, "stamina"),
|
|
intellect: primary(base.intellect, "intellect"),
|
|
spirit: primary(base.spirit, "spirit", "wisdom"),
|
|
health: stat(base.health, "maximum-health"),
|
|
mana: stat(base.mana, "maximum-mana"),
|
|
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
|
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
|
spellPower: stat(base.spellPower, "spell-power", "magical-attack"),
|
|
armor: stat(base.armor, "armor", "physical-defense"),
|
|
blockValue: stat(base.blockValue, "block-value"),
|
|
meleeCritPercent: stat(base.meleeCritPercent, "critical-strike", "physical-critical-strike"),
|
|
rangedCritPercent: stat(base.rangedCritPercent, "critical-strike", "physical-critical-strike"),
|
|
spellCritPercent: stat(base.spellCritPercent, "spell-critical-strike", "magical-critical-strike"),
|
|
meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"),
|
|
rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit"),
|
|
spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"),
|
|
meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"),
|
|
rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"),
|
|
spellHastePercent: stat(base.spellHastePercent, "haste", "spell-haste"),
|
|
dodgePercent: stat(base.dodgePercent, "dodge"),
|
|
parryPercent: stat(base.parryPercent, "parry"),
|
|
blockPercent: stat(base.blockPercent, "block"),
|
|
expertise: stat(base.expertise, "expertise"),
|
|
arcaneResistance: resistance(base.arcaneResistance, "arcane"),
|
|
fireResistance: resistance(base.fireResistance, "fire"),
|
|
frostResistance: resistance(base.frostResistance, "frost"),
|
|
natureResistance: resistance(base.natureResistance, "nature"),
|
|
shadowResistance: resistance(base.shadowResistance, "shadow"),
|
|
};
|
|
}
|
|
|
|
function syncPassiveAuras(
|
|
current: readonly ActiveAura[],
|
|
abilities: readonly AbilityDefinition[],
|
|
level: number,
|
|
talentRanks: TalentRanks,
|
|
now: number,
|
|
): ActiveAura[] {
|
|
const definitions = abilities.flatMap((catalogAbility) => {
|
|
if (!catalogAbility.passive || !isAbilityUnlocked(catalogAbility, level)) return [];
|
|
const learnedRank = catalogAbility.talentEntryId === undefined
|
|
? undefined
|
|
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
if (catalogAbility.talentEntryId !== undefined && !learnedRank) return [];
|
|
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
|
return ability.effects
|
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
|
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
|
});
|
|
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
|
let next = current.filter((aura) => (
|
|
aura.sourceId !== PLAYER_AURA_ENTITY_ID
|
|
|| !abilities.some((ability) => ability.passive && aura.definition.id.includes(`:${ability.dbcSpellId}:`))
|
|
|| passiveIds.has(aura.definition.id)
|
|
));
|
|
for (const definition of definitions) {
|
|
if (next.some((aura) => aura.definition.id === definition.id && aura.targetId === PLAYER_AURA_ENTITY_ID)) continue;
|
|
next = [...applyCombatAura(next, {
|
|
definition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function executeTriggeredAuraProcsInWork(
|
|
work: MutableCastWork,
|
|
triggeredProcs: readonly TriggeredAuraProc[],
|
|
now: number,
|
|
): void {
|
|
for (const triggered of triggeredProcs) {
|
|
const action = triggered.action;
|
|
const actionTarget = action.kind === "custom"
|
|
? null
|
|
: action.target === "aura-source"
|
|
? triggered.sourceId
|
|
: action.target === "aura-target"
|
|
? triggered.targetId
|
|
: triggered.event.otherId ?? null;
|
|
if (action.kind === "resource" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
const resource = action.resource as ResourceType;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + action.amount, 0, maximum);
|
|
} else if (action.kind === "heal" && actionTarget === PLAYER_AURA_ENTITY_ID) {
|
|
const healed = Math.min(Math.max(0, action.amount), work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
} else if (action.kind === "damage" && actionTarget && work.mobs[actionTarget]) {
|
|
const mob = work.mobs[actionTarget];
|
|
const amount = Math.min(mob.health, Math.max(0, Math.round(action.amount)));
|
|
work.mobs[actionTarget] = {
|
|
...mob,
|
|
health: mob.health - amount,
|
|
dead: mob.health - amount <= 0,
|
|
};
|
|
work.totalDamage += amount;
|
|
} else if (action.kind === "apply-aura" && actionTarget) {
|
|
const definition = work.auras.find((aura) => aura.definition.id === action.auraId)?.definition;
|
|
if (definition) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition,
|
|
sourceId: triggered.sourceId,
|
|
targetId: actionTarget,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (
|
|
action.kind === "custom"
|
|
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
|
|
&& typeof action.data?.spellId === "number"
|
|
) {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
action.data.spellId,
|
|
triggered.sourceId,
|
|
triggered.event.otherId ?? triggered.targetId,
|
|
now,
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function evaluateProcsInWork(
|
|
work: MutableCastWork,
|
|
event: ProcEvent,
|
|
now: number,
|
|
): readonly TriggeredAuraProc[] {
|
|
const result = evaluateAuraProcs(
|
|
work.auras,
|
|
event,
|
|
now,
|
|
({ aura, proc }) => stableCombatUnit(
|
|
`${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`,
|
|
),
|
|
);
|
|
work.auras = [...result.auras];
|
|
executeTriggeredAuraProcsInWork(work, result.triggered, now);
|
|
return result.triggered;
|
|
}
|
|
|
|
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
|
|
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
return Boolean(mob && !mob.dead);
|
|
});
|
|
}
|
|
|
|
function castFailureMessage(reason: CastFailureReason, resourceName: string, unlockLevel?: number): string {
|
|
switch (reason) {
|
|
case "unknown-ability": return "That ability is unavailable.";
|
|
case "wrong-class": return "Your class cannot use that ability.";
|
|
case "not-learned": return "Learn that ability in the Conquest talent tree first.";
|
|
case "passive": return "Passive abilities activate automatically and cannot be cast.";
|
|
case "unsupported": return "That spell has no executable combat effect.";
|
|
case "level-locked": return `That ability unlocks at level ${unlockLevel ?? "a higher level"}.`;
|
|
case "player-dead": return "You cannot act while defeated.";
|
|
case "player-controlled": return "You cannot act while under an enemy control effect.";
|
|
case "already-casting": return "Another spell is already being cast.";
|
|
case "global-cooldown": return "Your abilities are not ready yet.";
|
|
case "cooldown": return "That ability is still recharging.";
|
|
case "not-enough-resource": return `Not enough ${resourceName}.`;
|
|
case "no-target": return "Select an enemy target.";
|
|
case "target-dead": return "That target is already defeated.";
|
|
case "target-unavailable": return "That target is not currently visible.";
|
|
case "too-close": return "That target is too close.";
|
|
case "out-of-range": return "That target is out of range.";
|
|
}
|
|
}
|
|
|
|
function levelUpMessage(classId: ClassId, previousLevel: number, currentLevel: number): string {
|
|
const parts = [`Level ${currentLevel}!`];
|
|
const unlocked = abilitiesUnlockedBetweenLevels(classId, previousLevel, currentLevel);
|
|
if (unlocked.length === 1) {
|
|
parts.push(`New ability unlocked: ${unlocked[0].name}.`);
|
|
} else if (unlocked.length > 1) {
|
|
parts.push(`New abilities unlocked: ${unlocked.map((ability) => ability.name).join(", ")}.`);
|
|
}
|
|
if (talentPointsForLevel(currentLevel) > talentPointsForLevel(previousLevel)) {
|
|
parts.push("A new talent point is available.");
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function progressionFromState(state: CombatState): MutableProgression {
|
|
return {
|
|
level: state.level,
|
|
xp: state.xp,
|
|
xpToNext: state.xpToNext,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
levelsGained: 0,
|
|
};
|
|
}
|
|
|
|
function grantMobExperience(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
xpReward: number,
|
|
): number {
|
|
const { progression } = work;
|
|
const scaledAward = healerManExperienceAward(xpReward, progression.level);
|
|
const result = awardExperience({ level: progression.level, xp: progression.xp }, scaledAward);
|
|
progression.level = result.level;
|
|
progression.xp = result.xp;
|
|
progression.xpToNext = result.xpToNext;
|
|
progression.levelsGained += result.levelsGained;
|
|
if (result.levelsGained > 0) {
|
|
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
|
|
progression.maxHealth = talentAdjustedMaxHealth(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
);
|
|
progression.health = progression.maxHealth;
|
|
work.health = progression.health;
|
|
work.maxHealth = progression.maxHealth;
|
|
work.maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
result.level,
|
|
work.talentModifiers,
|
|
work.gearStats,
|
|
work.raceId,
|
|
work.secondaryClassId,
|
|
);
|
|
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, Date.now());
|
|
}
|
|
return scaledAward;
|
|
}
|
|
|
|
function mobGroupId(id: string): string {
|
|
if (id.startsWith("manastorm:guardian-spawn:")) {
|
|
const memberSeparator = id.lastIndexOf(":");
|
|
return memberSeparator > 0 ? id.slice(0, memberSeparator) : id;
|
|
}
|
|
// A Manastorm boss shares an aura with guardians, not their aggro group.
|
|
// Keeping its full runtime id lets players choose to pull it early without
|
|
// every guardian hit dragging the boss across the stage.
|
|
if (id.startsWith("manastorm:spawn:")) return id;
|
|
const separator = id.indexOf(":");
|
|
return separator > 0 ? id.slice(0, separator) : id;
|
|
}
|
|
|
|
function engageMobGroup(
|
|
mobs: Record<string, MobCombatState>,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): boolean {
|
|
const groupId = mobGroupId(sourceId);
|
|
const groupWasEngaged = Object.entries(mobs).some(([id, mob]) => (
|
|
mobGroupId(id) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
let changed = false;
|
|
for (const [id, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
|
const threatByActor = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, sourceActorId);
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor,
|
|
targetActorId: mob.targetActorId ?? (groupWasEngaged ? null : sourceActorId),
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(id))
|
|
: (getMobPosition(id) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function engageMobGroupInWork(
|
|
work: MutableCastWork,
|
|
sourceId: string,
|
|
now: number,
|
|
sourceActorId: AggroActorId,
|
|
): void {
|
|
engageMobGroup(work.mobs, sourceId, now, sourceActorId);
|
|
}
|
|
|
|
function nearestAggroActorId(
|
|
state: Pick<CombatState, "health" | "playerPosition">,
|
|
mobPosition: CombatPosition,
|
|
aggroRange: number,
|
|
): AggroActorId | null {
|
|
if (!Number.isFinite(aggroRange) || aggroRange <= 0) return null;
|
|
let nearestId: AggroActorId | null = null;
|
|
let nearestDistanceSquared = aggroRange * aggroRange;
|
|
if (state.health > 0) {
|
|
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
|
if (playerDistanceSquared <= nearestDistanceSquared) {
|
|
nearestId = PLAYER_AGGRO_ID;
|
|
nearestDistanceSquared = playerDistanceSquared;
|
|
}
|
|
}
|
|
for (const member of usePartyStore.getState().members) {
|
|
if (member.health <= 0) continue;
|
|
const position = getPartyRuntimePosition(member.id);
|
|
if (!position) continue;
|
|
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
|
if (memberDistanceSquared < nearestDistanceSquared) {
|
|
nearestId = member.id;
|
|
nearestDistanceSquared = memberDistanceSquared;
|
|
}
|
|
}
|
|
return nearestId;
|
|
}
|
|
|
|
function mobResistanceForSchool(
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
): number {
|
|
if (school === "arcane") return mob.resistances.arcane;
|
|
if (school === "fire") return mob.resistances.fire;
|
|
if (school === "frost") return mob.resistances.frost;
|
|
if (school === "nature") return mob.resistances.nature;
|
|
if (school === "shadow") return mob.resistances.shadow;
|
|
return 0;
|
|
}
|
|
|
|
function damageAfterMobResistance(
|
|
amount: number,
|
|
mob: MobCombatState,
|
|
school: DamageSchool,
|
|
spellPenetration: number,
|
|
attackerLevel: number,
|
|
): number {
|
|
if (mob.schoolImmunities.includes(school)) return 0;
|
|
if (school === "physical" || school === "holy") return amount;
|
|
const resistance = Math.max(0, mobResistanceForSchool(mob, school) - Math.max(0, spellPenetration));
|
|
const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75;
|
|
return amount * (1 - Math.min(0.75, resisted));
|
|
}
|
|
|
|
function damageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
school: DamageSchool = "physical",
|
|
spellPenetration = 0,
|
|
combatResult?: CombatResult,
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
|
amount,
|
|
mob,
|
|
school,
|
|
spellPenetration,
|
|
work.progression.level,
|
|
);
|
|
const mitigated = resolveAuraDamageValue(
|
|
mitigatedBeforeAuras,
|
|
aurasForEntity(work.auras, id),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const auraAbsorb = absorbAuraDamage(work.auras, {
|
|
targetId: id,
|
|
amount: mitigated,
|
|
school,
|
|
now,
|
|
});
|
|
work.auras = [...auraAbsorb.auras];
|
|
const adjustedCombatResult = combatResult
|
|
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
|
|
: undefined;
|
|
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
|
|
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.remainingDamage)));
|
|
const thorns = mob.auras.find((aura) => aura.id === "thorns");
|
|
if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) {
|
|
const reflected = Math.min(
|
|
work.health,
|
|
Math.max(1, Math.round(applied * (thorns.magnitude ?? 0.08))),
|
|
);
|
|
work.health -= reflected;
|
|
work.progression.health = work.health;
|
|
}
|
|
const health = mob.health - applied;
|
|
const killed = health <= 0;
|
|
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
|
const canAward = killed && !mob.experienceGranted;
|
|
const beforeLevel = work.progression.level;
|
|
const pendingLoot = killed && mob.lootSourceId
|
|
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
|
now,
|
|
bonusItemChance: mob.bonusLootChance,
|
|
})
|
|
: mob.pendingLoot;
|
|
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 : mob.hasLoot;
|
|
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
|
|
engageMobGroupInWork(work, id, now, source.actorId);
|
|
const engagedMob = work.mobs[id] ?? mob;
|
|
const threatByActor = killed
|
|
? {}
|
|
: addActorThreat(
|
|
engagedMob.threatByActor,
|
|
source.actorId,
|
|
threatFromDamage(applied, source.role, source.threatScale),
|
|
);
|
|
work.mobs[id] = {
|
|
...engagedMob,
|
|
health,
|
|
dead: killed,
|
|
hasLoot,
|
|
pendingLoot,
|
|
looted: killed ? false : mob.looted,
|
|
despawned: false,
|
|
diedAt: killed ? now : mob.diedAt,
|
|
corpseDespawnAt: killed
|
|
? (hasLoot ? null : now + EMPTY_CORPSE_DESPAWN_MS)
|
|
: mob.corpseDespawnAt,
|
|
experienceGranted: mob.experienceGranted || killed,
|
|
lastDamageAt: now,
|
|
statuses: killed ? [] : mob.statuses,
|
|
threatByActor,
|
|
targetActorId: killed ? null : (engagedMob.targetActorId ?? source.actorId),
|
|
forcedTarget: killed ? null : engagedMob.forcedTarget,
|
|
engaged: !killed,
|
|
combatPhase: killed ? "idle" : "chasing",
|
|
homePosition: engagedMob.homePosition ?? getMobPosition(id),
|
|
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
|
|
activeCast: killed ? null : engagedMob.activeCast,
|
|
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
|
|
nextWoundAnimationAt: canPlayWoundAnimation
|
|
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
|
|
: mob.nextWoundAnimationAt,
|
|
deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision,
|
|
};
|
|
// Pulling a member links the whole authored pack even when the opening hit
|
|
// kills that member outright. The dead source remains inert because the
|
|
// group helper deliberately skips dead mobs.
|
|
work.totalDamage += applied;
|
|
if (applied > 0) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "damage-taken",
|
|
subjectId: id,
|
|
otherId: source.actorId,
|
|
abilityId: source.abilityId,
|
|
amount: applied,
|
|
school,
|
|
}, now);
|
|
}
|
|
return {
|
|
amount: applied,
|
|
killed,
|
|
xpAwarded,
|
|
levelsGained: work.progression.level - beforeLevel,
|
|
...(adjustedCombatResult ? { combatResult: adjustedCombatResult } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveAndDamageMobInWork(
|
|
classId: ClassId,
|
|
work: MutableCastWork,
|
|
id: string,
|
|
amount: number,
|
|
now: number,
|
|
source: ThreatSource,
|
|
attackKind: WotlkAttackKind,
|
|
school: DamageSchool,
|
|
seed: string,
|
|
ability?: AbilityDefinition,
|
|
periodic = false,
|
|
rules: CombatResultRules = {},
|
|
): DamageResult {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead || amount <= 0) {
|
|
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
|
}
|
|
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
|
const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, {
|
|
direction: "dealt",
|
|
school,
|
|
attackKind,
|
|
});
|
|
const combatResult = resolvePlayerDamageResult(
|
|
classId,
|
|
work,
|
|
mob,
|
|
modifiedAmount,
|
|
attackKind,
|
|
school,
|
|
seed,
|
|
ability,
|
|
periodic,
|
|
rules,
|
|
);
|
|
const result = damageMobInWork(
|
|
classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
source,
|
|
school,
|
|
work.gearStats.spellPenetration,
|
|
combatResult,
|
|
);
|
|
const trigger = combatResult.outcome === "crit"
|
|
? "crit"
|
|
: combatResult.outcome === "dodge"
|
|
? "dodge"
|
|
: combatResult.outcome === "parry"
|
|
? "parry"
|
|
: combatResult.outcome === "block"
|
|
? "block"
|
|
: combatResult.landed
|
|
? "hit"
|
|
: null;
|
|
if (trigger) {
|
|
evaluateProcsInWork(work, {
|
|
trigger,
|
|
subjectId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? id : source.actorId,
|
|
otherId: trigger === "dodge" || trigger === "parry" || trigger === "block" ? source.actorId : id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
if (result.killed) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "kill",
|
|
subjectId: source.actorId,
|
|
otherId: id,
|
|
abilityId: ability?.id,
|
|
amount: result.amount,
|
|
school,
|
|
attackKind,
|
|
}, now);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function applyStatus(
|
|
mob: MobCombatState,
|
|
effect: Extract<AbilityEffect, { kind: "status" }>,
|
|
abilityId: string,
|
|
now: number,
|
|
): MobCombatState {
|
|
if (mob.dead) return mob;
|
|
if (
|
|
(effect.status === "fear" || effect.status === "root" || effect.status === "stun")
|
|
&& mob.mechanicImmunities.includes(effect.status)
|
|
) return mob;
|
|
const durationMs = mob.boss && effect.status === "stun"
|
|
? Math.min(3000, effect.durationMs)
|
|
: effect.durationMs;
|
|
if (effect.status === "interrupt") {
|
|
const cast = mob.activeCast;
|
|
if (!cast?.interruptible) return mob;
|
|
return {
|
|
...mob,
|
|
activeCast: null,
|
|
nextAttackAt: now,
|
|
schoolLockedUntil: {
|
|
...mob.schoolLockedUntil,
|
|
[cast.school]: Math.max(mob.schoolLockedUntil[cast.school] ?? 0, now + durationMs),
|
|
},
|
|
};
|
|
}
|
|
const status: MobStatus = {
|
|
kind: effect.status,
|
|
sourceAbilityId: abilityId,
|
|
endsAt: now + durationMs,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
};
|
|
const incapacitated = [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(effect.status);
|
|
const cancelsActiveCast = Boolean(mob.activeCast && (
|
|
incapacitated
|
|
|| (effect.status === "silence" && mob.activeCast!.school !== "physical")
|
|
|| (effect.status === "disarm" && mob.activeCast!.school === "physical")
|
|
));
|
|
return {
|
|
...mob,
|
|
...(cancelsActiveCast
|
|
? { activeCast: null, nextAttackAt: now }
|
|
: {}),
|
|
statuses: [...mob.statuses.filter((entry) => entry.kind !== effect.status), status],
|
|
};
|
|
}
|
|
|
|
function executeTriggeredSpellInWork(
|
|
work: MutableCastWork,
|
|
spellId: number,
|
|
sourceId: string,
|
|
targetId: string | null,
|
|
now: number,
|
|
depth: number,
|
|
): void {
|
|
if (depth > 1) return;
|
|
const catalogAbility = work.abilities.find((candidate) => (
|
|
candidate.dbcSpellId === spellId
|
|
|| candidate.ranks?.some((rank) => rank.spellId === spellId)
|
|
));
|
|
if (!catalogAbility) return;
|
|
const ability = abilityAtLevel(catalogAbility, work.progression.level);
|
|
const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true);
|
|
const attackKind = abilityAttackKind(work.classId, ability);
|
|
const school = abilityDamageSchool(work.classId, ability);
|
|
const mobTargets = targetId && work.mobs[targetId] && !work.mobs[targetId].dead ? [targetId] : [];
|
|
for (const effect of ability.effects) {
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const mobId of mobTargets) {
|
|
resolveAndDamageMobInWork(
|
|
work.classId,
|
|
work,
|
|
mobId,
|
|
amount,
|
|
now,
|
|
source,
|
|
attackKind,
|
|
school,
|
|
`${sourceId}:${ability.id}:${mobId}:${now}:trigger:${depth}`,
|
|
ability,
|
|
);
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const rawAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability),
|
|
work.talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const recipient = targetId && !work.mobs[targetId] ? targetId : PLAYER_AURA_ENTITY_ID;
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(rawAmount, aurasForEntity(work.auras, sourceId), "done"),
|
|
aurasForEntity(work.auras, recipient),
|
|
"taken",
|
|
));
|
|
if (recipient === PLAYER_AURA_ENTITY_ID) {
|
|
const healed = Math.min(amount, work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
} else {
|
|
const healed = usePartyStore.getState().healMember(recipient, amount);
|
|
work.totalHealing += healed;
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], effect, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const mobId of mobTargets) {
|
|
work.mobs[mobId] = applyStatus(work.mobs[mobId], {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const recipient = effect.recipient === "caster"
|
|
? sourceId
|
|
: targetId ?? sourceId;
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: auraDefinitionForAbility(effect.aura, ability),
|
|
sourceId,
|
|
targetId: recipient,
|
|
now,
|
|
}).auras];
|
|
if (effect.control && work.mobs[recipient]) {
|
|
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs: effect.aura.durationMs ?? 1_000,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${effect.aura.id}`, now);
|
|
}
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? resourceProfileForClass(work.classId).type;
|
|
const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource);
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + effect.amount, 0, maximum);
|
|
if (resource === resourceProfileForClass(work.classId).type) {
|
|
work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "trigger-spell" && depth < 1 && effect.spellId !== spellId) {
|
|
executeTriggeredSpellInWork(work, effect.spellId, sourceId, targetId, now, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function timedEffect(
|
|
kind: "dot" | "hot",
|
|
abilityId: string,
|
|
sourceActorId: AggroActorId,
|
|
targetId: string | null,
|
|
scaling: AbilityAmountScaling & { readonly coefficient: number },
|
|
ticks: number,
|
|
intervalMs: number,
|
|
now: number,
|
|
tag?: string,
|
|
damageSchool?: DamageSchool,
|
|
spellPenetration?: number,
|
|
): TimedCombatEffect {
|
|
effectSequence += 1;
|
|
return {
|
|
id: `${abilityId}:${effectSequence}`,
|
|
kind,
|
|
sourceAbilityId: abilityId,
|
|
sourceActorId,
|
|
targetId,
|
|
coefficient: scaling.coefficient,
|
|
...(scaling.basePoints === undefined ? {} : { basePoints: scaling.basePoints }),
|
|
...(scaling.dieSides === undefined ? {} : { dieSides: scaling.dieSides }),
|
|
...(scaling.pointsPerLevel === undefined ? {} : { pointsPerLevel: scaling.pointsPerLevel }),
|
|
...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }),
|
|
...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }),
|
|
...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }),
|
|
remainingTicks: ticks,
|
|
intervalMs,
|
|
nextTickAt: now + intervalMs,
|
|
...(tag ? { tag } : {}),
|
|
...(damageSchool ? { damageSchool } : {}),
|
|
...(spellPenetration ? { spellPenetration } : {}),
|
|
};
|
|
}
|
|
|
|
function initialState() {
|
|
const classId: ClassId = "priest";
|
|
const raceId: RaceId = "human";
|
|
const level = 1;
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks: TalentRanks = {};
|
|
const talentModifiers = characterStatModifiers(classId, level, talentRanks);
|
|
const maxHealth = talentAdjustedMaxHealth(classId, level, talentModifiers, EMPTY_ITEM_STATS, raceId);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
level,
|
|
talentModifiers,
|
|
EMPTY_ITEM_STATS,
|
|
raceId,
|
|
);
|
|
const abilities = abilitiesForClass(classId);
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
|
return {
|
|
classId,
|
|
secondaryClassId: null,
|
|
raceId,
|
|
level,
|
|
xp: 0,
|
|
xpToNext: xpRequiredForNextLevel(level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
resourceName: resource.name,
|
|
comboPoints: 0,
|
|
abilities,
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
playerAnimationEvent: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs: {},
|
|
selectedTargetId: null,
|
|
effects: [],
|
|
auras: [],
|
|
summons: [],
|
|
feedback: null,
|
|
lastCombatResult: null,
|
|
bossLootRewards: [] as readonly BossLootReward[],
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
|
|
inventory: [] as readonly InventoryItem[],
|
|
equipment: {} as EquipmentAssignments,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
settings: DEFAULT_GAMEPLAY_SETTINGS,
|
|
playerPosition: [0, 0, 0] as CombatPosition,
|
|
};
|
|
}
|
|
|
|
export const useCombatStore = create<CombatState>((set, get) => ({
|
|
...initialState(),
|
|
|
|
initializeCharacter: (profile) => {
|
|
resetCombatPresentationSession();
|
|
const classId = profile.classId;
|
|
const secondaryClassId = profile.secondaryClassId ?? null;
|
|
const raceId = profile.raceId ?? "human";
|
|
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
|
const resource = resourceProfileForClass(classId);
|
|
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
|
|
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
|
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
|
const inventory = normalizeInventory(profile.inventory);
|
|
const equipment = normalizeEquipment(profile.equipment, inventory);
|
|
const gearStats = activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
classId,
|
|
progression.level,
|
|
talentModifiers,
|
|
gearStats,
|
|
raceId,
|
|
secondaryClassId,
|
|
);
|
|
const initialPools = initialResourcePools(classId, abilities, maxResource);
|
|
set((state) => ({
|
|
...initialState(),
|
|
classId,
|
|
secondaryClassId,
|
|
raceId,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: xpRequiredForNextLevel(progression.level),
|
|
health: maxHealth,
|
|
maxHealth,
|
|
resource: initialResourceForMaximum(classId, maxResource),
|
|
maxResource,
|
|
resourcePools: initialPools.pools,
|
|
maxResourcePools: initialPools.maximums,
|
|
resourceName: resource.name,
|
|
abilities,
|
|
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
|
|
talentRanks,
|
|
talentModifiers,
|
|
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
|
inventory,
|
|
equipment,
|
|
gearStats,
|
|
settings: normalizedSettings(DEFAULT_GAMEPLAY_SETTINGS, profile.settings ?? {}),
|
|
playerPosition: state.playerPosition,
|
|
}));
|
|
},
|
|
|
|
registerMob: (id, registration) => set((state) => {
|
|
if (!id) return state;
|
|
const existing = state.mobs[id];
|
|
const boss = registration.boss ?? existing?.boss ?? false;
|
|
const serverEntry = registration.serverEntry ?? existing?.serverEntry ?? null;
|
|
const lootSourceId = registration.lootSourceId ?? existing?.lootSourceId ?? null;
|
|
const level = Math.max(1, Math.trunc(registration.level ?? existing?.level ?? state.level));
|
|
const resistances: MobMagicResistances = {
|
|
arcane: Math.max(0, registration.resistances?.arcane ?? existing?.resistances.arcane ?? 0),
|
|
fire: Math.max(0, registration.resistances?.fire ?? existing?.resistances.fire ?? 0),
|
|
frost: Math.max(0, registration.resistances?.frost ?? existing?.resistances.frost ?? 0),
|
|
nature: Math.max(0, registration.resistances?.nature ?? existing?.resistances.nature ?? 0),
|
|
shadow: Math.max(0, registration.resistances?.shadow ?? existing?.resistances.shadow ?? 0),
|
|
};
|
|
const defaults = defaultMobStats(level, boss, serverEntry ?? undefined);
|
|
const mechanicImmunities = registration.mechanicImmunities
|
|
?? existing?.mechanicImmunities
|
|
?? defaults.mechanicImmunities;
|
|
const schoolImmunities = registration.schoolImmunities
|
|
?? existing?.schoolImmunities
|
|
?? defaults.schoolImmunities;
|
|
const healthMultiplier = Math.max(0.01, registration.healthMultiplier ?? existing?.healthMultiplier ?? 1);
|
|
const damageMultiplier = Math.max(0.01, registration.damageMultiplier ?? existing?.damageMultiplier ?? 1);
|
|
const bonusLootChance = clamp(
|
|
registration.bonusLootChance ?? existing?.bonusLootChance ?? 0,
|
|
0,
|
|
1,
|
|
);
|
|
const maxHealth = Math.max(1, Math.round(
|
|
registration.maxHealth
|
|
?? existing?.maxHealth
|
|
?? defaults.maxHealth * healthMultiplier,
|
|
));
|
|
const xpReward = Math.max(0, Math.round(registration.xpReward ?? existing?.xpReward ?? defaults.xpReward));
|
|
const hasLoot = registration.hasLoot ?? existing?.hasLoot ?? false;
|
|
const fallbackAttack = {
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
range: boss ? 5.2 : 4.3,
|
|
cooldownMs: defaults.swingMs,
|
|
};
|
|
const attacks = registration.attacks
|
|
? normalizeEnemyAttacks(registration.attacks, fallbackAttack)
|
|
: existing?.attacks ?? normalizeEnemyAttacks(undefined, fallbackAttack);
|
|
const attackRange = maximumEnemyAttackRange(attacks);
|
|
const swingMs = defaults.swingMs;
|
|
const leashRange = Math.max(
|
|
attackRange + 1,
|
|
registration.leashRange ?? existing?.leashRange ?? (boss ? 55 : 42),
|
|
);
|
|
const aggroRange = Math.max(
|
|
0,
|
|
Math.min(
|
|
leashRange,
|
|
registration.aggroRange
|
|
?? existing?.aggroRange
|
|
?? (boss ? DEFAULT_BOSS_AGGRO_RANGE : DEFAULT_MOB_AGGRO_RANGE),
|
|
),
|
|
);
|
|
const moveSpeed = Math.max(
|
|
0,
|
|
registration.moveSpeed ?? existing?.moveSpeed ?? (boss ? 3.15 : 3.5),
|
|
);
|
|
const attackDamage = existing && registration.damageMultiplier !== undefined
|
|
? Math.max(1, Math.round(
|
|
existing.attackDamage * (damageMultiplier / Math.max(0.01, existing.damageMultiplier)),
|
|
))
|
|
: existing?.attackDamage ?? Math.max(1, Math.round(
|
|
defaults.attackDamage * damageMultiplier,
|
|
));
|
|
const mob: MobCombatState = existing
|
|
? {
|
|
...existing,
|
|
name: registration.name || existing.name,
|
|
serverEntry,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
attacks,
|
|
attackRange,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackDamage,
|
|
health: Math.min(existing.health, maxHealth),
|
|
xpReward,
|
|
hasLoot,
|
|
}
|
|
: {
|
|
id,
|
|
name: registration.name || "Unknown Creature",
|
|
serverEntry,
|
|
boss,
|
|
lootSourceId,
|
|
level,
|
|
armor: defaults.armor,
|
|
maxMana: defaults.maxMana,
|
|
attackPower: defaults.attackPower,
|
|
mechanicImmunities,
|
|
schoolImmunities,
|
|
resistances,
|
|
health: maxHealth,
|
|
maxHealth,
|
|
healthMultiplier,
|
|
damageMultiplier,
|
|
bonusLootChance,
|
|
dead: false,
|
|
hasLoot,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
xpReward,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle",
|
|
homePosition: getMobPosition(id),
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
attackRange,
|
|
attackDamage,
|
|
swingMs,
|
|
aggroRange,
|
|
leashRange,
|
|
moveSpeed,
|
|
attackRevision: 0,
|
|
attackAnimation: "attack",
|
|
attacks,
|
|
nextAttackIndex: 0,
|
|
lastAttackId: null,
|
|
woundRevision: 0,
|
|
nextWoundAnimationAt: 0,
|
|
deathRevision: 0,
|
|
};
|
|
return { mobs: { ...state.mobs, [id]: mob } };
|
|
}),
|
|
|
|
removeMob: (id) => set((state) => {
|
|
if (!state.mobs[id]) return state;
|
|
const mobs = { ...state.mobs };
|
|
delete mobs[id];
|
|
return {
|
|
mobs,
|
|
selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
effects: state.effects.filter((effect) => effect.targetId !== id),
|
|
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
|
};
|
|
}),
|
|
|
|
lootMob: (id, now = Date.now()) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
|
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
|
const lootMessage = mob.pendingLoot.length
|
|
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
|
: `Looted ${mob.name}.`;
|
|
set((state) => {
|
|
const bossReward = mob.boss
|
|
? {
|
|
id: ++bossLootRewardSequence,
|
|
bossId: id,
|
|
bossName: mob.name,
|
|
items: mob.pendingLoot,
|
|
acquiredAt: now,
|
|
} satisfies BossLootReward
|
|
: null;
|
|
return {
|
|
inventory: [...state.inventory, ...mob.pendingLoot],
|
|
mobs: {
|
|
...state.mobs,
|
|
[id]: {
|
|
...state.mobs[id],
|
|
looted: true,
|
|
corpseDespawnAt,
|
|
despawned: now >= corpseDespawnAt,
|
|
},
|
|
},
|
|
...(bossReward
|
|
? { bossLootRewards: [...state.bossLootRewards, bossReward] }
|
|
: { feedback: nextFeedback("loot", lootMessage, { targetId: id }) }),
|
|
};
|
|
});
|
|
return true;
|
|
},
|
|
|
|
dismissBossLootReward: (id) => set((state) => ({
|
|
bossLootRewards: id === undefined
|
|
? state.bossLootRewards.slice(1)
|
|
: state.bossLootRewards.filter((reward) => reward.id !== id),
|
|
})),
|
|
|
|
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
|
|
const state = get();
|
|
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? state.classId
|
|
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.classId;
|
|
if (!ownerClassId) {
|
|
set({ feedback: nextFeedback("error", "That party member is not available.") });
|
|
return false;
|
|
}
|
|
const change = equipInventoryItem(
|
|
state.equipment,
|
|
state.inventory,
|
|
ownerId,
|
|
ownerClassId,
|
|
instanceId,
|
|
);
|
|
if (!change.ok) {
|
|
set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") });
|
|
return false;
|
|
}
|
|
const item = state.inventory.find((candidate) => candidate.instanceId === instanceId);
|
|
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
|
? "You"
|
|
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
|
|
const gearStats = activeEquipmentStatsForClass(
|
|
change.assignments,
|
|
state.inventory,
|
|
PLAYER_EQUIPMENT_OWNER_ID,
|
|
state.classId,
|
|
);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: change.assignments,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
|
|
});
|
|
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
unequipItem: (ownerId, slot) => {
|
|
const state = get();
|
|
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
|
if (next === state.equipment) return false;
|
|
const gearStats = activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
state.talentModifiers,
|
|
gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
equipment: next,
|
|
gearStats,
|
|
maxHealth,
|
|
maxResource,
|
|
health: clamp(
|
|
state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth,
|
|
0,
|
|
maxHealth,
|
|
),
|
|
resource: clamp(
|
|
state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource,
|
|
0,
|
|
maxResource,
|
|
),
|
|
feedback: nextFeedback("success", "Item returned to the shared inventory."),
|
|
});
|
|
usePartyStore.getState().syncEquipment(next, state.inventory);
|
|
return true;
|
|
},
|
|
|
|
selectMob: (id) => {
|
|
const mob = get().mobs[id];
|
|
if (!mob || mob.dead) return false;
|
|
set((state) => ({
|
|
selectedTargetId: id,
|
|
autoAttackTargetId: state.autoAttackTargetId ? id : null,
|
|
nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id
|
|
? 0
|
|
: state.nextAutoAttackAt,
|
|
comboPoints: state.selectedTargetId === id ? state.comboPoints : 0,
|
|
feedback: nextFeedback("target", mob.name, { targetId: id }),
|
|
}));
|
|
return true;
|
|
},
|
|
|
|
selectNearestTarget: (origin, maxRange = 40) => {
|
|
const state = get();
|
|
const id = eligibleTargetIds(state, currentOrigin(state, origin), maxRange)[0] ?? null;
|
|
if (id) state.selectMob(id);
|
|
else state.clearTarget();
|
|
return id;
|
|
},
|
|
|
|
cycleTarget: (direction = "next", origin, maxRange = 40) => {
|
|
const state = get();
|
|
const ids = eligibleTargetIds(state, currentOrigin(state, origin), maxRange);
|
|
if (!ids.length) {
|
|
state.clearTarget();
|
|
return null;
|
|
}
|
|
const step = direction === "previous" || direction === -1 ? -1 : 1;
|
|
const currentIndex = state.selectedTargetId ? ids.indexOf(state.selectedTargetId) : -1;
|
|
const nextIndex = currentIndex < 0
|
|
? (step > 0 ? 0 : ids.length - 1)
|
|
: (currentIndex + step + ids.length) % ids.length;
|
|
const id = ids[nextIndex];
|
|
state.selectMob(id);
|
|
return id;
|
|
},
|
|
|
|
clearTarget: () => set((state) => ({
|
|
selectedTargetId: null,
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
comboPoints: 0,
|
|
feedback: state.selectedTargetId
|
|
? nextFeedback("target", "Target cleared.")
|
|
: state.feedback,
|
|
})),
|
|
|
|
setPlayerPosition: (position) => set((state) => {
|
|
const next: CombatPosition = [position[0], position[1], position[2]];
|
|
const cast = state.activeCast;
|
|
const movedWhileCasting = Boolean(cast && (
|
|
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
|
|
));
|
|
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
|
|
return {
|
|
playerPosition: next,
|
|
activeCast: movedWhileCasting ? null : cast,
|
|
playerAnimationEvent: movedWhileCasting
|
|
? nextAnimationEvent("ability-cancel", cast!.abilityId)
|
|
: state.playerAnimationEvent,
|
|
feedback: movedWhileCasting
|
|
? nextFeedback("error", "Casting interrupted by movement.", { abilityId: cast!.abilityId })
|
|
: state.feedback,
|
|
};
|
|
}),
|
|
|
|
castAbility: (abilityId, origin, now = Date.now(), completingCast) => {
|
|
const state = get();
|
|
const party = usePartyStore.getState();
|
|
const catalogAbility = abilityById(abilityId);
|
|
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
|
? undefined
|
|
: state.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
const ability = catalogAbility
|
|
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
|
|
: null;
|
|
const modifiers = state.talentModifiers;
|
|
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
|
const costs = ability ? abilityCosts(ability, modifiers, state) : [];
|
|
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
|
let failure: CastFailureReason | undefined;
|
|
if (!ability) failure = "unknown-ability";
|
|
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
|
|
else if (ability.talentEntryId !== undefined && !learnedTalentRank) failure = "not-learned";
|
|
else if (ability.passive) failure = "passive";
|
|
else if (ability.effects.every((effect) => (
|
|
effect.kind === "scripted" || effect.kind === "unsupported" || effect.kind === "utility"
|
|
))) failure = "unsupported";
|
|
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
|
|
else if (state.health <= 0) failure = "player-dead";
|
|
else if (state.controlledUntil > now) failure = "player-controlled";
|
|
else if (state.activeCast && !completingCast) failure = "already-casting";
|
|
else if (!completingCast && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
|
else if ((state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
|
|
else if (costs.some((cost) => cost.resource === "health"
|
|
? state.health <= cost.amount
|
|
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource";
|
|
|
|
const playerOrigin = currentOrigin(state, origin);
|
|
// `null` is a meaningful snapshotted target for friendly casts: it means
|
|
// the player. Do not fall through to a newly selected ally on completion.
|
|
let targetId = completingCast
|
|
? completingCast.targetId
|
|
: ability?.target === "hostile"
|
|
? state.selectedTargetId
|
|
: ability?.target === "friendly"
|
|
? party.selectedMemberId
|
|
: null;
|
|
let targetPosition: CombatPosition | null = null;
|
|
if (!failure && ability?.target === "hostile") {
|
|
let target = targetId ? state.mobs[targetId] : null;
|
|
if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) {
|
|
const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers);
|
|
const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers);
|
|
const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin));
|
|
return distance >= minimumRange;
|
|
});
|
|
targetId = eligible ?? null;
|
|
target = targetId ? state.mobs[targetId] : null;
|
|
}
|
|
if (!targetId || !target) failure = "no-target";
|
|
else if (target.dead) failure = "target-dead";
|
|
else {
|
|
targetPosition = getMobPosition(targetId);
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
} else if (!failure && ability?.target === "friendly" && targetId) {
|
|
const target = party.members.find((member) => member.id === targetId);
|
|
if (!target) failure = "target-unavailable";
|
|
else if (target.health <= 0 && !ability.effects.some((effect) => effect.kind === "resurrection")) {
|
|
failure = "target-dead";
|
|
}
|
|
else {
|
|
targetPosition = getPartyRuntimePosition(targetId);
|
|
if (!targetPosition) failure = "target-unavailable";
|
|
else {
|
|
const distance = Math.sqrt(partyRuntimeDistanceSquared(targetId, playerOrigin));
|
|
if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close";
|
|
else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range";
|
|
}
|
|
}
|
|
}
|
|
|
|
const autoAttackTarget = targetId ? state.mobs[targetId] : null;
|
|
const startsAutoAttack = Boolean(
|
|
ability
|
|
&& ability.target === "hostile"
|
|
&& abilityAttackKind(state.classId, ability) === "melee"
|
|
&& targetId
|
|
&& autoAttackTarget
|
|
&& !autoAttackTarget.dead
|
|
&& state.abilities.some((entry) => entry.id === ability.id)
|
|
&& !ability.passive
|
|
&& isAbilityUnlocked(ability, state.level)
|
|
&& state.health > 0,
|
|
);
|
|
const autoAttackUpdate = startsAutoAttack && targetId
|
|
? {
|
|
autoAttackTargetId: targetId,
|
|
nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now,
|
|
}
|
|
: {};
|
|
|
|
if (failure || !ability) {
|
|
const reason = failure ?? "unknown-ability";
|
|
set({
|
|
feedback: nextFeedback("error", castFailureMessage(reason, state.resourceName, ability?.unlockLevel), { abilityId }),
|
|
...autoAttackUpdate,
|
|
...(completingCast
|
|
? { playerAnimationEvent: nextAnimationEvent("ability-cancel", abilityId) }
|
|
: {}),
|
|
});
|
|
if (completingCast) emitPlayerAbilityCancellation(abilityId, now, playerOrigin);
|
|
return emptyCastResult(reason, abilityId);
|
|
}
|
|
|
|
let affectedMobIds: string[] = [];
|
|
if (ability.radius !== undefined) {
|
|
const center = ability.target === "hostile" ? targetPosition! : playerOrigin;
|
|
affectedMobIds = nearbyMobRuntimeIds(center, talentAdjustedRange(ability.radius, ability, modifiers)).filter((id) => {
|
|
const mob = state.mobs[id];
|
|
return Boolean(mob && !mob.dead);
|
|
});
|
|
} else if (ability.target === "hostile" && targetId) {
|
|
affectedMobIds = [targetId];
|
|
}
|
|
|
|
const castTimeMs = talentAdjustedCastTime(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const gcdMs = talentAdjustedGcd(
|
|
ability,
|
|
modifiers,
|
|
state.gearStats,
|
|
state.level,
|
|
state.classId,
|
|
);
|
|
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
|
const spentPools = { ...state.resourcePools };
|
|
let healthAfterCosts = state.health;
|
|
for (const cost of costsToSpend) {
|
|
if (cost.resource === "health") healthAfterCosts = Math.max(1, healthAfterCosts - cost.amount);
|
|
else spentPools[cost.resource] = Math.max(0, (spentPools[cost.resource] ?? 0) - cost.amount);
|
|
}
|
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
|
if (!completingCast && castTimeMs > 0) {
|
|
castSequence += 1;
|
|
const activeCast: ActiveCast = {
|
|
id: castSequence,
|
|
abilityId: ability.id,
|
|
targetId,
|
|
mode: ability.castMode === "channel" ? "channel" : "cast",
|
|
startedAt: now,
|
|
completesAt: now + castTimeMs,
|
|
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
|
totalTicks: ability.castMode === "channel" ? 5 : 0,
|
|
ticksCompleted: 0,
|
|
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / 5 : 0,
|
|
};
|
|
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
|
|
if (activeCast.mode === "channel" && cooldownMs > 0) {
|
|
channelCooldowns[ability.id] = now + cooldownMs;
|
|
}
|
|
set({
|
|
activeCast,
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-start", ability.id),
|
|
health: activeCast.mode === "channel" ? healthAfterCosts : state.health,
|
|
resource: activeCast.mode === "channel" ? activeResourceAfterCosts : state.resource,
|
|
resourcePools: activeCast.mode === "channel" ? spentPools : state.resourcePools,
|
|
cooldowns: channelCooldowns,
|
|
globalCooldownEndsAt: now + gcdMs,
|
|
selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId,
|
|
feedback: nextFeedback(
|
|
"success",
|
|
`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${ability.name}...`,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined },
|
|
),
|
|
});
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"cast-start",
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
return { ...emptyCastResult(), ok: true, abilityId: ability.id };
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
health: healthAfterCosts,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: activeResourceAfterCosts,
|
|
maxResource: state.maxResource,
|
|
resourcePools: spentPools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
comboPoints: state.comboPoints,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: modifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const castThreatSource = playerThreatSource(state, ability.id);
|
|
|
|
const hasHostileEffect = ability.effects.some((effect) => (
|
|
effect.kind === "damage"
|
|
|| effect.kind === "finisher-damage"
|
|
|| effect.kind === "dot"
|
|
|| effect.kind === "taunt"
|
|
|| effect.kind === "status"
|
|
|| (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")
|
|
|| effect.kind === "interrupt"
|
|
|| effect.kind === "pull"
|
|
|| effect.kind === "execute"
|
|
|| effect.kind === "resource-drain"
|
|
|| effect.kind === "threat"
|
|
));
|
|
if (hasHostileEffect) {
|
|
for (const id of affectedMobIds) engageMobGroupInWork(work, id, now, castThreatSource.actorId);
|
|
}
|
|
|
|
const damageSchool = abilityDamageSchool(state.classId, ability);
|
|
const attackKind = abilityAttackKind(state.classId, ability);
|
|
let playerPositionAfterEffects: CombatPosition = state.playerPosition;
|
|
let clearedPlayerControl = false;
|
|
const cooldownAdjustments: Array<Extract<AbilityEffect, { kind: "cooldown-reset" }>> = [];
|
|
for (const effect of ability.effects) {
|
|
if (effect.kind === "damage") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
|
ability,
|
|
);
|
|
}
|
|
} else if (effect.kind === "finisher-damage") {
|
|
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
coefficient,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:finisher`,
|
|
ability,
|
|
);
|
|
}
|
|
work.comboPoints = 0;
|
|
} else if (effect.kind === "heal") {
|
|
const friendlyTarget = ability.target === "friendly" && targetId
|
|
? usePartyStore.getState().members.find((member) => member.id === targetId)
|
|
: null;
|
|
const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? work.maxHealth;
|
|
const unmodifiedAmount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
)
|
|
+ Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
const healingTargetId = friendlyTarget && targetId ? targetId : PLAYER_AURA_ENTITY_ID;
|
|
const amount = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
unmodifiedAmount,
|
|
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, healingTargetId),
|
|
"taken",
|
|
));
|
|
const healed = friendlyTarget && targetId
|
|
? usePartyStore.getState().healMember(targetId, amount)
|
|
: Math.min(amount, work.maxHealth - work.health);
|
|
if (!friendlyTarget) {
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
}
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, castThreatSource, healed);
|
|
if (healed > 0) {
|
|
evaluateProcsInWork(work, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: healingTargetId,
|
|
abilityId: ability.id,
|
|
amount: healed,
|
|
}, now);
|
|
}
|
|
} else if (effect.kind === "resurrection") {
|
|
const restored = targetId
|
|
? usePartyStore.getState().reviveMember(targetId, effect.percentMaxHealth)
|
|
: 0;
|
|
work.totalHealing += restored;
|
|
addHealingThreatInWork(work, castThreatSource, restored);
|
|
} else if (effect.kind === "summon") {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-summon-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: now + effect.durationMs,
|
|
});
|
|
if (ability.target === "hostile") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"damage-percent",
|
|
ability,
|
|
);
|
|
for (const id of affectedMobIds) {
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
castThreatSource,
|
|
attackKind,
|
|
damageSchool,
|
|
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:summon`,
|
|
ability,
|
|
);
|
|
}
|
|
}
|
|
} else if (effect.kind === "shield") {
|
|
const amount = talentAdjustedAmount(
|
|
equipmentAdjustedCombatAmount(
|
|
effect,
|
|
work.progression.level,
|
|
work.gearStats,
|
|
state.classId,
|
|
ability,
|
|
),
|
|
modifiers,
|
|
"healing-percent",
|
|
ability,
|
|
);
|
|
work.shield = Math.max(work.shield, amount);
|
|
work.shieldExpiresAt = now + talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
} else if (effect.kind === "resource") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const targetAuras = aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID);
|
|
const amount = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(effect.amount, targetAuras, resource, "generation"),
|
|
targetAuras,
|
|
"power",
|
|
"generation",
|
|
);
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + amount, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
} else if (effect.kind === "combo") {
|
|
work.comboPoints = clamp(work.comboPoints + effect.amount, 0, 5);
|
|
} else if (effect.kind === "dot") {
|
|
for (const id of affectedMobIds) {
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "dot"
|
|
&& entry.targetId === id
|
|
&& (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id)
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"dot",
|
|
ability.id,
|
|
castThreatSource.actorId,
|
|
id,
|
|
effect,
|
|
effect.ticks,
|
|
effect.intervalMs,
|
|
now,
|
|
effect.tag,
|
|
damageSchool,
|
|
work.gearStats.spellPenetration,
|
|
));
|
|
}
|
|
} else if (effect.kind === "hot") {
|
|
const hotTargetId = ability.target === "friendly" ? targetId : null;
|
|
work.effects = work.effects.filter((entry) => !(
|
|
entry.kind === "hot"
|
|
&& entry.sourceAbilityId === ability.id
|
|
&& entry.targetId === hotTargetId
|
|
));
|
|
work.effects.push(timedEffect(
|
|
"hot", ability.id, castThreatSource.actorId, hotTargetId, effect, effect.ticks, effect.intervalMs, now,
|
|
));
|
|
} else if (effect.kind === "taunt") {
|
|
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
|
const durationMs = talentAdjustedDuration(effect.durationMs, ability, modifiers);
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: tauntThreat(mob.threatByActor, castThreatSource.actorId),
|
|
targetActorId: castThreatSource.actorId,
|
|
forcedTarget: { actorId: castThreatSource.actorId, endsAt: now + durationMs },
|
|
};
|
|
}
|
|
} else if (effect.kind === "status") {
|
|
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
|
for (const id of candidates) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
...effect,
|
|
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "apply-aura") {
|
|
const durationMs = effect.aura.durationMs === null
|
|
? null
|
|
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
|
|
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
|
const recipients = effect.recipient === "caster"
|
|
? [PLAYER_AURA_ENTITY_ID]
|
|
: ability.target === "hostile"
|
|
? affectedMobIds
|
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
|
for (const recipientId of recipients) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: auraDefinition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: recipientId,
|
|
now,
|
|
}).auras];
|
|
if (effect.control && work.mobs[recipientId]) {
|
|
const durationMs = auraDefinition.durationMs ?? 1_000;
|
|
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
|
|
kind: "status",
|
|
status: effect.control.kind as MobStatusKind,
|
|
durationMs,
|
|
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
|
}, `aura:${auraDefinition.id}`, now);
|
|
}
|
|
}
|
|
} else if (effect.kind === "dispel") {
|
|
const primaryRecipientId = ability.target === "hostile"
|
|
? targetId
|
|
: targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const recipientIds = effect.scope === "party"
|
|
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)]
|
|
: primaryRecipientId ? [primaryRecipientId] : [];
|
|
for (const recipientId of recipientIds) {
|
|
const relationship = effect.relationship === "target"
|
|
? (work.mobs[recipientId] ? "hostile" : "friendly")
|
|
: effect.relationship;
|
|
const targeted = work.auras.filter((aura) => aura.targetId === recipientId);
|
|
const untouched = work.auras.filter((aura) => aura.targetId !== recipientId);
|
|
const result = dispelCombatAuras(targeted, {
|
|
relationship,
|
|
categories: effect.categories,
|
|
maxCount: effect.maxCount,
|
|
});
|
|
work.auras = [...untouched, ...result.auras];
|
|
if (result.removed.length > 0 && work.mobs[recipientId]) {
|
|
const removedTokens = new Set(result.removed.map((removed) => `aura:${removed.definition.id}`));
|
|
work.mobs[recipientId] = {
|
|
...work.mobs[recipientId],
|
|
statuses: work.mobs[recipientId].statuses.filter((status) => !removedTokens.has(status.sourceAbilityId)),
|
|
auras: work.mobs[recipientId].auras.filter((legacy) => !result.removed.some((removed) => (
|
|
removed.definition.id === `enemy:${recipientId}:${legacy.id}`
|
|
))),
|
|
};
|
|
}
|
|
if (
|
|
recipientId === PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) clearedPlayerControl = true;
|
|
if (
|
|
recipientId !== PLAYER_AURA_ENTITY_ID
|
|
&& result.removed.some((removed) => removed.definition.id.startsWith("enemy-control:"))
|
|
) usePartyStore.getState().clearMemberControl(recipientId);
|
|
if (effect.mode === "steal") {
|
|
for (const removed of result.removed) {
|
|
work.auras = [...applyCombatAura(work.auras, {
|
|
definition: { ...removed.definition, disposition: "buff" },
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now,
|
|
}).auras];
|
|
}
|
|
}
|
|
}
|
|
} else if (effect.kind === "interrupt") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = applyStatus(mob, {
|
|
kind: "status",
|
|
status: "interrupt",
|
|
durationMs: effect.lockoutMs,
|
|
}, ability.id, now);
|
|
}
|
|
} else if (effect.kind === "pull") {
|
|
if (ability.target === "friendly" && targetId && effect.toward === "caster") {
|
|
updatePartyRuntimePosition(
|
|
targetId,
|
|
state.playerPosition[0],
|
|
state.playerPosition[1],
|
|
state.playerPosition[2],
|
|
);
|
|
}
|
|
for (const id of affectedMobIds) {
|
|
const currentPosition = getMobPosition(id);
|
|
if (!currentPosition) continue;
|
|
if (effect.toward === "caster") {
|
|
const dx = currentPosition[0] - state.playerPosition[0];
|
|
const dz = currentPosition[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [
|
|
state.playerPosition[0] + dx / length * 1.5,
|
|
currentPosition[1],
|
|
state.playerPosition[2] + dz / length * 1.5,
|
|
]);
|
|
}
|
|
}
|
|
} else if (effect.kind === "execute") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
Math.max(mob.health, mob.maxHealth * effect.coefficient),
|
|
now,
|
|
castThreatSource,
|
|
damageSchool,
|
|
);
|
|
}
|
|
} else if (effect.kind === "resource-drain") {
|
|
const resource = effect.resource ?? activeResourceType;
|
|
const maximum = work.maxResourcePools[resource]
|
|
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
|
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
|
if (ability.target === "hostile") {
|
|
const returned = effect.burn ? 0 : drainAmount;
|
|
if (returned > 0) {
|
|
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else {
|
|
work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount);
|
|
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
|
}
|
|
} else if (effect.kind === "threat") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (!mob || mob.dead) continue;
|
|
const currentThreat = mob.threatByActor[castThreatSource.actorId] ?? 0;
|
|
const nextThreat = effect.mode === "redirect"
|
|
? 0
|
|
: effect.mode === "percent"
|
|
? currentThreat * Math.max(0, 1 + effect.amount / 100)
|
|
: Math.max(0, currentThreat + effect.amount);
|
|
work.mobs[id] = {
|
|
...mob,
|
|
threatByActor: { ...mob.threatByActor, [castThreatSource.actorId]: nextThreat },
|
|
};
|
|
}
|
|
} else if (effect.kind === "movement") {
|
|
if (effect.movement === "sanctuary") {
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const threatByActor = { ...mob.threatByActor, [castThreatSource.actorId]: 0 };
|
|
work.mobs[id] = { ...mob, threatByActor };
|
|
}
|
|
} else if (effect.movement === "knockback") {
|
|
for (const id of affectedMobIds) {
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const dx = position[0] - state.playerPosition[0];
|
|
const dz = position[2] - state.playerPosition[2];
|
|
const length = Math.max(0.001, Math.hypot(dx, dz));
|
|
setMobPosition(id, [position[0] + dx / length * 4, position[1], position[2] + dz / length * 4]);
|
|
}
|
|
} else if (targetPosition) {
|
|
playerPositionAfterEffects = [targetPosition[0], targetPosition[1], targetPosition[2]];
|
|
}
|
|
} else if (effect.kind === "dispel-mechanic") {
|
|
for (const id of affectedMobIds) {
|
|
const mob = work.mobs[id];
|
|
if (mob) work.mobs[id] = { ...mob, statuses: mob.statuses.slice(effect.maxCount) };
|
|
}
|
|
} else if (effect.kind === "pet") {
|
|
if ((effect.action === "summon" || effect.action === "call" || effect.action === "tame") && effect.creatureId) {
|
|
summonSequence += 1;
|
|
work.summons.push({
|
|
id: `player-pet-${summonSequence}`,
|
|
sourceAbilityId: ability.id,
|
|
creatureId: effect.creatureId,
|
|
targetId,
|
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
});
|
|
} else if (effect.action === "dismiss") {
|
|
work.summons = work.summons.filter((summon) => summon.sourceAbilityId !== ability.id);
|
|
}
|
|
} else if (effect.kind === "totem") {
|
|
work.summons = [];
|
|
} else if (effect.kind === "rune") {
|
|
const maximum = work.maxResourcePools["runic-power"] ?? 100;
|
|
work.resourcePools["runic-power"] = clamp((work.resourcePools["runic-power"] ?? 0) + effect.count * 10, 0, maximum);
|
|
if (activeResourceType === "runic-power") work.resource = work.resourcePools["runic-power"] ?? work.resource;
|
|
} else if (effect.kind === "cooldown-reset") {
|
|
cooldownAdjustments.push(effect);
|
|
} else if (effect.kind === "trigger-spell") {
|
|
if (effect.spellId !== ability.dbcSpellId) {
|
|
executeTriggeredSpellInWork(
|
|
work,
|
|
effect.spellId,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
targetId,
|
|
now,
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
evaluateProcsInWork(work, {
|
|
trigger: "cast",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: targetId ?? undefined,
|
|
abilityId: ability.id,
|
|
}, now);
|
|
|
|
// Judgement of Light restores 2% health when the marked target is struck.
|
|
if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) {
|
|
const healed = Math.min(talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability), work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(work, castThreatSource, healed);
|
|
}
|
|
|
|
const cooldowns: Record<string, number> = { ...state.cooldowns };
|
|
if (cooldownMs > 0) cooldowns[ability.id] = now + cooldownMs;
|
|
if (ability.cooldownGroup) {
|
|
for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) {
|
|
cooldowns[grouped.id] = now + Math.max(cooldownMs, talentAdjustedCooldown(grouped, modifiers));
|
|
}
|
|
}
|
|
for (const adjustment of cooldownAdjustments) {
|
|
const candidates = adjustment.abilityName
|
|
? state.abilities.filter((candidate) => candidate.name.toLowerCase() === adjustment.abilityName!.toLowerCase())
|
|
: state.abilities;
|
|
for (const candidate of candidates) {
|
|
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
|
|
else cooldowns[candidate.id] = Math.max(now, (cooldowns[candidate.id] ?? now) - (adjustment.amountMs ?? 0));
|
|
}
|
|
}
|
|
|
|
const primaryCombatResult = work.combatResults.length === 1 ? work.combatResults[0] : null;
|
|
const levelMessage = work.progression.levelsGained > 0
|
|
? levelUpMessage(state.classId, state.level, work.progression.level)
|
|
: primaryCombatResult
|
|
? `${ability.name} ${combatResultDescription(primaryCombatResult)}.`
|
|
: `${ability.name}${work.totalDamage ? ` dealt ${work.totalDamage}` : ""}${work.totalHealing ? ` healed ${work.totalHealing}` : ""}.`;
|
|
set({
|
|
level: work.progression.level,
|
|
xp: work.progression.xp,
|
|
xpToNext: work.progression.xpToNext,
|
|
health: work.progression.levelsGained > 0 ? work.progression.health : work.health,
|
|
maxHealth: work.progression.maxHealth,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [activeResourceType]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
talentModifiers: work.talentModifiers,
|
|
comboPoints: work.comboPoints,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
cooldowns,
|
|
globalCooldownEndsAt: completingCast ? state.globalCooldownEndsAt : now + gcdMs,
|
|
activeCast: null,
|
|
...autoAttackUpdate,
|
|
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
|
selectedTargetId: ability.target === "hostile" ? targetId : state.selectedTargetId,
|
|
playerPosition: playerPositionAfterEffects,
|
|
...(clearedPlayerControl ? { controlledUntil: 0, controlMechanic: null } : {}),
|
|
feedback: nextFeedback(
|
|
work.progression.levelsGained > 0 ? "level-up" : "success",
|
|
levelMessage,
|
|
{ abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing },
|
|
),
|
|
});
|
|
emitPlayerAbilityResolutionPresentation(
|
|
ability,
|
|
now,
|
|
playerOrigin,
|
|
targetId,
|
|
targetPosition,
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
abilityId: ability.id,
|
|
affectedMobIds,
|
|
damage: work.totalDamage,
|
|
healing: work.totalHealing,
|
|
levelsGained: work.progression.levelsGained,
|
|
};
|
|
},
|
|
|
|
castSlot: (index, origin, now) => {
|
|
const ability = get().abilities[Math.trunc(index)];
|
|
if (!ability) {
|
|
set({ feedback: nextFeedback("error", "That action-bar slot is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
return get().castAbility(ability.id, origin, now);
|
|
},
|
|
|
|
castActionBinding: (layer, control, origin, now) => {
|
|
const abilityId = get().actionBindings[actionBindingId(layer, control)];
|
|
if (!abilityId) {
|
|
set({ feedback: nextFeedback("error", "That controller binding is empty.") });
|
|
return emptyCastResult("unknown-ability");
|
|
}
|
|
return get().castAbility(abilityId, origin, now);
|
|
},
|
|
|
|
damageMob: (id, amount, now = Date.now(), source, school = "physical", rollContext) => {
|
|
const state = get();
|
|
const threatSource = source ?? playerThreatSource(state);
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [...state.effects],
|
|
auras: [...state.auras],
|
|
summons: [...state.summons],
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
shield: state.shield,
|
|
shieldExpiresAt: state.shieldExpiresAt,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools },
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
comboPoints: state.comboPoints,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
const result = rollContext
|
|
? resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
rollContext.attackKind,
|
|
school,
|
|
rollContext.seed,
|
|
rollContext.abilityId ? abilityById(rollContext.abilityId) ?? undefined : undefined,
|
|
rollContext.periodic,
|
|
rollContext.rules,
|
|
)
|
|
: damageMobInWork(
|
|
state.classId,
|
|
work,
|
|
id,
|
|
amount,
|
|
now,
|
|
threatSource,
|
|
school,
|
|
state.gearStats.spellPenetration,
|
|
);
|
|
set({
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
health: progression.health,
|
|
maxHealth: progression.maxHealth,
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
talentModifiers: work.talentModifiers,
|
|
lastCombatResult: result.combatResult ?? state.lastCombatResult,
|
|
selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId,
|
|
autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt,
|
|
comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.comboPoints,
|
|
feedback: result.levelsGained > 0
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: result.combatResult
|
|
? nextFeedback("damage", `${state.mobs[id]?.name ?? "Enemy"} ${combatResultDescription(result.combatResult)}.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
})
|
|
: nextFeedback("damage", result.killed ? `${state.mobs[id]?.name ?? "Enemy"} defeated.` : `${result.amount} damage.`, {
|
|
targetId: id,
|
|
amount: result.amount,
|
|
abilityId: threatSource.abilityId,
|
|
}),
|
|
});
|
|
return result;
|
|
},
|
|
|
|
addHealingThreat: (source, effectiveHealing) => set((state) => {
|
|
const engagedIds = Object.values(state.mobs)
|
|
.filter((mob) => mob.engaged && !mob.dead)
|
|
.map((mob) => mob.id);
|
|
const perMob = threatFromHealing(effectiveHealing, engagedIds.length) * (source.threatScale ?? 1);
|
|
if (perMob <= 0) return state;
|
|
const mobs = { ...state.mobs };
|
|
for (const id of engagedIds) {
|
|
const mob = mobs[id];
|
|
mobs[id] = {
|
|
...mob,
|
|
threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob),
|
|
};
|
|
}
|
|
return { mobs };
|
|
}),
|
|
|
|
tauntMob: (id, source, durationMs, now = Date.now()) => {
|
|
const target = get().mobs[id];
|
|
if (!target || target.dead || durationMs <= 0) return false;
|
|
set((state) => {
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
const groupId = mobGroupId(id);
|
|
const groupWasEngaged = Object.entries(mobs).some(([mobId, mob]) => (
|
|
mobGroupId(mobId) === groupId && mob.engaged && !mob.dead
|
|
));
|
|
for (const [mobId, mob] of Object.entries(mobs)) {
|
|
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
|
const seeded = groupWasEngaged
|
|
? mob.threatByActor
|
|
: seedActorThreat(mob.threatByActor, source.actorId);
|
|
mobs[mobId] = {
|
|
...mob,
|
|
threatByActor: mobId === id ? tauntThreat(seeded, source.actorId) : seeded,
|
|
targetActorId: mobId === id
|
|
? source.actorId
|
|
: (mob.targetActorId ?? (groupWasEngaged ? null : source.actorId)),
|
|
forcedTarget: mobId === id
|
|
? { actorId: source.actorId, endsAt: now + Math.max(1, durationMs) }
|
|
: mob.forcedTarget,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
homePosition: groupWasEngaged
|
|
? (mob.homePosition ?? getMobPosition(mobId))
|
|
: (getMobPosition(mobId) ?? mob.homePosition),
|
|
abilityReadyAt: Object.keys(mob.abilityReadyAt).length
|
|
? mob.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(mob, now),
|
|
nextAttackAt: mob.nextAttackAt > now ? mob.nextAttackAt : now + 450,
|
|
};
|
|
}
|
|
return { mobs };
|
|
});
|
|
return true;
|
|
},
|
|
|
|
damagePlayer: (amount, school = "physical", attackerLevel) => {
|
|
const state = get();
|
|
const mitigated = damageAfterEquipmentMitigation(
|
|
talentAdjustedIncomingDamage(amount, state.talentModifiers),
|
|
state.gearStats,
|
|
state.level,
|
|
school,
|
|
attackerLevel ?? state.level,
|
|
);
|
|
const incoming = resolveAuraDamageValue(
|
|
mitigated,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school, attackKind: "spell" },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(state.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: incoming,
|
|
school,
|
|
now: Date.now(),
|
|
});
|
|
const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed);
|
|
const health = state.health - healthDamage;
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
healthDamage > 0 && hasRomWarriorRagePool(state.classId, state.secondaryClassId)
|
|
? ROM_RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
state.resource,
|
|
state.maxResource,
|
|
{ ...state.resourcePools },
|
|
{ ...state.maxResourcePools },
|
|
);
|
|
const procResult = evaluateAuraProcs(namedAbsorb.auras, {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healthDamage,
|
|
school,
|
|
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`));
|
|
set({
|
|
auras: procResult.auras,
|
|
shield: state.shield - absorbed,
|
|
health,
|
|
resource: rage.resource,
|
|
resourcePools: rage.resourcePools,
|
|
activeCast: health <= 0 ? null : state.activeCast,
|
|
autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId,
|
|
nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(health <= 0 ? "death" : "wound")
|
|
: state.playerAnimationEvent,
|
|
feedback: nextFeedback("damage", health <= 0 ? "You have been defeated." : `${healthDamage} damage taken.`, { amount: healthDamage }),
|
|
});
|
|
return healthDamage;
|
|
},
|
|
|
|
healPlayer: (amount) => {
|
|
const state = get();
|
|
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
|
// Resurrection is an explicit combat action handled by revivePlayer.
|
|
if (state.health <= 0) return 0;
|
|
const modified = resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
|
"taken",
|
|
);
|
|
const healed = Math.min(Math.max(0, Math.round(modified)), state.maxHealth - state.health);
|
|
const procResult = evaluateAuraProcs(state.auras, {
|
|
trigger: "heal",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: PLAYER_AURA_ENTITY_ID,
|
|
amount: healed,
|
|
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
|
|
set({ auras: procResult.auras, health: state.health + healed, feedback: nextFeedback("heal", `${healed} health restored.`, { amount: healed }) });
|
|
return healed;
|
|
},
|
|
|
|
revivePlayer: (percentMaxHealth = 0.35) => {
|
|
const state = get();
|
|
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
|
const restored = Math.max(1, Math.round(
|
|
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
|
));
|
|
set({
|
|
health: restored,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
|
});
|
|
return restored;
|
|
},
|
|
|
|
cancelCast: (message = "Casting interrupted.") => {
|
|
const cast = get().activeCast;
|
|
if (!cast) return false;
|
|
set({
|
|
activeCast: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
|
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
|
|
});
|
|
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
|
|
return true;
|
|
},
|
|
|
|
engageNearbyMobs: (now = Date.now(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let engagedGroups = 0;
|
|
for (const id of Object.keys(mobs)) {
|
|
const mob = mobs[id];
|
|
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
|
if (!actorId) continue;
|
|
if (engageMobGroup(mobs, id, now, actorId)) engagedGroups += 1;
|
|
}
|
|
if (engagedGroups > 0) set({ mobs });
|
|
return engagedGroups;
|
|
},
|
|
|
|
advanceMobCombat: (now = Date.now(), playerPosition) => {
|
|
const state = get();
|
|
const runtimeState = playerPosition
|
|
? { ...state, playerPosition }
|
|
: state;
|
|
const mobs: Record<string, MobCombatState> = { ...state.mobs };
|
|
let namedAuras = [...state.auras];
|
|
const incomingHits: Array<{
|
|
readonly actorId: AggroActorId;
|
|
readonly amount: number;
|
|
readonly mob: MobCombatState;
|
|
readonly attack: EnemyAttackDefinition;
|
|
readonly attackRevision: number;
|
|
}> = [];
|
|
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
|
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
|
const memberControls: Array<{
|
|
readonly memberId: string;
|
|
readonly mechanic: EnemyControlMechanic;
|
|
readonly endsAt: number;
|
|
}> = [];
|
|
let changed = false;
|
|
|
|
for (const [id, mob] of Object.entries(state.mobs)) {
|
|
if (!mob.engaged || mob.dead) continue;
|
|
const position = getMobPosition(id);
|
|
if (!position) continue;
|
|
const home = mob.homePosition ?? position;
|
|
const eligible = eligibleAggroActorIds(runtimeState, { ...mob, homePosition: home });
|
|
const threatByActor = pruneThreatTable(mob.threatByActor, eligible);
|
|
const resolution = resolveAggroTarget(
|
|
threatByActor,
|
|
eligible,
|
|
mob.targetActorId,
|
|
mob.forcedTarget,
|
|
now,
|
|
);
|
|
const targetPosition = resolution.targetActorId
|
|
? actorPosition(runtimeState, resolution.targetActorId)
|
|
: null;
|
|
if (!resolution.targetActorId || !targetPosition) {
|
|
mobs[id] = {
|
|
...mob,
|
|
engaged: false,
|
|
combatPhase: "returning",
|
|
nextAttackAt: 0,
|
|
health: mob.maxHealth,
|
|
statuses: [],
|
|
auras: [],
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
};
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
const activeStatuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const cannotAttack = activeStatuses.some((status) => [
|
|
"stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse",
|
|
].includes(status.kind));
|
|
const silenced = activeStatuses.some((status) => status.kind === "silence");
|
|
const disarmed = activeStatuses.some((status) => status.kind === "disarm");
|
|
const rooted = activeStatuses.some((status) => status.kind === "root");
|
|
const dx = position[0] - targetPosition[0];
|
|
const dy = position[1] - targetPosition[1];
|
|
const dz = position[2] - targetPosition[2];
|
|
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
|
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
|
? { ...mob.abilityReadyAt }
|
|
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
|
const abilityCastCounts = { ...mob.abilityCastCounts };
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
|
? null
|
|
: mob.activeCast;
|
|
let completingEnemyCast = false;
|
|
let selectedAttackIndex = -1;
|
|
let selectedAttack: EnemyAttackDefinition | null = null;
|
|
if (enemyActiveCast && now >= enemyActiveCast.completesAt) {
|
|
selectedAttackIndex = mob.attacks.findIndex((attack) => attack.id === enemyActiveCast!.attackId);
|
|
selectedAttack = selectedAttackIndex >= 0 ? mob.attacks[selectedAttackIndex] : null;
|
|
completingEnemyCast = selectedAttack !== null;
|
|
if (!selectedAttack) enemyActiveCast = null;
|
|
} else if (!enemyActiveCast && !cannotAttack) {
|
|
for (let offset = 0; offset < mob.attacks.length; offset += 1) {
|
|
const index = (mob.nextAttackIndex + offset) % mob.attacks.length;
|
|
const attack = mob.attacks[index];
|
|
const attackKind = enemyAttackKind(attack);
|
|
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
|
|| distanceSquared <= attack.range * attack.range;
|
|
if (
|
|
inAbilityRange
|
|
&& !(silenced && attackKind === "spell")
|
|
&& !(disarmed && attackKind === "melee")
|
|
&& now >= (abilityReadyAt[attack.id] ?? 0)
|
|
&& now >= (schoolLockedUntil[attack.school] ?? 0)
|
|
&& (!attack.maximumCasts || (abilityCastCounts[attack.id] ?? 0) < attack.maximumCasts)
|
|
&& enemyAbilityConditionMet(mob, mobs, attack)
|
|
) {
|
|
selectedAttackIndex = index;
|
|
selectedAttack = attack;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const inRange = mob.attacks.some((attack) => (
|
|
!enemyAbilityNeedsHostileRange(attack)
|
|
|| distanceSquared <= attack.range * attack.range
|
|
));
|
|
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
|
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
let attackRevision = mob.attackRevision;
|
|
let attackAnimation = mob.attackAnimation;
|
|
let nextAttackIndex = mob.nextAttackIndex;
|
|
let lastAttackId = mob.lastAttackId;
|
|
let activeAuras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const startingEnemyCast = Boolean(
|
|
selectedAttack
|
|
&& !completingEnemyCast
|
|
&& (selectedAttack.castTimeMs ?? 0) > 0,
|
|
);
|
|
if (selectedAttack && startingEnemyCast) {
|
|
const attack = selectedAttack;
|
|
enemyActiveCast = {
|
|
attackId: attack.id,
|
|
name: attack.name,
|
|
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
|
school: attack.school,
|
|
interruptible: attack.interruptible !== false,
|
|
targetActorId: resolution.targetActorId,
|
|
startedAt: now,
|
|
completesAt: now + (attack.castTimeMs ?? 0),
|
|
};
|
|
attackAnimation = "cast";
|
|
lastAttackId = attack.id;
|
|
nextAttackAt = enemyActiveCast.completesAt;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"cast-start",
|
|
now,
|
|
position,
|
|
resolution.targetActorId,
|
|
targetPosition,
|
|
);
|
|
} else if (selectedAttack && !cannotAttack) {
|
|
const attack = selectedAttack!;
|
|
enemyActiveCast = null;
|
|
const effect = attack.effect ?? {
|
|
kind: "damage" as const,
|
|
multiplier: attack.damageMultiplier,
|
|
};
|
|
const eligibleActors = [...eligible].sort();
|
|
const randomActor = eligibleActors.length
|
|
? eligibleActors[Math.floor(
|
|
stableCombatUnit(`${id}:${attack.id}:${attackRevision}:target`) * eligibleActors.length,
|
|
)]
|
|
: resolution.targetActorId;
|
|
const targets = attack.target === "nearby-party"
|
|
? eligibleActors.filter((actorId) => {
|
|
const candidate = actorPosition(runtimeState, actorId);
|
|
if (!candidate) return false;
|
|
const radius = attack.radius ?? Math.max(1, attack.range * 0.35);
|
|
const targetDx = candidate[0] - position[0];
|
|
const targetDy = candidate[1] - position[1];
|
|
const targetDz = candidate[2] - position[2];
|
|
return targetDx * targetDx + targetDy * targetDy + targetDz * targetDz
|
|
<= radius * radius;
|
|
})
|
|
: attack.target === "random-party"
|
|
? [randomActor]
|
|
: attack.target === "primary"
|
|
? [resolution.targetActorId]
|
|
: [];
|
|
const presentationTargetId = targets[0] ?? resolution.targetActorId;
|
|
emitEnemyAttackPresentation(
|
|
mob,
|
|
attack,
|
|
"release",
|
|
now,
|
|
position,
|
|
presentationTargetId,
|
|
actorPosition(runtimeState, presentationTargetId),
|
|
);
|
|
if (effect.kind === "damage") {
|
|
const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
|
|
for (const actorId of targets) {
|
|
incomingHits.push({
|
|
actorId,
|
|
amount: attackDamage,
|
|
mob,
|
|
attack,
|
|
attackRevision,
|
|
});
|
|
}
|
|
} else if (effect.kind === "heal") {
|
|
const requiredMissingHealth = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.amount
|
|
: 1;
|
|
const friendlyRange = attack.condition?.kind === "friendly-missing-health"
|
|
? attack.condition.range
|
|
: attack.range;
|
|
const friendly = Object.values(mobs)
|
|
.filter((candidate) => {
|
|
if (candidate.dead || candidate.health > candidate.maxHealth - requiredMissingHealth) {
|
|
return false;
|
|
}
|
|
const candidatePosition = getMobPosition(candidate.id);
|
|
if (!candidatePosition) return false;
|
|
const friendlyDx = candidatePosition[0] - position[0];
|
|
const friendlyDy = candidatePosition[1] - position[1];
|
|
const friendlyDz = candidatePosition[2] - position[2];
|
|
return friendlyDx * friendlyDx + friendlyDy * friendlyDy + friendlyDz * friendlyDz
|
|
<= friendlyRange * friendlyRange;
|
|
})
|
|
.sort((left, right) => (
|
|
(left.health / Math.max(1, left.maxHealth))
|
|
- (right.health / Math.max(1, right.maxHealth))
|
|
|| left.id.localeCompare(right.id)
|
|
))[0];
|
|
if (friendly) {
|
|
enemyHealing.push({
|
|
targetId: friendly.id,
|
|
amount: Math.max(1, Math.round(mob.attackDamage * effect.multiplier)),
|
|
});
|
|
}
|
|
} else if (effect.kind === "aura") {
|
|
const endsAt = effect.durationMs > 0 ? now + effect.durationMs : 0;
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => aura.id !== effect.aura),
|
|
{
|
|
id: effect.aura,
|
|
sourceAbilityId: attack.id,
|
|
endsAt,
|
|
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
|
},
|
|
];
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy:${id}:${effect.aura}`,
|
|
name: `${attack.name}: ${effect.aura}`,
|
|
disposition: "buff",
|
|
durationMs: effect.durationMs > 0 ? effect.durationMs : null,
|
|
dispelCategory: effect.aura === "enrage" ? "enrage" : "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: id,
|
|
now,
|
|
}).auras];
|
|
} else if (effect.kind === "control") {
|
|
for (const actorId of targets) {
|
|
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
|
|
if (actorId === PLAYER_AGGRO_ID) playerControl = control;
|
|
else memberControls.push({ memberId: actorId, ...control });
|
|
namedAuras = [...applyCombatAura(namedAuras, {
|
|
definition: {
|
|
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
|
|
name: `${attack.name}: ${effect.mechanic}`,
|
|
disposition: "debuff",
|
|
durationMs: effect.durationMs,
|
|
dispelCategory: "magic",
|
|
},
|
|
sourceId: id,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras];
|
|
}
|
|
} else if (effect.kind === "transform") {
|
|
activeAuras = [
|
|
...activeAuras.filter((aura) => !aura.id.startsWith("form:")),
|
|
{
|
|
id: `form:${effect.form}`,
|
|
sourceAbilityId: attack.id,
|
|
endsAt: 0,
|
|
},
|
|
];
|
|
} else if (effect.kind === "call-for-help") {
|
|
for (const helperId of nearbyMobRuntimeIds(position, effect.radius)) {
|
|
const helper = mobs[helperId];
|
|
if (!helper || helper.dead) continue;
|
|
mobs[helperId] = {
|
|
...helper,
|
|
engaged: true,
|
|
combatPhase: "chasing",
|
|
threatByActor: seedActorThreat(helper.threatByActor, resolution.targetActorId),
|
|
targetActorId: helper.targetActorId ?? resolution.targetActorId,
|
|
homePosition: helper.homePosition ?? getMobPosition(helperId),
|
|
abilityReadyAt: Object.keys(helper.abilityReadyAt).length
|
|
? helper.abilityReadyAt
|
|
: initialEnemyAbilityReadyAt(helper, now),
|
|
};
|
|
}
|
|
} else if (effect.kind === "summon") {
|
|
const castNumber = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
for (let summonIndex = 0; summonIndex < effect.count; summonIndex += 1) {
|
|
const summonId = `${id}:${attack.id}:summon:${castNumber}:${summonIndex + 1}`;
|
|
if (mobs[summonId]) continue;
|
|
const angle = summonIndex * 2.399963229728653;
|
|
const summonPosition: CombatPosition = [
|
|
position[0] + Math.cos(angle) * 2.2,
|
|
position[1],
|
|
position[2] + Math.sin(angle) * 2.2,
|
|
];
|
|
setMobPosition(summonId, summonPosition);
|
|
mobs[summonId] = summonedMobCombatState(
|
|
summonId,
|
|
mob,
|
|
effect.creatureEntry,
|
|
resolution.targetActorId,
|
|
summonPosition,
|
|
);
|
|
}
|
|
} else if (effect.kind === "encounter-event") {
|
|
if (effect.eventId === "wailing-caverns:naralex-start") {
|
|
useWailingEncounterStore.getState().start(now);
|
|
}
|
|
}
|
|
attackRevision += 1;
|
|
abilityCastCounts[attack.id] = (abilityCastCounts[attack.id] ?? 0) + 1;
|
|
attackAnimation = attack.animation;
|
|
nextAttackIndex = (selectedAttackIndex + 1) % mob.attacks.length;
|
|
lastAttackId = attack.id;
|
|
const slowAttack = activeStatuses.find((status) => status.kind === "attack-speed")?.magnitude ?? 0;
|
|
const cooldownRange = attack.repeatCooldownMs
|
|
?? [attack.cooldownMs, attack.cooldownMs] as const;
|
|
abilityReadyAt[attack.id] = attack.maximumCasts
|
|
&& abilityCastCounts[attack.id] >= attack.maximumCasts
|
|
? Number.POSITIVE_INFINITY
|
|
: now + Math.round(
|
|
deterministicEnemyCooldown(id, attack.id, attackRevision, cooldownRange) * (1 + slowAttack),
|
|
);
|
|
nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
|
}
|
|
if (enemyActiveCast) nextAttackAt = enemyActiveCast.completesAt;
|
|
if (
|
|
phase !== mob.combatPhase
|
|
|| nextAttackAt !== mob.nextAttackAt
|
|
|| attackRevision !== mob.attackRevision
|
|
|| attackAnimation !== mob.attackAnimation
|
|
|| nextAttackIndex !== mob.nextAttackIndex
|
|
|| lastAttackId !== mob.lastAttackId
|
|
|| activeStatuses.length !== mob.statuses.length
|
|
|| activeAuras.length !== mob.auras.length
|
|
|| Object.entries(abilityReadyAt).some(([abilityId, readyAt]) => (
|
|
mob.abilityReadyAt[abilityId] !== readyAt
|
|
))
|
|
|| Object.entries(abilityCastCounts).some(([abilityId, count]) => (
|
|
mob.abilityCastCounts[abilityId] !== count
|
|
))
|
|
|| enemyActiveCast !== mob.activeCast
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| Object.entries(schoolLockedUntil).some(([school, lockedUntil]) => (
|
|
mob.schoolLockedUntil[school as EnemyDamageSchool] !== lockedUntil
|
|
))
|
|
|| threatByActor !== mob.threatByActor
|
|
|| resolution.targetActorId !== mob.targetActorId
|
|
|| resolution.forcedTarget !== mob.forcedTarget
|
|
) {
|
|
mobs[id] = {
|
|
...mob,
|
|
combatPhase: phase,
|
|
nextAttackAt,
|
|
abilityReadyAt,
|
|
abilityCastCounts,
|
|
activeCast: enemyActiveCast,
|
|
schoolLockedUntil,
|
|
attackRevision,
|
|
attackAnimation,
|
|
nextAttackIndex,
|
|
lastAttackId,
|
|
statuses: activeStatuses,
|
|
auras: activeAuras,
|
|
threatByActor,
|
|
targetActorId: resolution.targetActorId,
|
|
forcedTarget: resolution.forcedTarget,
|
|
homePosition: home,
|
|
};
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
for (const healing of enemyHealing) {
|
|
const target = mobs[healing.targetId];
|
|
if (!target || target.dead) continue;
|
|
mobs[healing.targetId] = {
|
|
...target,
|
|
health: Math.min(target.maxHealth, target.health + healing.amount),
|
|
};
|
|
changed = true;
|
|
}
|
|
if (changed || playerControl) {
|
|
set({
|
|
mobs,
|
|
auras: namedAuras,
|
|
...(playerControl
|
|
? {
|
|
controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt),
|
|
controlMechanic: playerControl.mechanic,
|
|
activeCast: null,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
let appliedDamage = 0;
|
|
for (const hit of incomingHits) {
|
|
const impactOrigin = getMobPosition(hit.mob.id);
|
|
const impactTarget = actorPosition(get(), hit.actorId);
|
|
if (impactOrigin && impactTarget) {
|
|
emitEnemyAttackPresentation(
|
|
hit.mob,
|
|
hit.attack,
|
|
"impact",
|
|
now,
|
|
impactOrigin,
|
|
hit.actorId,
|
|
impactTarget,
|
|
);
|
|
}
|
|
if (hit.actorId === PLAYER_AGGRO_ID) {
|
|
const current = get();
|
|
const defender = applyAuraStats(
|
|
playerCharacterStats(
|
|
current.classId,
|
|
current.level,
|
|
current.gearStats,
|
|
current.raceId,
|
|
current.secondaryClassId,
|
|
current.talentModifiers,
|
|
),
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
);
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(current.auras, {
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage);
|
|
const healthDamage = Math.min(current.health, namedAbsorb.remainingDamage - absorbed);
|
|
const health = current.health - healthDamage;
|
|
const rage = gainRomRage(
|
|
current.classId,
|
|
current.secondaryClassId,
|
|
healthDamage > 0 && hasRomWarriorRagePool(current.classId, current.secondaryClassId)
|
|
? ROM_RAGE_PER_HIT_TAKEN
|
|
: 0,
|
|
current.resource,
|
|
current.maxResource,
|
|
{ ...current.resourcePools },
|
|
{ ...current.maxResourcePools },
|
|
);
|
|
const avoidTrigger = combatResult.dodged
|
|
? "dodge"
|
|
: combatResult.parried
|
|
? "parry"
|
|
: combatResult.blocked
|
|
? "block"
|
|
: null;
|
|
const procEvent: ProcEvent | null = avoidTrigger
|
|
? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id }
|
|
: healthDamage > 0
|
|
? {
|
|
trigger: "damage-taken",
|
|
subjectId: PLAYER_AURA_ENTITY_ID,
|
|
otherId: hit.mob.id,
|
|
abilityId: hit.attack.id,
|
|
amount: healthDamage,
|
|
school: hit.attack.school,
|
|
attackKind: enemyAttackKind(hit.attack),
|
|
}
|
|
: null;
|
|
const procProgression = progressionFromState(current);
|
|
procProgression.health = health;
|
|
const procWork: MutableCastWork = {
|
|
classId: current.classId,
|
|
abilities: current.abilities,
|
|
mobs: { ...get().mobs },
|
|
effects: [...current.effects],
|
|
auras: [...namedAbsorb.auras],
|
|
summons: [...current.summons],
|
|
health,
|
|
maxHealth: current.maxHealth,
|
|
shield: current.shield - absorbed,
|
|
shieldExpiresAt: current.shieldExpiresAt,
|
|
resource: rage.resource,
|
|
maxResource: current.maxResource,
|
|
resourcePools: { ...rage.resourcePools },
|
|
maxResourcePools: { ...current.maxResourcePools },
|
|
comboPoints: current.comboPoints,
|
|
progression: procProgression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: current.talentRanks,
|
|
talentModifiers: current.talentModifiers,
|
|
gearStats: current.gearStats,
|
|
raceId: current.raceId,
|
|
secondaryClassId: current.secondaryClassId,
|
|
};
|
|
if (procEvent) evaluateProcsInWork(procWork, procEvent, now);
|
|
set({
|
|
auras: procWork.auras,
|
|
mobs: procWork.mobs,
|
|
shield: procWork.shield,
|
|
health: procWork.health,
|
|
resource: procWork.resource,
|
|
resourcePools: procWork.resourcePools,
|
|
lastCombatResult: combatResult,
|
|
activeCast: procWork.health <= 0 ? null : current.activeCast,
|
|
autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId,
|
|
nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt,
|
|
playerAnimationEvent: healthDamage > 0
|
|
? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound")
|
|
: current.playerAnimationEvent,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
procWork.health <= 0
|
|
? "You have been defeated."
|
|
: `${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)}.`,
|
|
{ amount: healthDamage, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
appliedDamage += healthDamage;
|
|
} else {
|
|
const partyState = usePartyStore.getState();
|
|
const member = partyState.members.find((candidate) => candidate.id === hit.actorId);
|
|
if (!member || member.health <= 0) continue;
|
|
const defender = applyAuraStats(
|
|
deriveCharacterStats(
|
|
member.classId,
|
|
member.level,
|
|
member.gearStats,
|
|
member.raceId,
|
|
),
|
|
aurasForEntity(get().auras, member.id),
|
|
);
|
|
const combatResult = resolveEnemyDamageResult(
|
|
hit.mob,
|
|
hit.attack,
|
|
defender,
|
|
hit.amount,
|
|
hit.actorId,
|
|
hit.attackRevision,
|
|
now,
|
|
);
|
|
const currentAuras = get().auras;
|
|
const auraAdjustedDamage = resolveAuraDamageValue(
|
|
combatResult.finalDamage,
|
|
aurasForEntity(currentAuras, hit.actorId),
|
|
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
|
);
|
|
const namedAbsorb = absorbAuraDamage(currentAuras, {
|
|
targetId: hit.actorId,
|
|
amount: auraAdjustedDamage,
|
|
school: hit.attack.school,
|
|
now,
|
|
});
|
|
const damaged = partyState.damageMemberResolved(hit.actorId, namedAbsorb.remainingDamage);
|
|
appliedDamage += damaged;
|
|
set({
|
|
auras: namedAbsorb.auras,
|
|
lastCombatResult: combatResult,
|
|
feedback: nextFeedback(
|
|
"damage",
|
|
`${hit.mob.name}'s ${hit.attack.name} ${combatResultDescription(combatResult)} against ${member.name}.`,
|
|
{ amount: damaged, abilityId: hit.attack.id },
|
|
),
|
|
});
|
|
}
|
|
}
|
|
for (const control of memberControls) {
|
|
usePartyStore.getState().controlMember(
|
|
control.memberId,
|
|
control.mechanic,
|
|
control.endsAt,
|
|
);
|
|
}
|
|
return appliedDamage;
|
|
},
|
|
|
|
tick: (deltaSeconds, now = Date.now()) => {
|
|
const partyHotTicks: Array<{
|
|
memberId: string;
|
|
amount: number;
|
|
sourceActorId: AggroActorId;
|
|
sourceAbilityId: string;
|
|
}> = [];
|
|
set((state) => {
|
|
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
|
const resourceProfile = resourceProfileForClass(state.classId);
|
|
const hasNativeRomRage = hasRomRagePool(state.classId, state.secondaryClassId);
|
|
const baseRegeneration = applyTalentModifierValue(
|
|
hasNativeRomRage && resourceProfile.type === "rage"
|
|
? 0
|
|
: resourceProfile.regenerationPerSecond
|
|
+ state.gearStats.mp5 / 5
|
|
+ state.gearStats.spirit * 0.02,
|
|
state.talentModifiers,
|
|
"resource-regeneration-percent",
|
|
);
|
|
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
|
const regeneration = resolveAuraResourceValue(
|
|
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
|
playerAuras,
|
|
"power",
|
|
"regeneration",
|
|
);
|
|
let resource = clamp(
|
|
state.resource + regeneration * delta,
|
|
0,
|
|
state.maxResource,
|
|
);
|
|
const resourcePools: Partial<Record<ResourceType, number>> = { ...state.resourcePools };
|
|
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
|
|
const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId);
|
|
const passiveRates: Partial<Record<ResourceType, number>> = {
|
|
mana: 4,
|
|
rage: hasNativeRomRage ? 0 : 5,
|
|
energy: 10,
|
|
focus: 6,
|
|
"runic-power": 0,
|
|
};
|
|
for (const [type, maximum] of Object.entries(state.maxResourcePools) as [ResourceType, number][]) {
|
|
if (type === resourceProfile.type) continue;
|
|
resourcePools[type] = clamp((resourcePools[type] ?? 0) + (passiveRates[type] ?? 0) * delta, 0, maximum);
|
|
}
|
|
resourcePools[resourceProfile.type] = resource;
|
|
if (hasNativeRomWarriorRage && !inCombat) {
|
|
const maximum = state.maxResourcePools.rage
|
|
?? (resourceProfile.type === "rage" ? state.maxResource : 100);
|
|
const rage = clamp(
|
|
(resourcePools.rage ?? (resourceProfile.type === "rage" ? resource : 0))
|
|
- ROM_RAGE_DECAY_PER_SECOND * delta,
|
|
0,
|
|
maximum,
|
|
);
|
|
resourcePools.rage = rage;
|
|
if (resourceProfile.type === "rage") resource = rage;
|
|
}
|
|
let shield = state.shield;
|
|
let shieldExpiresAt = state.shieldExpiresAt;
|
|
if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) {
|
|
shield = 0;
|
|
shieldExpiresAt = 0;
|
|
}
|
|
|
|
const progression = progressionFromState(state);
|
|
const work: MutableCastWork = {
|
|
classId: state.classId,
|
|
abilities: state.abilities,
|
|
mobs: { ...state.mobs },
|
|
effects: [],
|
|
auras: [...expireAuras(state.auras, now).active],
|
|
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
|
health: Math.min(
|
|
state.maxHealth,
|
|
state.health + (state.gearStats.healthPer5 / 5 + state.gearStats.spirit * 0.01) * delta,
|
|
),
|
|
maxHealth: state.maxHealth,
|
|
shield,
|
|
shieldExpiresAt,
|
|
resource,
|
|
maxResource: state.maxResource,
|
|
resourcePools,
|
|
maxResourcePools: { ...state.maxResourcePools },
|
|
comboPoints: state.comboPoints,
|
|
progression,
|
|
totalDamage: 0,
|
|
totalHealing: 0,
|
|
combatResults: [],
|
|
talentRanks: state.talentRanks,
|
|
talentModifiers: state.talentModifiers,
|
|
gearStats: state.gearStats,
|
|
raceId: state.raceId,
|
|
secondaryClassId: state.secondaryClassId,
|
|
};
|
|
|
|
let autoAttackTargetId = state.autoAttackTargetId;
|
|
let nextAutoAttackAt = state.nextAutoAttackAt;
|
|
let autoAttackEvent: CharacterCombatAnimationEvent | null = null;
|
|
const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null;
|
|
if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
} else {
|
|
const canAutoAttack = (
|
|
!state.activeCast
|
|
&& state.controlledUntil <= now
|
|
&& Math.sqrt(mobRuntimeDistanceSquared(autoAttackTargetId!, state.playerPosition)) <= PLAYER_AUTO_ATTACK_RANGE
|
|
);
|
|
if (canAutoAttack) {
|
|
const profile = playerAutoAttackProfile(state);
|
|
if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
ROM_CHAMPION_RAGE_PER_SECOND * profile.hasteMultiplier * delta,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (now >= nextAutoAttackAt) {
|
|
const offHandId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.["off-hand"];
|
|
const isDualWielding = Boolean(
|
|
offHandId && state.inventory.some((item) => item.instanceId === offHandId && item.weaponType),
|
|
);
|
|
const autoAttackSchool = state.gearStats.meleeWeapon?.damageSchool ?? "physical";
|
|
const result = resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
autoAttackTargetId!,
|
|
profile.damage,
|
|
now,
|
|
playerThreatSource(state),
|
|
"melee",
|
|
autoAttackSchool,
|
|
`${PLAYER_AGGRO_ID}:auto:${autoAttackTargetId}:${now}`,
|
|
undefined,
|
|
false,
|
|
{ isAutoAttack: true, isDualWielding },
|
|
);
|
|
if (result.amount > 0 && hasNativeRomWarriorRage) {
|
|
const rage = gainRomRage(
|
|
state.classId,
|
|
state.secondaryClassId,
|
|
ROM_RAGE_PER_AUTO_ATTACK,
|
|
work.resource,
|
|
work.maxResource,
|
|
work.resourcePools,
|
|
work.maxResourcePools,
|
|
);
|
|
work.resource = rage.resource;
|
|
work.resourcePools = rage.resourcePools;
|
|
}
|
|
if (result.amount > 0) autoAttackEvent = nextAnimationEvent("attack");
|
|
if (result.amount > 0) {
|
|
const targetPosition = getMobPosition(autoAttackTargetId!);
|
|
emitCombatPresentation({
|
|
occurredAt: now,
|
|
phase: "release",
|
|
actorGroup: "player",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
targetActorId: autoAttackTargetId!,
|
|
abilityId: "player-auto-attack",
|
|
name: "Auto Attack",
|
|
source: "fallback",
|
|
school: autoAttackSchool,
|
|
delivery: "melee",
|
|
origin: state.playerPosition,
|
|
targetPosition: targetPosition ?? state.playerPosition,
|
|
});
|
|
}
|
|
nextAutoAttackAt = now + profile.intervalMs;
|
|
if (result.killed) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const effect of state.effects) {
|
|
let remainingTicks = effect.remainingTicks;
|
|
let nextTickAt = effect.nextTickAt;
|
|
while (remainingTicks > 0 && nextTickAt <= now) {
|
|
const ability = abilityById(effect.sourceAbilityId);
|
|
const baseAmount = ability
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
progression.level,
|
|
state.gearStats,
|
|
state.classId,
|
|
ability,
|
|
)
|
|
: Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level)));
|
|
const amount = ability
|
|
? talentAdjustedAmount(
|
|
baseAmount,
|
|
state.talentModifiers,
|
|
effect.kind === "dot" ? "damage-percent" : "healing-percent",
|
|
ability,
|
|
true,
|
|
)
|
|
: baseAmount;
|
|
if (effect.kind === "dot" && effect.targetId) {
|
|
const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell";
|
|
resolveAndDamageMobInWork(
|
|
state.classId,
|
|
work,
|
|
effect.targetId,
|
|
amount,
|
|
nextTickAt,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
dotAttackKind,
|
|
effect.damageSchool ?? "physical",
|
|
`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}`,
|
|
ability ?? undefined,
|
|
true,
|
|
{
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
},
|
|
);
|
|
} else if (effect.kind === "hot") {
|
|
const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
|
|
const modifiedHealing = Math.round(resolveAuraHealingValue(
|
|
resolveAuraHealingValue(
|
|
amount,
|
|
aurasForEntity(work.auras, effect.sourceActorId),
|
|
"done",
|
|
),
|
|
aurasForEntity(work.auras, hotTargetId),
|
|
"taken",
|
|
));
|
|
if (effect.targetId) {
|
|
const target = usePartyStore.getState().members.find((member) => member.id === effect.targetId);
|
|
if (target && target.health > 0) partyHotTicks.push({
|
|
memberId: target.id,
|
|
amount: modifiedHealing,
|
|
sourceActorId: effect.sourceActorId,
|
|
sourceAbilityId: effect.sourceAbilityId,
|
|
});
|
|
} else {
|
|
const healed = Math.min(modifiedHealing, work.maxHealth - work.health);
|
|
work.health += healed;
|
|
work.progression.health = work.health;
|
|
work.totalHealing += healed;
|
|
addHealingThreatInWork(
|
|
work,
|
|
threatSourceForActor(state, effect.sourceActorId, effect.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
evaluateProcsInWork(work, {
|
|
trigger: "periodic",
|
|
subjectId: effect.sourceActorId,
|
|
otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID,
|
|
abilityId: effect.sourceAbilityId,
|
|
amount,
|
|
...(effect.damageSchool ? { school: effect.damageSchool } : {}),
|
|
}, nextTickAt);
|
|
if (ability) {
|
|
const sourceActorId = effect.sourceActorId;
|
|
const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party";
|
|
const origin = sourceActorId === PLAYER_AGGRO_ID
|
|
? state.playerPosition
|
|
: getPartyRuntimePosition(sourceActorId) ?? state.playerPosition;
|
|
const targetPosition = effect.targetId
|
|
? getMobPosition(effect.targetId) ?? getPartyRuntimePosition(effect.targetId)
|
|
: state.playerPosition;
|
|
emitCombatPresentation({
|
|
occurredAt: nextTickAt,
|
|
phase: "tick",
|
|
actorGroup,
|
|
sourceActorId,
|
|
...(effect.targetId ? { targetActorId: effect.targetId } : {}),
|
|
abilityId: ability.id,
|
|
sourceSpellId: ability.dbcSpellId,
|
|
name: ability.name,
|
|
source: presentationSourceForAbility(ability),
|
|
school: presentationSchoolForAbility(ability),
|
|
delivery: presentationDeliveryForAbility(ability),
|
|
origin,
|
|
targetPosition: targetPosition ?? origin,
|
|
});
|
|
}
|
|
remainingTicks -= 1;
|
|
nextTickAt += effect.intervalMs;
|
|
}
|
|
const targetAlive = effect.kind === "dot"
|
|
? Boolean(effect.targetId && !work.mobs[effect.targetId]?.dead)
|
|
: !effect.targetId || usePartyStore.getState().members.some(
|
|
(member) => member.id === effect.targetId && member.health > 0,
|
|
);
|
|
if (remainingTicks > 0 && targetAlive) {
|
|
work.effects.push(
|
|
remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt
|
|
? effect
|
|
: { ...effect, remainingTicks, nextTickAt },
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
|
const statuses = mob.statuses.filter((status) => status.endsAt > now);
|
|
const auras = mob.auras.filter((aura) => aura.endsAt <= 0 || aura.endsAt > now);
|
|
const schoolLockedUntil = Object.fromEntries(
|
|
Object.entries(mob.schoolLockedUntil).filter(([, lockedUntil]) => (lockedUntil ?? 0) > now),
|
|
) as Partial<Record<EnemyDamageSchool, number>>;
|
|
const corpseExpired = mob.dead
|
|
&& !mob.despawned
|
|
&& mob.corpseDespawnAt !== null
|
|
&& now >= mob.corpseDespawnAt;
|
|
if (
|
|
statuses.length !== mob.statuses.length
|
|
|| auras.length !== mob.auras.length
|
|
|| Object.keys(schoolLockedUntil).length !== Object.keys(mob.schoolLockedUntil).length
|
|
|| corpseExpired
|
|
) {
|
|
work.mobs[id] = {
|
|
...mob,
|
|
statuses,
|
|
auras,
|
|
schoolLockedUntil,
|
|
activeCast: mob.dead ? null : mob.activeCast,
|
|
despawned: mob.despawned || corpseExpired,
|
|
};
|
|
}
|
|
}
|
|
|
|
const levelled = progression.levelsGained > 0;
|
|
if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) {
|
|
autoAttackTargetId = null;
|
|
nextAutoAttackAt = 0;
|
|
}
|
|
return {
|
|
resource: clamp(work.resource, 0, work.maxResource),
|
|
maxResource: work.maxResource,
|
|
resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) },
|
|
maxResourcePools: work.maxResourcePools,
|
|
talentModifiers: work.talentModifiers,
|
|
shield: work.shield,
|
|
shieldExpiresAt: work.shieldExpiresAt,
|
|
health: progression.levelsGained > 0 ? progression.health : work.health,
|
|
maxHealth: progression.maxHealth,
|
|
controlledUntil: state.controlledUntil > now ? state.controlledUntil : 0,
|
|
controlMechanic: state.controlledUntil > now ? state.controlMechanic : null,
|
|
level: progression.level,
|
|
xp: progression.xp,
|
|
xpToNext: progression.xpToNext,
|
|
mobs: work.mobs,
|
|
auras: work.auras,
|
|
effects: work.effects.length === state.effects.length
|
|
&& work.effects.every((effect, index) => effect === state.effects[index])
|
|
? state.effects
|
|
: work.effects,
|
|
summons: work.summons,
|
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
|
selectedTargetId: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? null : state.selectedTargetId,
|
|
autoAttackTargetId,
|
|
nextAutoAttackAt,
|
|
playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent,
|
|
comboPoints: state.selectedTargetId && work.mobs[state.selectedTargetId]?.dead ? 0 : state.comboPoints,
|
|
feedback: levelled
|
|
? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level))
|
|
: work.combatResults.length > 0
|
|
&& work.combatResults.at(-1)?.outcome !== "hit"
|
|
? nextFeedback("damage", `Attack ${combatResultDescription(work.combatResults.at(-1)!)}.`, {
|
|
amount: work.combatResults.at(-1)!.finalDamage,
|
|
})
|
|
: state.feedback,
|
|
};
|
|
});
|
|
|
|
for (const tick of partyHotTicks) {
|
|
const healed = usePartyStore.getState().healMember(tick.memberId, tick.amount);
|
|
if (healed > 0) {
|
|
get().addHealingThreat(
|
|
threatSourceForActor(get(), tick.sourceActorId, tick.sourceAbilityId, true),
|
|
healed,
|
|
);
|
|
}
|
|
}
|
|
|
|
const cast = get().activeCast;
|
|
if (cast?.mode === "channel") {
|
|
const catalogAbility = abilityById(cast.abilityId);
|
|
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
|
|
? undefined
|
|
: get().talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
|
|
const ability = catalogAbility
|
|
? abilityAtLevel(catalogAbility, get().level, learnedTalentRank)
|
|
: null;
|
|
let ticksCompleted = cast.ticksCompleted;
|
|
let nextTickAt = cast.nextTickAt;
|
|
while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now) {
|
|
const target = cast.targetId ? get().mobs[cast.targetId] : null;
|
|
const targetPosition = cast.targetId ? getMobPosition(cast.targetId) : null;
|
|
const distance = cast.targetId ? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin)) : 0;
|
|
const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0;
|
|
const maximumRange = ability ? talentAdjustedRange(ability.range.max, ability, get().talentModifiers) : 0;
|
|
if (!target || target.dead || !targetPosition || distance < minimumRange || distance > maximumRange) {
|
|
get().cancelCast(target?.dead ? "Channel ended: the target was defeated." : "Channel interrupted: target unavailable.");
|
|
return;
|
|
}
|
|
const damageAmount = ability.effects.reduce(
|
|
(total, effect) => total + (effect.kind === "damage"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
)
|
|
: 0),
|
|
0,
|
|
) / cast.totalTicks;
|
|
const healingAmount = ability.effects.reduce(
|
|
(total, effect) => total + (effect.kind === "heal"
|
|
? equipmentAdjustedCombatAmount(
|
|
effect,
|
|
get().level,
|
|
get().gearStats,
|
|
get().classId,
|
|
ability,
|
|
)
|
|
: 0),
|
|
0,
|
|
) / cast.totalTicks;
|
|
if (damageAmount > 0 && cast.targetId) {
|
|
get().damageMob(
|
|
cast.targetId,
|
|
talentAdjustedAmount(
|
|
damageAmount,
|
|
get().talentModifiers,
|
|
"damage-percent",
|
|
ability,
|
|
),
|
|
nextTickAt,
|
|
playerThreatSource(get(), ability.id, true),
|
|
abilityDamageSchool(get().classId, ability),
|
|
{
|
|
attackKind: abilityAttackKind(get().classId, ability),
|
|
seed: `${PLAYER_AGGRO_ID}:${ability.id}:${cast.targetId}:${nextTickAt}:channel:${ticksCompleted}`,
|
|
abilityId: ability.id,
|
|
periodic: true,
|
|
rules: {
|
|
canMiss: false,
|
|
canCrit: false,
|
|
canDodge: false,
|
|
canParry: false,
|
|
canBlock: false,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
if (healingAmount > 0) {
|
|
const healed = get().healPlayer(talentAdjustedAmount(
|
|
healingAmount,
|
|
get().talentModifiers,
|
|
"healing-percent",
|
|
ability,
|
|
));
|
|
if (healed > 0) {
|
|
get().addHealingThreat(playerThreatSource(get(), ability.id, true), healed);
|
|
}
|
|
}
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"tick",
|
|
nextTickAt,
|
|
get().playerPosition,
|
|
cast.targetId,
|
|
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
|
|
);
|
|
ticksCompleted += 1;
|
|
nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
|
|
}
|
|
if (get().activeCast?.id !== cast.id) return;
|
|
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
|
|
set({
|
|
activeCast: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
|
|
feedback: nextFeedback("success", `${ability?.name ?? "Channel"} completed.`, {
|
|
abilityId: cast.abilityId,
|
|
targetId: cast.targetId ?? undefined,
|
|
}),
|
|
});
|
|
if (ability) {
|
|
emitPlayerAbilityPresentation(
|
|
ability,
|
|
"release",
|
|
now,
|
|
get().playerPosition,
|
|
cast.targetId,
|
|
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
|
|
);
|
|
}
|
|
} else if (ticksCompleted !== cast.ticksCompleted) {
|
|
set({ activeCast: { ...cast, ticksCompleted, nextTickAt } });
|
|
}
|
|
return;
|
|
}
|
|
if (cast && now >= cast.completesAt) {
|
|
set({ activeCast: null });
|
|
get().castAbility(cast.abilityId, cast.origin, now, cast);
|
|
}
|
|
},
|
|
|
|
resetEncounter: () => set((state) => {
|
|
resetCombatPresentationSession();
|
|
for (const mob of Object.values(state.mobs)) {
|
|
if (mob.summonedBy) removeMobPosition(mob.id);
|
|
}
|
|
const mobs = Object.fromEntries(Object.entries(state.mobs)
|
|
.filter(([, mob]) => !mob.summonedBy)
|
|
.map(([id, mob]) => [id, {
|
|
...mob,
|
|
health: mob.maxHealth,
|
|
dead: false,
|
|
pendingLoot: [],
|
|
looted: false,
|
|
despawned: false,
|
|
diedAt: null,
|
|
corpseDespawnAt: null,
|
|
experienceGranted: false,
|
|
statuses: [],
|
|
auras: [],
|
|
threatByActor: {},
|
|
targetActorId: null,
|
|
forcedTarget: null,
|
|
lastDamageAt: 0,
|
|
engaged: false,
|
|
combatPhase: "idle" as const,
|
|
homePosition: null,
|
|
nextAttackAt: 0,
|
|
abilityReadyAt: {},
|
|
abilityCastCounts: {},
|
|
activeCast: null,
|
|
schoolLockedUntil: {},
|
|
nextWoundAnimationAt: 0,
|
|
}]));
|
|
return {
|
|
health: state.maxHealth,
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
shield: 0,
|
|
shieldExpiresAt: 0,
|
|
resource: initialResourceForMaximum(state.classId, state.maxResource),
|
|
comboPoints: 0,
|
|
cooldowns: {},
|
|
globalCooldownEndsAt: 0,
|
|
activeCast: null,
|
|
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
|
autoAttackTargetId: null,
|
|
nextAutoAttackAt: 0,
|
|
mobs,
|
|
selectedTargetId: null,
|
|
effects: [],
|
|
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
|
|
summons: [],
|
|
lastCombatResult: null,
|
|
feedback: nextFeedback("success", "The encounter has been reset."),
|
|
};
|
|
}),
|
|
|
|
allocateTalent: (treeId) => {
|
|
const state = get();
|
|
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
|
|
if (!result.ok) {
|
|
const message = result.reason === "no-points"
|
|
? "No talent points are available."
|
|
: result.reason === "ability-essence"
|
|
? "Not enough Ability Essence."
|
|
: result.reason === "talent-essence"
|
|
? "Not enough Talent Essence."
|
|
: result.reason === "point-gate"
|
|
? "Invest the required Ability and Talent Essence in this tab first."
|
|
: result.reason === "level-locked"
|
|
? "That node requires a higher level."
|
|
: result.reason === "max-rank"
|
|
? "That talent is already at maximum rank."
|
|
: result.reason === "tier-locked"
|
|
? "Spend five points per earlier tier in this tree first."
|
|
: result.reason === "prerequisite"
|
|
? "The prerequisite talent must be completed first."
|
|
: "That talent is unavailable.";
|
|
set({ feedback: nextFeedback("error", message) });
|
|
return false;
|
|
}
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
set({
|
|
talentRanks: result.ranks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, Date.now()),
|
|
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
|
});
|
|
return true;
|
|
},
|
|
|
|
resetTalents: () => set((state) => {
|
|
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
|
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
|
|
const maxHealth = talentAdjustedMaxHealth(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
const maxResource = talentAdjustedMaxResource(
|
|
state.classId,
|
|
state.level,
|
|
talentModifiers,
|
|
state.gearStats,
|
|
state.raceId,
|
|
state.secondaryClassId,
|
|
);
|
|
return {
|
|
talentRanks,
|
|
talentModifiers,
|
|
maxHealth,
|
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
|
maxResource,
|
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
|
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, Date.now()),
|
|
feedback: nextFeedback("talent", "Talent points reset."),
|
|
};
|
|
}),
|
|
|
|
setActionBinding: (layer, control, abilityId) => {
|
|
const state = get();
|
|
if (abilityId !== null && !state.abilities.some((ability) => (
|
|
ability.id === abilityId && !ability.passive
|
|
))) return false;
|
|
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
|
|
return true;
|
|
},
|
|
resetActionBindingLayer: (layer) => set((state) => ({
|
|
actionBindings: restoreActionBindingLayer(
|
|
state.actionBindings,
|
|
layer,
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
resetActionBindings: () => set((state) => ({
|
|
actionBindings: defaultActionBindings(
|
|
defaultActionBarForCharacter(state.classId, state.secondaryClassId, state.level),
|
|
),
|
|
})),
|
|
|
|
updateSettings: (patch) => set((state) => ({ settings: normalizedSettings(state.settings, patch) })),
|
|
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }),
|
|
}));
|
|
|
|
export function selectedMob(state: CombatState): MobCombatState | null {
|
|
return state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null;
|
|
}
|
|
|
|
export function cooldownRemainingMs(
|
|
state: Pick<CombatState, "cooldowns">,
|
|
abilityId: string,
|
|
now = Date.now(),
|
|
): number {
|
|
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
|
|
}
|
|
|
|
export function globalCooldownRemainingMs(
|
|
state: Pick<CombatState, "globalCooldownEndsAt">,
|
|
now = Date.now(),
|
|
): number {
|
|
return Math.max(0, state.globalCooldownEndsAt - now);
|
|
}
|
|
|
|
let collectingBossLoot = false;
|
|
useCombatStore.subscribe((state) => {
|
|
if (collectingBossLoot) return;
|
|
if (useGameStore.getState().gameMode === "manastorm") return;
|
|
const lootableBosses = Object.values(state.mobs).filter((mob) => (
|
|
mob.boss
|
|
&& mob.dead
|
|
&& mob.hasLoot
|
|
&& !mob.looted
|
|
&& !mob.despawned
|
|
));
|
|
if (!lootableBosses.length) return;
|
|
collectingBossLoot = true;
|
|
try {
|
|
for (const boss of lootableBosses) {
|
|
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now());
|
|
}
|
|
} finally {
|
|
collectingBossLoot = false;
|
|
}
|
|
});
|
|
|
|
export function combatPercent(current: number, maximum: number): number {
|
|
if (maximum <= 0) return 0;
|
|
return clamp((current / maximum) * 100, 0, 100);
|
|
}
|