Files
healer-man/server/arena-rules.mjs
2026-09-05 20:42:38 -04:00

116 lines
6.3 KiB
JavaScript

export const ARENA_BRACKETS = Object.freeze({
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
});
export const ARENA_RATING_BANDS = Object.freeze([
Object.freeze({ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 }),
Object.freeze({ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 }),
Object.freeze({ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 }),
Object.freeze({ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 }),
Object.freeze({ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 }),
Object.freeze({ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 }),
Object.freeze({ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 }),
]);
export const ARENA_MODES = Object.freeze(["rated-ai", "ranked-pvp"]);
export const ARENA_RULESETS = Object.freeze(["wow", "coa", "rom"]);
export function clampArenaRating(value) {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating) {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max)
?? ARENA_RATING_BANDS[0];
}
function lerp(left, right, amount) {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating) {
const normalized = clampArenaRating(rating);
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
const current = ARENA_RATING_BANDS[Math.max(0, index)];
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
const span = Math.max(1, current.max - current.min + 1);
const progress = current === next ? 0 : (normalized - current.min) / span;
return Object.freeze({
rating: normalized,
title: current.title,
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
minimumErrorChance: lerp(current.error, next.error, progress),
});
}
export function calculateArenaRating({ rating, opponentRating, score }) {
const before = clampArenaRating(rating);
const opposing = clampArenaRating(opponentRating);
const normalizedScore = score === 0.5 ? 0.5 : score > 0 ? 1 : 0;
if (normalizedScore === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((opposing - before) / 400));
const delta = Math.round(arenaRatingBand(before).k * (normalizedScore - expected));
const after = clampArenaRating(before + delta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs) {
const increases = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 30_000);
return Math.min(1, 0.1 + increases * 0.05);
}
export function applyArenaHealing(rawAmount, elapsedMs) {
return Math.max(0, Math.round(Math.max(0, Number(rawAmount) || 0) * (1 - arenaDampening(elapsedMs))));
}
export function arenaMatchTolerance(waitMs) {
return 100 + Math.floor(Math.max(0, Number(waitMs) || 0) / 10_000) * 100;
}
export function validateArenaComposition(bracket, roles, { allowPartial = false } = {}) {
const definition = ARENA_BRACKETS[bracket];
if (!definition) return { ok: false, code: "invalid_bracket", message: "Choose 2v2 or 3v3." };
if (!Array.isArray(roles) || roles.some((role) => !["healer", "damage"].includes(role))) {
return { ok: false, code: "invalid_arena_role", message: "Rated arenas allow healer and damage specializations only." };
}
const healers = roles.filter((role) => role === "healer").length;
const damage = roles.filter((role) => role === "damage").length;
if (roles.length > definition.size || healers > definition.healers || damage > definition.damage) {
return { ok: false, code: "invalid_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
if (!allowPartial && (roles.length !== definition.size || healers !== definition.healers || damage !== definition.damage)) {
return { ok: false, code: "incomplete_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
return { ok: true, missing: { healer: definition.healers - healers, damage: definition.damage - damage } };
}
export function arenaDrDuration(baseDurationMs, priorApplications, elapsedSinceEndMs = Infinity) {
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
}
export function shouldArenaBotChooseCorrectly(successChance, minimumErrorChance, random = Math.random) {
const cappedSuccess = Math.min(1 - Math.max(0, minimumErrorChance), Math.max(0, successChance));
return random() < cappedSuccess;
}
export function assertArenaQueueInput(input) {
if (!ARENA_BRACKETS[input?.bracket]) throw new Error("Choose 2v2 or 3v3.");
if (!ARENA_MODES.includes(input?.mode)) throw new Error("Choose Rated AI or Ranked PvP.");
if (!ARENA_RULESETS.includes(input?.ruleset)) throw new Error("Choose a valid arena ruleset.");
if (typeof input?.characterId !== "string" || !input.characterId.trim()) throw new Error("Choose a character.");
return {
bracket: input.bracket,
mode: input.mode,
ruleset: input.ruleset,
characterId: input.characterId.trim().slice(0, 100),
};
}