import type { PartyRole } from "./partyRoles"; export interface CompanionCooldownGenericRules { readonly defensiveHealthPercent: number; readonly emergencyHealthPercent: number; readonly healerMitigationHealthPercent: number; readonly offensiveMajorCooldownSeconds: number; readonly conserveOffensiveCooldownsForDurableTargets: boolean; } export interface CompanionCooldownMechanicRule { readonly id: string; readonly enabled: boolean; readonly bossKey: string; readonly bossName: string; readonly bossServerEntry: number | null; readonly attackId: string; readonly attackName: string; readonly scope: "tank" | "party"; readonly leadTimeMs: number; readonly minimumCooldownSeconds: number; } export interface CompanionCooldownPolicy { readonly schemaVersion: 1; readonly generic: CompanionCooldownGenericRules; readonly mechanics: readonly CompanionCooldownMechanicRule[]; } export interface CooldownPolicyMobState { readonly name?: string; readonly serverEntry?: number | null; readonly activeCast?: { readonly attackId: string; readonly completesAt: number } | null; readonly abilityReadyAt?: Readonly>; } export const DEFAULT_COMPANION_COOLDOWN_POLICY: CompanionCooldownPolicy = Object.freeze({ schemaVersion: 1, generic: Object.freeze({ defensiveHealthPercent: 55, emergencyHealthPercent: 35, healerMitigationHealthPercent: 80, offensiveMajorCooldownSeconds: 60, conserveOffensiveCooldownsForDurableTargets: true, }), mechanics: Object.freeze([]), }); let installedPolicy = DEFAULT_COMPANION_COOLDOWN_POLICY; function bounded(value: unknown, fallback: number, minimum: number, maximum: number): number { const number = Number(value); return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback; } export function normalizeCompanionCooldownPolicy(value: unknown): CompanionCooldownPolicy { const input = value && typeof value === "object" ? value as Partial : {}; const generic: Partial = input.generic && typeof input.generic === "object" ? input.generic : {}; const defaults = DEFAULT_COMPANION_COOLDOWN_POLICY.generic; const mechanics = Array.isArray(input.mechanics) ? input.mechanics.flatMap((candidate, index) => { if (!candidate || typeof candidate !== "object") return []; const rule = candidate as Partial; const attackId = typeof rule.attackId === "string" ? rule.attackId.trim() : ""; if (!attackId) return []; return [{ id: typeof rule.id === "string" && rule.id.trim() ? rule.id.trim() : `mechanic-${index + 1}`, enabled: rule.enabled !== false, bossKey: typeof rule.bossKey === "string" ? rule.bossKey : "", bossName: typeof rule.bossName === "string" ? rule.bossName : "", bossServerEntry: rule.bossServerEntry !== null && rule.bossServerEntry !== undefined && Number.isFinite(Number(rule.bossServerEntry)) ? Math.trunc(Number(rule.bossServerEntry)) : null, attackId, attackName: typeof rule.attackName === "string" ? rule.attackName : attackId, scope: rule.scope === "party" ? "party" as const : "tank" as const, leadTimeMs: bounded(rule.leadTimeMs, 2_500, 0, 15_000), minimumCooldownSeconds: bounded(rule.minimumCooldownSeconds, 20, 0, 600), }]; }) : []; return Object.freeze({ schemaVersion: 1, generic: Object.freeze({ defensiveHealthPercent: bounded(generic.defensiveHealthPercent, defaults.defensiveHealthPercent, 1, 100), emergencyHealthPercent: bounded(generic.emergencyHealthPercent, defaults.emergencyHealthPercent, 1, 100), healerMitigationHealthPercent: bounded(generic.healerMitigationHealthPercent, defaults.healerMitigationHealthPercent, 1, 100), offensiveMajorCooldownSeconds: bounded(generic.offensiveMajorCooldownSeconds, defaults.offensiveMajorCooldownSeconds, 0, 600), conserveOffensiveCooldownsForDurableTargets: generic.conserveOffensiveCooldownsForDurableTargets !== false, }), mechanics: Object.freeze(mechanics), }); } export function installCompanionCooldownPolicy(value: unknown): CompanionCooldownPolicy { installedPolicy = normalizeCompanionCooldownPolicy(value); return installedPolicy; } export function currentCompanionCooldownPolicy(): CompanionCooldownPolicy { return installedPolicy; } export function imminentCooldownMechanic( mob: CooldownPolicyMobState | null, role: PartyRole, now: number, policy = installedPolicy, ): CompanionCooldownMechanicRule | null { if (!mob) return null; return policy.mechanics.find((rule) => { if (!rule.enabled || (rule.scope === "tank" && role !== "tank")) return false; if (rule.bossServerEntry !== null && mob.serverEntry !== rule.bossServerEntry) return false; if (rule.bossServerEntry === null && rule.bossName && mob.name !== rule.bossName) return false; if (mob.activeCast?.attackId === rule.attackId) { const remaining = mob.activeCast.completesAt - now; return remaining >= 0 && remaining <= rule.leadTimeMs; } const readyAt = mob.abilityReadyAt?.[rule.attackId]; return readyAt !== undefined && readyAt >= now && readyAt - now <= rule.leadTimeMs; }) ?? null; }