Files
healer-man/src/game/companionCooldownPolicy.ts
2026-09-19 21:01:51 -04:00

124 lines
5.2 KiB
TypeScript

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<Record<string, number>>;
}
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<CompanionCooldownPolicy> : {};
const generic: Partial<CompanionCooldownGenericRules> = 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<CompanionCooldownMechanicRule>;
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;
}