import type { AbilityDefinition } from "./abilityCatalog"; import { targetingRulesForAbility } from "./abilityRules"; import { COA_FIRST_PARTY_CLASS_IDS } from "./coaFirstPartyRoster"; import type { RotationContext } from "./combatRotation"; type CoaPeriodicContext = Pick; type MaintainedPeriodicEffect = NonNullable[number]; const COA_PERIODIC_SOURCES = new Set(["coa-starter", "coa-progression", "coa-tree"]); /** * Identifies the narrow CoA case where maintenance is target-specific. Callers * should use the selected ability target (the same recipient used for scoring) * when asking whether its effect is already present. */ export function usesCoaTargetedPeriodicMaintenance( ability: AbilityDefinition, _context?: CoaPeriodicContext, ): boolean { if (!COA_FIRST_PARTY_CLASS_IDS.some((classId) => classId === ability.classId) || !ability.source || !COA_PERIODIC_SOURCES.has(ability.source) || targetingRulesForAbility(ability).shape !== "unit") return false; return ability.effects.some((effect) => ( effect.kind === "dot" && (ability.target === "hostile" || ability.target === "any") ) || ( effect.kind === "hot" && effect.recipient !== "caster" && (ability.target === "friendly" || ability.target === "any") )); } /** A live periodic on another target or from another actor never maintains this cast target. */ export function coaPeriodicActionMaintainedOnTarget( ability: AbilityDefinition, context: CoaPeriodicContext, targetId: string | null | undefined, ): boolean { const casterId = context.selfId; if (!usesCoaTargetedPeriodicMaintenance(ability, context) || !casterId || !targetId) return false; return (context.maintainedPeriodicEffects ?? []).some((effect: MaintainedPeriodicEffect) => ( effect.abilityId === ability.id && effect.sourceActorId === casterId && effect.targetId === targetId && Number.isFinite(effect.remainingMs) && effect.remainingMs > 0 )); }