import { abilitiesForClass, abilityAtLevel, abilityById, isAbilityUnlocked, resourceProfileForClass, triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect, type ResourceType, } from "./abilityCatalog"; import { canAbilityCastWhileMoving, targetingRulesForAbility, } from "./abilityRules"; import type { MobCombatState } from "./combatStore"; import { useCombatStore } from "./combatStore"; import { memberFromCompanionActor } from "./combatActorAuthority"; import type { MobVector3 } from "./mobPopulation"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { getMobPosition } from "./mobRuntimeRegistry"; import { getPartyRuntimePosition, partyRuntimeCenter, partyRuntimePositionAlongAttackCorridor, partyRuntimeTargetVisible, updatePartyRuntimePosition, } from "./partyRuntimeRegistry"; import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore"; import { useGameStore } from "./store"; import { useManastormStore } from "./manastormStore"; import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro"; import type { ActiveAura, DispelCategory } from "./combatAuras"; import { equipmentAttackPower, hastedDuration, ratingPercent, type WotlkAttackKind, } from "./wotlkStats"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { emitCombatPresentation, presentationDeliveryForAbility, presentationSchoolForAbility, type CombatPresentationDelivery, type CombatPresentationSchool, } from "./combatPresentation"; import { choosePartyAbility, commitPartyAbilityUse, partyAbilityAmount, } from "./partyAbilityAi"; import { normalizeActorResourcePool } from "./combatActors"; import { advanceCombatResourcePool } from "./combatResources"; import { combatHasLineOfSight, combatSpatialActor, isPositionInFacingArc, isSourceBehindTarget, } from "./combatSpatial"; const OUT_OF_COMBAT_REGEN_FRACTION = 0.06; const ATTACK_ACQUISITION_RANGE = 30; const HEALER_ACTION_INTERVAL_MS = 2_000; const TANK_TAUNT_RANGE = 30; const TANK_TAUNT_COOLDOWN_MS = 8_000; const TANK_TAUNT_DURATION_MS = 3_000; let lastAdvanceAt = 0; let nextRegenAt = 0; export interface PartyPresentationIdentity { readonly spellId: number; readonly name: string; readonly school: CombatPresentationSchool; readonly delivery: CombatPresentationDelivery; } function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity { if (!partyUsesRangedAttacks(member)) { const spellId = member.classId === "rogue" ? 1752 : member.classId === "death-knight" ? 45462 : member.classId === "paladin" ? 20271 : 78; return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" }; } if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" }; if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" }; if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" }; if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" }; return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" }; } function partyHealPresentation(member: PartyMember): PartyPresentationIdentity { if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" }; if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" }; return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" }; } function partyTauntSpellId(member: PartyMember): number { if (member.classId === "paladin") return 62124; if (member.classId === "death-knight") return 56222; if (member.classId === "druid") return 6795; return 355; } export interface PartyBossObjective { readonly id: string; readonly name: string; readonly position: MobVector3; } export function activePartyBossObjectives(): readonly PartyBossObjective[] { if (useGameStore.getState().gameMode === "manastorm") { const encounter = useManastormStore.getState().currentEncounter; return encounter ? [{ id: encounter.bossRuntimeId, name: encounter.bossName, position: encounter.bossPosition, }] : []; } const game = useGameStore.getState(); const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder; if (game.activeDungeonId !== "wailing-caverns") return objectives; const phase = useWailingEncounterStore.getState().phase; return phase === "mutanus" || phase === "complete" ? objectives : objectives.filter((objective) => objective.id !== "mutanus-spawn"); } export function resetPartyRuntimeClock(): void { lastAdvanceAt = 0; nextRegenAt = 0; } export function activePartyTargetId( mobs: Readonly>, selectedTargetId: string | null, ): string | null { const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (selected?.engaged && !selected.dead) return selected.id; return Object.values(mobs) .filter((mob) => mob.engaged && !mob.dead) .sort((left, right) => Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id))[0]?.id ?? null; } export function partyAttackDamage( member: Pick & Partial>, ): number { const coefficient = member.role === "tank" ? 0.85 : member.role === "healer" ? 0.55 : 1.25; const gear = member.gearStats; if (!gear) return Math.max(1, Math.round(3 + member.level * coefficient)); const classId = member.classId ?? "warrior"; const kind: WotlkAttackKind = partyUsesRangedAttacks({ classId, specialization: member.specialization ?? "", role: member.role, }) ? classId === "hunter" ? "ranged" : "spell" : "melee"; const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null; const equipmentPower = kind === "spell" ? equipmentAttackPower(classId, gear, kind) : equipmentAttackPower(classId, gear, kind) / 14 * ((weapon?.speedMs ?? 2400) / 1_000) + (weapon ? (weapon.damageMin + weapon.damageMax) / 2 * 0.35 : 0); const crit = ratingPercent( gear.criticalStrikeRating, kind === "spell" ? "spellCrit" : kind === "ranged" ? "rangedCrit" : "meleeCrit", member.level, classId, ); const hit = ratingPercent( gear.hitRating, kind === "spell" ? "spellHit" : kind === "ranged" ? "rangedHit" : "meleeHit", member.level, classId, ); return Math.max( 1, Math.round((3 + member.level * coefficient + equipmentPower) * (1 + crit / 100) * (1 + hit / 100)), ); } export function partyUsesRangedAttacks( member: Pick, ): boolean { if (member.role === "tank") return false; if (member.role === "healer") return true; return ["hunter", "mage", "priest", "warlock"].includes(member.classId) || member.specialization === "Elemental" || member.specialization === "Balance"; } export function partyAttackRange(member: Pick): number { if (member.role === "tank") return 3.8; if (partyUsesRangedAttacks(member)) return 19; return 4.2; } export interface PartyCombatRangeBand { readonly minimum: number; readonly preferred: number; readonly maximum: number; } const RANGED_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 10, preferred: 14.5, maximum: 18.1, }); const TANK_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15, }); const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 3.3, maximum: 3.55, }); /** * Movement uses a narrower band than damage validation. Ranged attackers keep * enough room to read as ranged while melee attackers stay close to a fleeing * target instead of treating the tank's position as their own. */ export function partyCombatRangeBand( member: Pick, ): PartyCombatRangeBand { if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND; return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND; } export function nextPartyBossObjective( mobs: Readonly>, ): PartyBossObjective | null { return activePartyBossObjectives().find((objective) => !mobs[objective.id]?.dead) ?? null; } function distanceSquared(left: MobVector3, right: MobVector3): number { const dx = left[0] - right[0]; const dy = left[1] - right[1]; const dz = left[2] - right[2]; return dx * dx + dy * dy + dz * dz; } function livingTargetInRange( mob: MobCombatState | null | undefined, origin: MobVector3, maximumRange: number, requireAttackCorridor = false, ): mob is MobCombatState { if (!mob || mob.dead) return false; const position = getMobPosition(mob.id); return Boolean( position && distanceSquared(position, origin) <= maximumRange * maximumRange && (!requireAttackCorridor || partyRuntimePositionAlongAttackCorridor(position)), ); } export function partyCommandTargetId( command: PartyCommand, mobs: Readonly>, selectedTargetId: string | null, currentTargetId: string | null, origin: MobVector3, acquisitionRange = ATTACK_ACQUISITION_RANGE, ): string | null { if (command === "stop") return null; const engaged = activePartyTargetId(mobs, selectedTargetId); if (engaged) return engaged; if (command === "defend") return null; const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (livingTargetInRange(selected, origin, acquisitionRange * 2, true)) return selected.id; const current = currentTargetId ? mobs[currentTargetId] : null; // currentTargetId was already authorized while it intersected the objective // corridor. Keep pursuing it while its mob-specific route is active. if (livingTargetInRange(current, origin, acquisitionRange * 1.5)) return current.id; return Object.values(mobs) .filter((mob) => livingTargetInRange(mob, origin, acquisitionRange, true)) .sort((left, right) => { // Clear adds before committing to the boss at the end of a pull. const kindOrder = Number(left.boss) - Number(right.boss); if (kindOrder) return kindOrder; const leftPosition = getMobPosition(left.id)!; const rightPosition = getMobPosition(right.id)!; return distanceSquared(leftPosition, origin) - distanceSquared(rightPosition, origin) || left.id.localeCompare(right.id); })[0]?.id ?? null; } export interface HealerSupportResult { readonly members: readonly PartyMember[]; readonly playerHealing: number; readonly playerResurrectionPercent: number; readonly targetId: string | null; readonly effectiveHealing: number; readonly sourceMemberId: string | null; readonly action: "heal" | "resurrection" | "dispel" | null; readonly presentation: PartyPresentationIdentity | null; readonly dispelCategories: readonly DispelCategory[]; readonly dispelMaximum: number; } function partyHealAmount(level: number, spellPower = 0): number { return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25)); } function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null { const definition = abilitiesForClass(member.classId) .filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any")) .filter((ability) => isAbilityUnlocked(ability, member.level)) .find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")); return definition ? abilityAtLevel(definition, member.level) : null; } function partyResurrectionPresentation( member: PartyMember, ability: AbilityDefinition, ): PartyPresentationIdentity { const nature = member.classId === "druid" || member.classId === "shaman" || member.classId === "rom-druid"; return { spellId: ability.dbcSpellId, name: ability.name, school: nature ? "nature" : "holy", delivery: "projectile", }; } function noSupport(members: readonly PartyMember[]): HealerSupportResult { return { members, playerHealing: 0, playerResurrectionPercent: 0, targetId: null, effectiveHealing: 0, sourceMemberId: null, action: null, presentation: null, dispelCategories: [], dispelMaximum: 0, }; } /** * Gives the party one legal support action at a time. Any living class with an * unlocked resurrection can raise a fallen target; ordinary healing remains * the dedicated healer's job. The runtime caller applies player-side changes * to the combat store after this pure party-state update. */ export function applyHealerSupport( members: readonly PartyMember[], playerHealth: number, playerMaxHealth: number, now: number, auras: readonly ActiveAura[] = [], ): HealerSupportResult { const resurrector = members .map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) })) .filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => ( candidate.member.health > 0 && candidate.member.controlledUntil <= now && candidate.member.nextActionAt <= now && candidate.ability !== null )) .sort((left, right) => ( Number(right.member.role === "healer") - Number(left.member.role === "healer") || left.member.id.localeCompare(right.member.id) ))[0]; const fallen = [ ...(playerHealth <= 0 && playerMaxHealth > 0 ? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }] : []), ...members .map((member, index) => ({ id: member.id, index, maxHealth: member.maxHealth, rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2, })) .filter((candidate) => members[candidate.index].health <= 0), ].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id)); const fallenTarget = fallen[0]; if (resurrector && fallenTarget) { const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection"); if (resurrection?.kind === "resurrection") { const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth)); const next = [...members]; if (fallenTarget.index >= 0) { const target = next[fallenTarget.index]; next[fallenTarget.index] = { ...target, health: restored, status: "ready", controlledUntil: 0, controlMechanic: null, }; } const caster = next[resurrector.index]; const committed = commitPartyAbilityUse(caster, resurrector.ability, now) ?? caster; next[resurrector.index] = { ...committed, status: "healing", nextActionAt: now + Math.max( HEALER_ACTION_INTERVAL_MS, resurrector.ability.castTimeMs, resurrector.ability.gcdMs, ), }; return { members: next, playerHealing: 0, playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0, targetId: fallenTarget.id, effectiveHealing: restored, sourceMemberId: resurrector.member.id, action: "resurrection", presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability), dispelCategories: [], dispelMaximum: 0, }; } } const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0); if (healerIndex < 0) return noSupport(members); const healer = members[healerIndex]; if (now < healer.nextActionAt) return noSupport(members); const dispelTarget = [ ...(playerHealth > 0 ? [PLAYER_AGGRO_ID] : []), ...members.filter((member) => member.health > 0).map((member) => member.id), ].find((id) => auras.some((aura) => ( aura.targetId === id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory ))); if (dispelTarget) { const dispelAbility = choosePartyAbility(healer, { now, target: null, allyNeedsDispel: true }); const dispel = dispelAbility?.effects.find((effect) => effect.kind === "dispel" && effect.relationship !== "hostile"); if (dispelAbility && dispel?.kind === "dispel") { const next = [...members]; const committed = commitPartyAbilityUse(healer, dispelAbility, now) ?? healer; next[healerIndex] = { ...committed, status: "healing", nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, dispelAbility.castTimeMs, dispelAbility.gcdMs), }; return { members: next, playerHealing: 0, playerResurrectionPercent: 0, targetId: dispelTarget, effectiveHealing: 0, sourceMemberId: healer.id, action: "dispel", presentation: { spellId: dispelAbility.dbcSpellId, name: dispelAbility.name, school: presentationSchoolForAbility(dispelAbility), delivery: presentationDeliveryForAbility(dispelAbility), }, dispelCategories: dispel.categories, dispelMaximum: dispel.maxCount, }; } } const candidates = members .map((member, index) => ({ id: member.id, index, health: member.health, maxHealth: member.maxHealth, })) .filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth); if (playerHealth > 0 && playerHealth < playerMaxHealth) { candidates.push({ id: "__player__", index: -1, health: playerHealth, maxHealth: playerMaxHealth }); } candidates.sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || (right.maxHealth - right.health) - (left.maxHealth - left.health) || left.id.localeCompare(right.id) )); const target = candidates[0]; if (!target) { if (healer.status !== "healing" && healer.nextActionAt === 0) { return noSupport(members); } const next = [...members]; next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 }; return noSupport(next); } const healingAbility = choosePartyAbility(healer, { now, target: null, injuredAllies: candidates.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), }); const healingEffect = healingAbility?.effects.find((effect) => effect.kind === "heal"); if (!healingAbility || !healingEffect || healingEffect.kind !== "heal") return noSupport(members); const requested = partyAbilityAmount(healer, healingAbility, healingEffect) + Math.round(target.maxHealth * (healingEffect.percentMaxHealth ?? 0)); const healed = Math.min(requested, target.maxHealth - target.health); const next = [...members]; if (target.index >= 0) { const currentTarget = next[target.index]; next[target.index] = { ...currentTarget, health: currentTarget.health + healed }; } const currentHealer = commitPartyAbilityUse(next[healerIndex], healingAbility, now) ?? next[healerIndex]; next[healerIndex] = { ...currentHealer, status: "healing", nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, healingAbility.castTimeMs, healingAbility.gcdMs), }; return { members: next, playerHealing: target.index < 0 ? healed : 0, playerResurrectionPercent: 0, targetId: target.id, effectiveHealing: healed, sourceMemberId: healer.id, action: "heal", presentation: { spellId: healingAbility.dbcSpellId, name: healingAbility.name, school: presentationSchoolForAbility(healingAbility), delivery: presentationDeliveryForAbility(healingAbility), }, dispelCategories: [], dispelMaximum: 0, }; } function actorRole(actorId: string | null, members: readonly PartyMember[]): PartyMember["role"] | null { if (!actorId) return null; if (actorId === PLAYER_AGGRO_ID) return usePartyStore.getState().playerRole; return members.find((member) => member.id === actorId)?.role ?? null; } function tankPriorityTargetId( mobs: Readonly>, members: readonly PartyMember[], tankId: string, tankPosition: MobVector3, now: number, fallbackTargetId: string | null, ): string | null { const victimPriority = (mob: MobCombatState) => { const role = actorRole(mob.targetActorId, members); return role === "healer" ? 0 : role === "damage" ? 1 : role === "tank" ? 2 : 0; }; const loose = Object.values(mobs) .filter((mob) => ( mob.engaged && !mob.dead && mob.targetActorId !== tankId && !(mob.forcedTarget && mob.forcedTarget.endsAt > now) && getMobPosition(mob.id) )) .sort((left, right) => { const victimOrder = victimPriority(left) - victimPriority(right); if (victimOrder) return victimOrder; const bossOrder = Number(right.boss) - Number(left.boss); if (bossOrder) return bossOrder; return distanceSquared(getMobPosition(left.id)!, tankPosition) - distanceSquared(getMobPosition(right.id)!, tankPosition) || left.id.localeCompare(right.id); }); return loose[0]?.id ?? fallbackTargetId; } function memberSwingMs(member: PartyMember): number { const base = member.role === "tank" ? 1_800 : member.classId === "rogue" || member.specialization === "Enhancement" || member.specialization === "Feral (Cat)" ? 1_550 : 1_900; return hastedDuration( base, ratingPercent( member.gearStats.hasteRating, partyUsesRangedAttacks(member) ? member.classId === "hunter" ? "rangedHaste" : "spellHaste" : "meleeHaste", member.level, member.classId, ), 500, ); } function emitPartyAbility( member: PartyMember, ability: AbilityDefinition, targetId: string, phase: "cast-start" | "release" | "impact", now: number, ): void { const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition; const targetPosition = targetId === PLAYER_AGGRO_ID ? useCombatStore.getState().playerPosition : getMobPosition(targetId) ?? getPartyRuntimePosition(targetId) ?? origin; emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id, targetActorId: targetId, abilityId: ability.id, sourceSpellId: ability.dbcSpellId, name: ability.name, source: ability.source === "runewaker" ? "runewaker" : ability.source?.startsWith("coa") ? "ascension" : "wow335", school: presentationSchoolForAbility(ability), delivery: presentationDeliveryForAbility(ability), origin, targetPosition, ...(ability.radius === undefined ? {} : { radius: ability.radius }), }); } function partyFriendlyPosition(actorId: string): MobVector3 | null { if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition; return getPartyRuntimePosition(actorId); } function partyAbilityTargetIsValid( member: PartyMember, ability: AbilityDefinition, targetId: string, castOrigin?: MobVector3, ): boolean { const combat = useCombatStore.getState(); const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; if (castOrigin && !canAbilityCastWhileMoving(ability) && distanceSquared(origin, castOrigin) > 0.04) return false; const mob = combat.mobs[targetId]; const hostile = Boolean(mob); const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId); if (!targetPosition || mob?.dead) return false; const targetActor = combat.actors[targetId]; if (!hostile && (!targetActor?.alive || targetActor.health <= 0) && !ability.effects.some((effect) => effect.kind === "resurrection")) return false; const distance = Math.sqrt(distanceSquared(origin, targetPosition)); if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false; const targeting = targetingRulesForAbility(ability); if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false; if (hostile && targeting.requiresFacing) { const sourceSpatial = combatSpatialActor(member.id); if (sourceSpatial && !isPositionInFacingArc(sourceSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) return false; } if (hostile && targeting.requiresBehindTarget) { const targetSpatial = combatSpatialActor(targetId); if (targetSpatial && !isSourceBehindTarget(origin, targetSpatial)) return false; } return true; } function partyHostileTargets( ability: AbilityDefinition, primaryTargetId: string, origin: MobVector3, sourceActorId: string, ): readonly string[] { const combat = useCombatStore.getState(); const targeting = targetingRulesForAbility(ability); const primaryPosition = getMobPosition(primaryTargetId); if (!primaryPosition) return []; if (targeting.shape === "chain") { const selected: string[] = []; let previous = primaryPosition; const ordered = [primaryTargetId, ...Object.keys(combat.mobs).filter((id) => id !== primaryTargetId).sort()]; for (const id of ordered) { if (selected.length >= (targeting.maximumTargets ?? 3)) break; const position = getMobPosition(id); if (!position || combat.mobs[id]?.dead) continue; if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue; if (!combatHasLineOfSight(previous, position)) continue; selected.push(id); previous = position; } return selected; } if (targeting.shape === "cone") { const memberSpatial = combatSpatialActor(sourceActorId); return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); return !mob.dead && Boolean(position && distanceSquared(origin, position) <= ability.range.max ** 2 && combatHasLineOfSight(origin, position) && (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2))); }) .map((mob) => mob.id) .sort(); } if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { const radius = targeting.radius ?? ability.radius ?? 0; return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); return !mob.dead && Boolean(position && distanceSquared(primaryPosition, position) <= radius * radius && combatHasLineOfSight(primaryPosition, position)); }) .map((mob) => mob.id) .sort(); } return combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : []; } function partyHealingTargets( ability: AbilityDefinition, primaryTargetId: string, members: readonly PartyMember[], ): readonly string[] { const combat = useCombatStore.getState(); const targeting = targetingRulesForAbility(ability); const living = [PLAYER_AGGRO_ID, ...members.map((member) => member.id)].filter((id) => combat.actors[id]?.alive); const healthFraction = (id: string) => { const actor = combat.actors[id]; return actor ? actor.health / Math.max(1, actor.maxHealth) : 1; }; if (targeting.shape === "chain") { const selected: string[] = []; let previous = partyFriendlyPosition(primaryTargetId); const ordered = [primaryTargetId, ...living.filter((id) => id !== primaryTargetId) .sort((left, right) => healthFraction(left) - healthFraction(right) || left.localeCompare(right))]; for (const id of ordered) { if (selected.length >= (targeting.maximumTargets ?? 3)) break; const position = partyFriendlyPosition(id); if (!position || !previous) continue; if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue; if (!combatHasLineOfSight(previous, position)) continue; selected.push(id); previous = position; } return selected; } if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { const center = partyFriendlyPosition(primaryTargetId); const radius = targeting.radius ?? ability.radius ?? 0; return center ? living.filter((id) => { const position = partyFriendlyPosition(id); return Boolean(position && distanceSquared(center, position) <= radius * radius && combatHasLineOfSight(center, position)); }) : []; } return [primaryTargetId]; } interface PartyAbilityExecutionResult { readonly member: PartyMember; readonly effectiveAmount: number; } interface PartySupportIntent { readonly ability: AbilityDefinition; readonly targetId: string; } function choosePartySupportIntent( member: PartyMember, members: readonly PartyMember[], now: number, ): PartySupportIntent | null { const combat = useCombatStore.getState(); const fallenTargetId = [ ...(combat.health <= 0 ? [PLAYER_AGGRO_ID] : []), ...members .filter((candidate) => candidate.health <= 0) .sort((left, right) => ( Number(left.role === "healer") - Number(right.role === "healer") || Number(left.role === "tank") - Number(right.role === "tank") || left.id.localeCompare(right.id) )) .map((candidate) => candidate.id), ][0]; if (fallenTargetId) { const resurrection = partyResurrectionAbility(member); if (resurrection) return { ability: resurrection, targetId: fallenTargetId }; } if (member.role !== "healer") return null; const dispelTargetId = [ ...(combat.health > 0 ? [PLAYER_AGGRO_ID] : []), ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id), ].find((id) => combat.auras.some((aura) => ( aura.targetId === id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory ))); if (dispelTargetId) { const ability = choosePartyAbility(member, { now, target: null, allyNeedsDispel: true }); if (ability?.effects.some((effect) => effect.kind === "dispel" && effect.relationship !== "hostile")) { return { ability, targetId: dispelTargetId }; } } const injured = [ ...(combat.health > 0 && combat.health < combat.maxHealth ? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }] : []), ...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth), ].sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || (right.maxHealth - right.health) - (left.maxHealth - left.health) || left.id.localeCompare(right.id) )); const target = injured[0]; if (!target) return null; const ability = choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), }); return ability?.effects.some((effect) => ["heal", "hot", "shield"].includes(effect.kind)) ? { ability, targetId: target.id } : null; } function executePartyAbility( member: PartyMember, ability: AbilityDefinition, targetId: string, now: number, members: readonly PartyMember[], depth = 0, ): PartyAbilityExecutionResult { const combat = useCombatStore.getState(); const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; const hostileTargets = combat.mobs[targetId] ? partyHostileTargets(ability, targetId, origin, member.id) : []; const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members); const spellSchool = presentationSchoolForAbility(ability); const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground" ? Boolean(useCombatStore.getState().createActorAreaEffect( member.id, ability, getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin, now, )) : false; const criticalChance = Math.max(0, ratingPercent( member.gearStats.criticalStrikeRating, "spellCrit", member.level, member.classId, ) / 100); let runtimeMember = member; let effectiveAmount = 0; for (const effect of ability.effects) { if (effect.kind === "damage" || effect.kind === "dot" || effect.kind === "finisher-damage") { for (const hostileTargetId of hostileTargets) { if (effect.kind === "dot") { if (!groundAreaCreated) combat.applyTimedActorEffect(member.id, hostileTargetId, ability, effect, now); } else { effectiveAmount += combat.damageMob( hostileTargetId, partyAbilityAmount(runtimeMember, ability, effect), now, { actorId: member.id, role: member.role, abilityId: ability.id }, spellSchool, ).amount; } } } else if (effect.kind === "heal") { for (const friendlyTargetId of friendlyTargets) { const actor = useCombatStore.getState().actors[friendlyTargetId]; if (!actor?.alive) continue; const requested = partyAbilityAmount(runtimeMember, ability, effect) + Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0)); const result = useCombatStore.getState().healActor(friendlyTargetId, requested, { sourceActorId: member.id, abilityId: ability.id, school: spellSchool, criticalChance, seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`, now, }); effectiveAmount += result.effectiveAmount; } } else if (effect.kind === "hot") { if (!groundAreaCreated) for (const friendlyTargetId of friendlyTargets) { useCombatStore.getState().applyTimedActorEffect(member.id, friendlyTargetId, ability, effect, now); } } else if (effect.kind === "shield") { for (const friendlyTargetId of friendlyTargets) { effectiveAmount += useCombatStore.getState().shieldActor( friendlyTargetId, partyAbilityAmount(runtimeMember, ability, effect), effect.durationMs, member.id, ability, now, ); } } else if (effect.kind === "resurrection") { effectiveAmount += useCombatStore.getState().reviveActor( targetId, effect.percentMaxHealth, member.id, ability.id, now, ); } else if (effect.kind === "execute") { for (const hostileTargetId of hostileTargets) { const target = useCombatStore.getState().mobs[hostileTargetId]; if (target && target.health / Math.max(1, target.maxHealth) <= 0.2) { effectiveAmount += combat.damageMob( hostileTargetId, Math.max(target.health, partyAbilityAmount(runtimeMember, ability, effect)), now, { actorId: member.id, role: member.role, abilityId: ability.id }, spellSchool, ).amount; } } } else if (effect.kind === "interrupt") { for (const hostileTargetId of hostileTargets) combat.interruptMob(hostileTargetId, ability.id, effect.lockoutMs, now); } else if (effect.kind === "taunt") { for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) { combat.tauntMob(hostileTargetId, { actorId: member.id, role: member.role, abilityId: ability.id }, effect.durationMs, now); } } else if (effect.kind === "status") { for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) { combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now); } } else if (effect.kind === "apply-aura") { const recipients = effect.scope === "party" ? [PLAYER_AGGRO_ID, ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id)] : effect.recipient === "caster" ? [member.id] : combat.mobs[targetId] ? hostileTargets : friendlyTargets; for (const recipientId of recipients) { useCombatStore.getState().applyActorAura(member.id, recipientId, ability, effect, now); if (effect.control && useCombatStore.getState().mobs[recipientId]) { useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, effect.aura.durationMs ?? 1_000, now); } } } else if (effect.kind === "dispel") { const recipients = effect.scope === "party" ? [PLAYER_AGGRO_ID, ...members.map((candidate) => candidate.id)] : [targetId]; for (const recipientId of recipients) { effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount); } } else if (effect.kind === "resource") { for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) { effectiveAmount += useCombatStore.getState().restoreActorResource( friendlyTargetId, effect.resource ?? member.resourceType, effect.amount, effect.percentage, member.id, ability.id, now, ); } } else if (effect.kind === "summon") { useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now); } else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) { const summonEffect: Extract = { kind: "summon", creatureId: effect.creatureId ?? 1, coefficient: 0.35, durationMs: Number.MAX_SAFE_INTEGER - now, }; useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now); } else if (effect.kind === "movement") { const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId); if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") { updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]); } } else if (effect.kind === "cooldown-reset") { const candidates = Object.keys(runtimeMember.cooldowns).filter((id) => { const candidate = abilityById(id); return candidate && (!effect.abilityName || candidate.name === effect.abilityName); }); const cooldowns = { ...runtimeMember.cooldowns }; for (const id of candidates) { const readyAt = cooldowns[id] ?? now; const reduction = effect.amountPercent !== undefined ? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent)) : effect.amountMs ?? Number.MAX_SAFE_INTEGER; cooldowns[id] = Math.max(now, readyAt - reduction); } runtimeMember = { ...runtimeMember, cooldowns }; } else if (effect.kind === "resource-drain" && !combat.mobs[targetId]) { const resource = effect.resource ?? runtimeMember.resourceType; const maximum = resource === runtimeMember.resourceType ? runtimeMember.maxResource : 100; const drained = effect.percentage ? maximum * effect.amount / 100 : effect.amount; const current = runtimeMember.resourcePools[resource] ?? 0; const resourcePools: Partial> = { ...runtimeMember.resourcePools, [resource]: Math.max(0, current - drained), }; runtimeMember = { ...runtimeMember, resourcePools, resource: resource === runtimeMember.resourceType ? resourcePools[resource] ?? runtimeMember.resource : runtimeMember.resource, }; } else if (effect.kind === "trigger-spell" && depth < 2) { const triggered = abilitiesForClass(member.classId).find((candidate) => candidate.dbcSpellId === effect.spellId) ?? triggeredAbilityBySpellId(effect.spellId); if (triggered && triggered.id !== ability.id) { const result = executePartyAbility(runtimeMember, triggered, targetId, now, members, depth + 1); runtimeMember = result.member; effectiveAmount += result.effectiveAmount; } } } emitPartyAbility(member, ability, targetId, "release", now); emitPartyAbility(member, ability, targetId, "impact", now); if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) { useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount); } return { member: runtimeMember, effectiveAmount }; } function membersDiffer(left: readonly PartyMember[], right: readonly PartyMember[]): boolean { if (left === right) return false; if (left.length !== right.length) return true; return left.some((member, index) => member !== right[index]); } /** * Low-frequency party simulation. Call immediately after mob combat so a living * tank can redirect that tick's pressure before the death/respawn boundary. */ export function advanceDungeonPartyCombat(now = Date.now()): void { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); if (!party.members.length) return; const deltaSeconds = lastAdvanceAt > 0 ? Math.max(0, Math.min(1, (now - lastAdvanceAt) / 1_000)) : 0; lastAdvanceAt = now; const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => ( memberFromCompanionActor(member, combat.actors[member.id]) )); let members: readonly PartyMember[] = authoritativeMembers; const partyOrigin = partyRuntimeCenter( members.map((member) => member.id), combat.playerPosition, ); let targetId = partyCommandTargetId( party.command, combat.mobs, combat.selectedTargetId, party.activeMobId, partyOrigin, ); members = members.map((member) => { const profile = resourceProfileForClass(member.classId); const regeneration = (profile.type === "rage" || profile.type === "runic-power") ? 0 : profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; const pool = advanceCombatResourcePool(normalizeActorResourcePool( member.resourceType, member.resource, member.maxResource, regeneration, member.lastResourceSpentAt[member.resourceType] ?? 0, ), { inCombat: Boolean(targetId), actorLevel: member.level, now, deltaSeconds, manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02, }); return { ...member, resource: pool.current, resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current }, cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), }; }); const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null; let memberTargetIds: Record = {}; const shouldRegen = !targetId && now >= nextRegenAt; if (shouldRegen) nextRegenAt = now + 1_000; if (!targetId) { members = members.map((member) => { if (member.health <= 0 || member.controlledUntil > now || !shouldRegen || deltaSeconds <= 0) return member; return { ...member, health: Math.min( member.maxHealth, member.health + Math.max(1, member.maxHealth * OUT_OF_COMBAT_REGEN_FRACTION), ), }; }); } // A defeated player can watch the encounter continue while waiting for a // resurrection. Living companions remain active until the whole party falls. if (members.some((member) => member.health > 0)) { const nextMembers = [...members]; for (let index = 0; index < nextMembers.length; index += 1) { let member = nextMembers[index]; if (member.health <= 0) { if (member.status !== "down") nextMembers[index] = { ...member, status: "down" }; continue; } if (member.controlledUntil > now) { nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" }; continue; } if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("pacified"))) { nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" }; continue; } const activeMember = member.controlMechanic ? { ...member, controlledUntil: 0, controlMechanic: null } : member; member = activeMember; const currentCombat = useCombatStore.getState(); if (member.activeCast) { const castingAbility = abilityById(member.activeCast.abilityId); const castTargetId = member.activeCastTargetId; if (!castingAbility || !castTargetId) { nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null }; continue; } const hostileCast = Boolean(currentCombat.mobs[castTargetId]); const desiredStatus = hostileCast ? member.role === "tank" ? "guarding" : "attacking" : "healing"; if (now < member.activeCast.completesAt) { nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus }; if (hostileCast) memberTargetIds[member.id] = castTargetId; continue; } const completedMember = { ...member, activeCast: null, activeCastTargetId: null }; const valid = partyAbilityTargetIsValid( completedMember, castingAbility, castTargetId, member.activeCast.origin, ); if (valid) { const execution = executePartyAbility(completedMember, castingAbility, castTargetId, now, nextMembers); nextMembers[index] = { ...execution.member, status: desiredStatus, nextActionAt: now + Math.max(250, castingAbility.gcdMs), }; } else { nextMembers[index] = { ...completedMember, status: hostileCast ? "advancing" : "ready", nextActionAt: now + 250, }; } continue; } const supportIntent = choosePartySupportIntent(member, nextMembers, now); if (supportIntent) { delete memberTargetIds[member.id]; if (now < member.nextActionAt) { nextMembers[index] = member.status === "healing" ? member : { ...member, status: "healing" }; continue; } if (!partyAbilityTargetIsValid(member, supportIntent.ability, supportIntent.targetId)) { nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 }; continue; } const committed = commitPartyAbilityUse(member, supportIntent.ability, now); if (!committed) { nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 }; continue; } const instantOutOfCombatResurrection = !targetId && supportIntent.ability.effects.some((effect) => effect.kind === "resurrection"); if (supportIntent.ability.castTimeMs > 0 && !instantOutOfCombatResurrection) { const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition; nextMembers[index] = { ...committed, status: "healing", activeCast: { abilityId: supportIntent.ability.id, school: presentationSchoolForAbility(supportIntent.ability), mode: supportIntent.ability.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + supportIntent.ability.castTimeMs, originalDurationMs: supportIntent.ability.castTimeMs, pushbackHits: 0, origin: castOrigin, targetId: supportIntent.targetId, }, activeCastTargetId: supportIntent.targetId, nextActionAt: now + supportIntent.ability.castTimeMs, }; emitPartyAbility(member, supportIntent.ability, supportIntent.targetId, "cast-start", now); } else { const execution = executePartyAbility( committed, supportIntent.ability, supportIntent.targetId, now, nextMembers, ); nextMembers[index] = { ...execution.member, status: "healing", nextActionAt: now + Math.max(250, supportIntent.ability.gcdMs), }; } continue; } targetId = partyCommandTargetId( party.command, currentCombat.mobs, currentCombat.selectedTargetId, targetId, partyOrigin, ); if (!targetId) { nextMembers[index] = member.status === "ready" && member.nextActionAt === 0 ? member : { ...member, status: party.command === "stop" ? "holding" : "ready", nextActionAt: 0 }; continue; } const desiredStatus = member.role === "tank" ? "guarding" : "attacking"; const memberPosition = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition; const memberTargetId = member.role === "tank" ? tankPriorityTargetId( currentCombat.mobs, nextMembers, member.id, memberPosition, now, targetId, ) : targetId; if (!memberTargetId) continue; memberTargetIds[member.id] = memberTargetId; const targetPosition = getMobPosition(memberTargetId); const targetVisible = Boolean( targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId) ); let runtimeMember = member; const targetMob = currentCombat.mobs[memberTargetId]; if (now < member.nextActionAt) { if (runtimeMember.status !== desiredStatus) { runtimeMember = { ...runtimeMember, status: desiredStatus }; } nextMembers[index] = runtimeMember; continue; } const selectedAbility = choosePartyAbility(runtimeMember, { now, target: targetMob ?? null, targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id, }); if (!selectedAbility) { nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; continue; } const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId); if (!inRange) { nextMembers[index] = runtimeMember.status === "advancing" ? runtimeMember : { ...runtimeMember, status: "advancing" }; continue; } const committed = commitPartyAbilityUse(runtimeMember, selectedAbility, now); if (!committed) { nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; continue; } if (selectedAbility.castTimeMs > 0) { nextMembers[index] = { ...committed, status: desiredStatus, activeCast: { abilityId: selectedAbility.id, school: presentationSchoolForAbility(selectedAbility), mode: selectedAbility.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + selectedAbility.castTimeMs, originalDurationMs: selectedAbility.castTimeMs, pushbackHits: 0, origin: memberPosition, targetId: memberTargetId, }, activeCastTargetId: memberTargetId, nextActionAt: now + selectedAbility.castTimeMs, }; emitPartyAbility(member, selectedAbility, memberTargetId, "cast-start", now); } else { const execution = executePartyAbility(committed, selectedAbility, memberTargetId, now, nextMembers); nextMembers[index] = { ...execution.member, status: desiredStatus, nextActionAt: now + Math.max(250, selectedAbility.gcdMs), }; } } members = nextMembers; } else { memberTargetIds = {}; const idleStatus = party.command === "stop" ? "holding" as const : party.command === "attack" && objectiveBossId ? "advancing" as const : "ready" as const; members = members.map((member) => { if (member.health <= 0) return member.status === "down" ? member : { ...member, status: "down" }; if (member.controlledUntil > now) { return member.status === "holding" ? member : { ...member, status: "holding" }; } const activeMember = member.controlMechanic ? { ...member, controlledUntil: 0, controlMechanic: null } : member; const recovered = activeMember.health; if (activeMember.role === "healer" && activeMember.nextActionAt > now) { if (recovered === activeMember.health) return activeMember; return { ...activeMember, health: recovered }; } if (recovered === activeMember.health && activeMember.status === idleStatus && activeMember.nextActionAt === 0) return activeMember; return { ...activeMember, health: recovered, status: idleStatus, nextActionAt: 0 }; }); } // Execution actions may have changed health while this low-frequency party // loop was still holding its pre-cast compatibility records. Actor vitals win; // runtime-owned resources, cooldowns, status, and casts remain on the member. const actorsAfterActions = useCombatStore.getState().actors; members = members.map((member) => { const actor = actorsAfterActions[member.id]; const baseline = authoritativeMembers.find((candidate) => candidate.id === member.id); return actor?.kind === "companion" && baseline && actor.health !== baseline.health ? { ...member, health: actor.health, maxHealth: actor.maxHealth } : member; }); const targetMapChanged = ( Object.keys(party.memberTargetIds).length !== Object.keys(memberTargetIds).length || Object.entries(memberTargetIds).some(([memberId, mobId]) => party.memberTargetIds[memberId] !== mobId) ); if ( membersDiffer(authoritativeMembers, members) || party.activeMobId !== targetId || party.objectiveBossId !== objectiveBossId || targetMapChanged ) { party.commitRuntime({ members, activeMobId: targetId, objectiveBossId, memberTargetIds }); } useCombatStore.getState().commitCompanionActors(members); }