import type { AbilityDefinition, AbilityHealingRecipients } from "./abilityCatalog"; import type { CombatActorState } from "./combatActors"; import type { TimedCombatEffect } from "./combatStore"; import { PLAYER_AGGRO_ID } from "./aggro"; import { targetingRulesForAbility } from "./abilityRules"; type Position = readonly [number, number, number]; /** Healing riders retain their own recipients even when the cast targets an enemy. */ export function healingRecipientIds( effect: AbilityHealingRecipients, sourceId: string, targetId: string | null, actors: Readonly>, positionFor: (id: string) => Position | null, fallback: readonly string[], hasLineOfSight: (from: Position, to: Position) => boolean, seed = 0, ): readonly string[] { if (effect.scope !== "party") return effect.recipient === "caster" ? [sourceId] : fallback; const center = positionFor(effect.center === "ability-target" && targetId ? targetId : sourceId); if (!center) return []; const candidates = Object.values(actors) .filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0) .filter(actor => !effect.excludeCaster || actor.id !== sourceId) .filter(actor => { const position = positionFor(actor.id); return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2 && hasLineOfSight(center, position); }) .sort((a, b) => effect.selection === "lowest-max-health" ? a.maxHealth - b.maxHealth || a.id.localeCompare(b.id) : a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id)); if (effect.selection === "random" && candidates.length > 0) { let hash = 2166136261; for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619); const offset = Math.floor((hash >>> 0) / 0x1_0000_0000 * candidates.length); return [...candidates.slice(offset), ...candidates.slice(0, offset)].slice(0, effect.maximumTargets ?? 1).map(actor => actor.id); } return candidates.slice(0, effect.maximumTargets ?? Infinity).map(actor => actor.id); } export function healingAbilityCanAidOthers(ability: AbilityDefinition): boolean { return ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind) && "scope" in effect && effect.scope === "party") || (ability.target !== "self" && ability.target !== "hostile"); } /** Let an existing HoT finish rather than resetting its first tick on every GCD. */ export function healingAbilityHasPendingHot( ability: AbilityDefinition, sourceId: string, targetId: string, effects: readonly TimedCombatEffect[], ): boolean { return ability.effects.some(effect => effect.kind === "hot") && effects.some(effect => effect.kind === "hot" && effect.sourceAbilityId === ability.id && effect.sourceActorId === sourceId && (effect.targetId ?? PLAYER_AGGRO_ID) === targetId && effect.remainingTicks > 1); } export function healingAbilityIsArea(ability: AbilityDefinition): boolean { const rules = targetingRulesForAbility(ability); return rules.shape !== "unit" || (rules.maximumTargets ?? 1) > 1 || (rules.radius ?? ability.radius ?? 0) > 0 || ability.effects.some(effect => "scope" in effect && effect.scope === "party"); }