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