Files
healer-man/server/companion-cooldown-policy.mjs
2026-09-19 21:01:51 -04:00

88 lines
4.1 KiB
JavaScript

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));
}