export const DEFAULT_COMPANION_COOLDOWN_POLICY = Object.freeze({ schemaVersion: 1, generic: Object.freeze({ defensiveHealthPercent: 55, emergencyHealthPercent: 35, healerMitigationHealthPercent: 80, offensiveMajorCooldownSeconds: 60, conserveOffensiveCooldownsForDurableTargets: true, }), mechanics: Object.freeze([]), }); function invalid(message) { const error = new Error(message); error.code = "invalid_companion_cooldown_policy"; return error; } function number(value, label, minimum, maximum) { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) { throw invalid(`${label} must be a number from ${minimum} through ${maximum}.`); } return value; } function string(value, label, allowEmpty = false) { if (typeof value !== "string" || (!allowEmpty && !value.trim()) || value.length > 256) { throw invalid(`${label} must be ${allowEmpty ? "a" : "a non-empty"} string no longer than 256 characters.`); } return value.trim(); } export function normalizeCompanionCooldownPolicy(value) { if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) { throw invalid("Companion cooldown policy schemaVersion must be 1."); } const generic = value.generic; if (!generic || typeof generic !== "object" || Array.isArray(generic)) throw invalid("generic rules are required."); if (typeof generic.conserveOffensiveCooldownsForDurableTargets !== "boolean") { throw invalid("generic.conserveOffensiveCooldownsForDurableTargets must be boolean."); } if (!Array.isArray(value.mechanics) || value.mechanics.length > 1_000) throw invalid("mechanics must be an array of at most 1,000 rules."); const seen = new Set(); const mechanics = value.mechanics.map((rule, index) => { if (!rule || typeof rule !== "object" || Array.isArray(rule)) throw invalid(`mechanics[${index}] must be an object.`); const id = string(rule.id, `mechanics[${index}].id`); if (seen.has(id)) throw invalid(`mechanic rule id ${id} is duplicated.`); seen.add(id); if (typeof rule.enabled !== "boolean") throw invalid(`mechanics[${index}].enabled must be boolean.`); if (!new Set(["tank", "party"]).has(rule.scope)) throw invalid(`mechanics[${index}].scope is invalid.`); const serverEntry = rule.bossServerEntry; if (serverEntry !== null && (!Number.isInteger(serverEntry) || serverEntry <= 0)) { throw invalid(`mechanics[${index}].bossServerEntry must be null or a positive integer.`); } const bossName = string(rule.bossName, `mechanics[${index}].bossName`, true); if (serverEntry === null && !bossName) { throw invalid(`mechanics[${index}].bossName is required when bossServerEntry is null.`); } return { id, enabled: rule.enabled, bossKey: string(rule.bossKey, `mechanics[${index}].bossKey`, true), bossName, bossServerEntry: serverEntry, attackId: string(rule.attackId, `mechanics[${index}].attackId`), attackName: string(rule.attackName, `mechanics[${index}].attackName`), scope: rule.scope, leadTimeMs: number(rule.leadTimeMs, `mechanics[${index}].leadTimeMs`, 0, 15_000), minimumCooldownSeconds: number(rule.minimumCooldownSeconds, `mechanics[${index}].minimumCooldownSeconds`, 0, 600), }; }); return { schemaVersion: 1, generic: { defensiveHealthPercent: number(generic.defensiveHealthPercent, "generic.defensiveHealthPercent", 1, 100), emergencyHealthPercent: number(generic.emergencyHealthPercent, "generic.emergencyHealthPercent", 1, 100), healerMitigationHealthPercent: number(generic.healerMitigationHealthPercent, "generic.healerMitigationHealthPercent", 1, 100), offensiveMajorCooldownSeconds: number(generic.offensiveMajorCooldownSeconds, "generic.offensiveMajorCooldownSeconds", 0, 600), conserveOffensiveCooldownsForDurableTargets: generic.conserveOffensiveCooldownsForDurableTargets, }, mechanics, }; } export function cloneDefaultCompanionCooldownPolicy() { return JSON.parse(JSON.stringify(DEFAULT_COMPANION_COOLDOWN_POLICY)); }