import { abilitiesForClass, abilityAtLevel, isAbilityUnlocked, type AbilityDefinition, } from "./abilityCatalog"; import type { MobCombatState } from "./combatStore"; import { useCombatStore } from "./combatStore"; import type { MobVector3 } from "./mobPopulation"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { getMobPosition } from "./mobRuntimeRegistry"; import { getPartyRuntimePosition, partyRuntimeCenter, partyRuntimePositionAlongAttackCorridor, partyRuntimeTargetVisible, } 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 { equipmentAttackPower, hastedDuration, ratingPercent, type WotlkAttackKind, } from "./wotlkStats"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { emitCombatPresentation, type CombatPresentationDelivery, type CombatPresentationSchool, } from "./combatPresentation"; 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" | null; readonly presentation: PartyPresentationIdentity | null; } 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") .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, }; } /** * 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, ): 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]; next[resurrector.index] = { ...caster, 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), }; } } 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 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 requested = partyHealAmount(healer.level, healer.gearStats.spellPower); 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 = next[healerIndex]; next[healerIndex] = { ...currentHealer, status: "healing", nextActionAt: now + HEALER_ACTION_INTERVAL_MS, }; return { members: next, playerHealing: target.index < 0 ? healed : 0, playerResurrectionPercent: 0, targetId: target.id, effectiveHealing: healed, sourceMemberId: healer.id, action: "heal", presentation: partyHealPresentation(healer), }; } 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 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; let members = party.members; const partyOrigin = partyRuntimeCenter( members.map((member) => member.id), combat.playerPosition, ); let targetId = partyCommandTargetId( party.command, combat.mobs, combat.selectedTargetId, party.activeMobId, partyOrigin, ); const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null; let memberTargetIds: Record = {}; // A defeated player can watch the encounter continue while waiting for a // resurrection. Living companions remain active until the whole party falls. if (targetId && 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; } const activeMember = member.controlMechanic ? { ...member, controlledUntil: 0, controlMechanic: null } : member; if (activeMember.role === "healer") { delete memberTargetIds[activeMember.id]; if (now >= activeMember.nextActionAt && activeMember.status === "healing") { nextMembers[index] = { ...activeMember, status: "ready", nextActionAt: 0 }; } else { nextMembers[index] = activeMember; } continue; } member = activeMember; const desiredStatus = member.role === "tank" ? "guarding" : "attacking"; const currentCombat = useCombatStore.getState(); targetId = partyCommandTargetId( party.command, currentCombat.mobs, currentCombat.selectedTargetId, targetId, partyOrigin, ); if (!targetId) break; 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 ( member.role === "tank" && targetMob && targetMob.targetActorId !== member.id && !(targetMob.forcedTarget && targetMob.forcedTarget.endsAt > now) && now >= member.tauntReadyAt && targetPosition && targetVisible && distanceSquared(memberPosition, targetPosition) <= TANK_TAUNT_RANGE * TANK_TAUNT_RANGE ) { const tauntSource: ThreatSource = { actorId: member.id, role: "tank" }; if (currentCombat.tauntMob(memberTargetId, tauntSource, TANK_TAUNT_DURATION_MS, now)) { runtimeMember = { ...runtimeMember, tauntReadyAt: now + TANK_TAUNT_COOLDOWN_MS }; const tauntPresentation = { occurredAt: now, actorGroup: "party", sourceActorId: member.id, targetActorId: memberTargetId, abilityId: `party-taunt:${member.id}`, sourceSpellId: partyTauntSpellId(member), name: "Taunt", source: "wow335", school: "physical", delivery: "aura", origin: memberPosition, targetPosition, } as const; emitCombatPresentation({ ...tauntPresentation, phase: "release" }); emitCombatPresentation({ ...tauntPresentation, phase: "aura-start" }); } } if (now < member.nextActionAt) { if (runtimeMember.status !== desiredStatus) { runtimeMember = { ...runtimeMember, status: desiredStatus }; } nextMembers[index] = runtimeMember; continue; } const attackRange = partyAttackRange(member); const inRange = Boolean( targetPosition && targetVisible && distanceSquared(memberPosition, targetPosition) <= attackRange * attackRange && Math.abs(memberPosition[1] - targetPosition[1]) <= 8, ); if (!inRange) { nextMembers[index] = runtimeMember.status === "advancing" ? runtimeMember : { ...runtimeMember, status: "advancing" }; continue; } useCombatStore.getState().damageMob( memberTargetId, partyAttackDamage(member), now, { actorId: member.id, role: member.role, abilityId: `party:${member.id}` }, ); const presentation = partyAttackPresentation(member); const attackPresentation = { occurredAt: now, actorGroup: "party", sourceActorId: member.id, targetActorId: memberTargetId, abilityId: `party:${member.id}`, sourceSpellId: presentation.spellId, name: presentation.name, source: "wow335", school: presentation.school, delivery: presentation.delivery, origin: memberPosition, targetPosition: targetPosition!, } as const; emitCombatPresentation({ ...attackPresentation, phase: "release" }); emitCombatPresentation({ ...attackPresentation, phase: "impact" }); nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + memberSwingMs(member), }; } members = nextMembers; } else { memberTargetIds = {}; const shouldRegen = now >= nextRegenAt; if (shouldRegen) nextRegenAt = now + 1_000; 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 = shouldRegen && deltaSeconds > 0 ? Math.min(activeMember.maxHealth, activeMember.health + Math.max(1, activeMember.maxHealth * OUT_OF_COMBAT_REGEN_FRACTION)) : 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 }; }); } const currentPlayer = useCombatStore.getState(); const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now); const supporter = support.sourceMemberId ? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null : null; members = support.members; let effectiveSupport = support.effectiveHealing; if (support.playerResurrectionPercent > 0) { effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent); } if (support.playerHealing > 0) { effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing); } if (supporter && support.presentation && effectiveSupport > 0) { useCombatStore.getState().addHealingThreat( { actorId: supporter.id, role: supporter.role }, effectiveSupport, ); const presentation = support.presentation; const supporterPosition = getPartyRuntimePosition(supporter.id) ?? currentPlayer.playerPosition; const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId; const targetPosition = presentationTargetId === PLAYER_AGGRO_ID ? currentPlayer.playerPosition : presentationTargetId ? getPartyRuntimePosition(presentationTargetId) : null; const healPresentation = { occurredAt: now, actorGroup: "party", sourceActorId: supporter.id, ...(presentationTargetId ? { targetActorId: presentationTargetId } : {}), abilityId: `party-${support.action}:${supporter.id}`, sourceSpellId: presentation.spellId, name: presentation.name, source: "wow335", school: presentation.school, delivery: presentation.delivery, origin: supporterPosition, targetPosition: targetPosition ?? supporterPosition, } as const; emitCombatPresentation({ ...healPresentation, phase: "release" }); emitCombatPresentation({ ...healPresentation, phase: "impact" }); } const targetMapChanged = ( Object.keys(party.memberTargetIds).length !== Object.keys(memberTargetIds).length || Object.entries(memberTargetIds).some(([memberId, mobId]) => party.memberTargetIds[memberId] !== mobId) ); if ( membersDiffer(party.members, members) || party.activeMobId !== targetId || party.objectiveBossId !== objectiveBossId || targetMapChanged ) { party.commitRuntime({ members, activeMobId: targetId, objectiveBossId, memberTargetIds }); } }