export const PLAYER_AGGRO_ID = "__player__"; export type AggroActorId = string; export type AggroRole = "tank" | "healer" | "damage"; export type ThreatTable = Readonly>; export interface ThreatSource { readonly actorId: AggroActorId; readonly role: AggroRole; readonly abilityId?: string; /** Final scalar applied after the role multiplier, typically from talents. */ readonly threatScale?: number; } export interface ForcedTarget { readonly actorId: AggroActorId; readonly endsAt: number; } export const TANK_DAMAGE_THREAT_MULTIPLIER = 5; export const DAMAGE_THREAT_MULTIPLIER = 1; export const HEALING_THREAT_MULTIPLIER = 0.25; export const PACK_PULL_THREAT = 1; function safeThreat(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0; } export function threatFromDamage(amount: number, role: AggroRole, threatScale = 1): number { return safeThreat(amount) * ( role === "tank" ? TANK_DAMAGE_THREAT_MULTIPLIER : DAMAGE_THREAT_MULTIPLIER ) * safeThreat(threatScale); } export function threatFromHealing(amount: number, engagedMobCount: number): number { const count = Math.max(0, Math.trunc(engagedMobCount)); return count > 0 ? safeThreat(amount) * HEALING_THREAT_MULTIPLIER / count : 0; } export function addActorThreat( table: ThreatTable, actorId: AggroActorId, amount: number, ): ThreatTable { const added = safeThreat(amount); if (!actorId || added <= 0) return table; return { ...table, [actorId]: safeThreat(table[actorId] ?? 0) + added, }; } export function seedActorThreat( table: ThreatTable, actorId: AggroActorId, amount = PACK_PULL_THREAT, ): ThreatTable { if (!actorId || safeThreat(table[actorId] ?? 0) > 0) return table; return addActorThreat(table, actorId, amount); } export function tauntThreat( table: ThreatTable, actorId: AggroActorId, ): ThreatTable { const maximum = Object.values(table).reduce((highest, value) => ( Math.max(highest, safeThreat(value)) ), 0); return { ...table, [actorId]: maximum + 1, }; } export interface AggroTargetResolution { readonly targetActorId: AggroActorId | null; readonly forcedTarget: ForcedTarget | null; } export function resolveAggroTarget( table: ThreatTable, eligibleActorIds: ReadonlySet, currentTargetActorId: AggroActorId | null, forcedTarget: ForcedTarget | null, now: number, ): AggroTargetResolution { const activeForced = forcedTarget && forcedTarget.endsAt > now && eligibleActorIds.has(forcedTarget.actorId) ? forcedTarget : null; if (activeForced) { return { targetActorId: activeForced.actorId, forcedTarget: activeForced }; } const ranked = Object.entries(table) .filter(([actorId, threat]) => eligibleActorIds.has(actorId) && safeThreat(threat) > 0) .sort(([leftId, leftThreat], [rightId, rightThreat]) => ( safeThreat(rightThreat) - safeThreat(leftThreat) || Number(rightId === currentTargetActorId) - Number(leftId === currentTargetActorId) || leftId.localeCompare(rightId) )); return { targetActorId: ranked[0]?.[0] ?? null, forcedTarget: null, }; } export function pruneThreatTable( table: ThreatTable, eligibleActorIds: ReadonlySet, ): ThreatTable { const entries = Object.entries(table).filter(([actorId, threat]) => ( eligibleActorIds.has(actorId) && safeThreat(threat) > 0 )); if ( entries.length === Object.keys(table).length && entries.every(([actorId, threat]) => table[actorId] === threat) ) return table; return Object.fromEntries(entries); }