forked from phenom/healer-man
124 lines
5.2 KiB
TypeScript
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;
|
|
}
|