116 lines
6.3 KiB
JavaScript
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),
|
|
};
|
|
}
|