81 lines
4.4 KiB
TypeScript
81 lines
4.4 KiB
TypeScript
export type ArenaBracket = "2v2" | "3v3";
|
|
export type ArenaMode = "practice" | "rated-ai" | "ranked-pvp";
|
|
export type ArenaRuleset = "wow" | "coa" | "rom";
|
|
export type ArenaRole = "healer" | "damage";
|
|
|
|
export interface ArenaAiProfile {
|
|
readonly rating: number;
|
|
readonly title: string;
|
|
readonly reactionMinSeconds: number;
|
|
readonly reactionMaxSeconds: number;
|
|
readonly healerInterruptChance: number;
|
|
readonly coordinatedCcChance: number;
|
|
readonly correctDefensiveChance: number;
|
|
readonly minimumErrorChance: number;
|
|
}
|
|
|
|
export const ARENA_BRACKETS: Readonly<Record<ArenaBracket, { readonly size: 2 | 3; readonly healers: 1; readonly damage: 1 | 2 }>> = 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([
|
|
{ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 },
|
|
{ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 },
|
|
{ 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 },
|
|
{ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 },
|
|
{ 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 },
|
|
{ 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 },
|
|
{ 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 },
|
|
] as const);
|
|
|
|
export function clampArenaRating(value: number): number {
|
|
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
|
|
}
|
|
|
|
export function arenaRatingBand(rating: number): typeof ARENA_RATING_BANDS[number] {
|
|
const normalized = clampArenaRating(rating);
|
|
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max) ?? ARENA_RATING_BANDS[0];
|
|
}
|
|
|
|
function lerp(left: number, right: number, amount: number): number {
|
|
return left + (right - left) * Math.max(0, Math.min(1, amount));
|
|
}
|
|
|
|
export function arenaAiProfile(rating: number): ArenaAiProfile {
|
|
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 progress = current === next ? 0 : (normalized - current.min) / Math.max(1, current.max - current.min + 1);
|
|
return {
|
|
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: number, opponentRating: number, score: 0 | 0.5 | 1) {
|
|
const before = clampArenaRating(rating);
|
|
if (score === 0.5) return { before, expected: 0.5, delta: 0, after: before };
|
|
const expected = 1 / (1 + 10 ** ((clampArenaRating(opponentRating) - before) / 400));
|
|
const rawDelta = Math.round(arenaRatingBand(before).k * (score - expected));
|
|
const after = clampArenaRating(before + rawDelta);
|
|
return { before, expected, delta: after - before, after };
|
|
}
|
|
|
|
export function arenaDampening(elapsedMs: number): number {
|
|
return Math.min(1, 0.1 + Math.floor(Math.max(0, elapsedMs) / 30_000) * 0.05);
|
|
}
|
|
|
|
export function arenaDrDuration(baseDurationMs: number, priorApplications: number, 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 };
|
|
}
|