sendou.ink/app/features/tournament/core/tiering.ts
2026-01-22 20:04:26 +02:00

149 lines
3.4 KiB
TypeScript

/**
* Tournament tier calculation based on top teams' average SeedingSkill ordinal.
* Lower tier number = better tier (like placements).
*/
export const TIER_THRESHOLDS = {
X: 32,
"S+": 29,
S: 26,
"A+": 24,
A: 21,
"B+": 15,
B: 10,
"C+": 5,
C: Number.NEGATIVE_INFINITY,
} as const;
export const TOP_TEAMS_COUNT = 8;
export const MIN_TEAMS_FOR_TIERING = 8;
export const TIER_HISTORY_LENGTH = 5;
const SIZE_BONUS = {
NO_BONUS_ABOVE: 32,
MAX_BONUS_PER_10_TEAMS: 1.5,
} as const;
export const TIER_TO_NUMBER = {
X: 1,
"S+": 2,
S: 3,
"A+": 4,
A: 5,
"B+": 6,
B: 7,
"C+": 8,
C: 9,
} as const;
const NUMBER_TO_TIER = {
1: "X",
2: "S+",
3: "S",
4: "A+",
5: "A",
6: "B+",
7: "B",
8: "C+",
9: "C",
} as const;
export type TournamentTier = keyof typeof TIER_TO_NUMBER;
export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier];
export function calculateAdjustedScore(
rawScore: number,
teamCount: number,
): number {
const scaleFactor = Math.max(
0,
(SIZE_BONUS.NO_BONUS_ABOVE - rawScore) / SIZE_BONUS.NO_BONUS_ABOVE,
);
const teamsAboveMin = Math.max(0, teamCount - MIN_TEAMS_FOR_TIERING);
const bonus =
scaleFactor * SIZE_BONUS.MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10);
return rawScore + bonus;
}
export function calculateTierNumber(
score: number | null,
): TournamentTierNumber | null {
if (score === null) return null;
const tiers = Object.entries(TIER_THRESHOLDS) as [TournamentTier, number][];
for (const [tier, threshold] of tiers) {
if (score >= threshold) return TIER_TO_NUMBER[tier];
}
return TIER_TO_NUMBER.C;
}
export function tierNumberToName(tierNumber: number): TournamentTier {
const tier = NUMBER_TO_TIER[tierNumber as TournamentTierNumber];
if (!tier) {
throw new Error(`Invalid tier number: ${tierNumber}`);
}
return tier;
}
export function calculateTournamentTierFromTeams(
teams: Array<{ avgOrdinal: number | null }>,
totalTeamCount: number,
): {
tierNumber: TournamentTierNumber | null;
rawScore: number | null;
adjustedScore: number | null;
} {
if (totalTeamCount < MIN_TEAMS_FOR_TIERING) {
return { tierNumber: null, rawScore: null, adjustedScore: null };
}
const teamsWithOrdinal = teams.filter(
(t): t is { avgOrdinal: number } => t.avgOrdinal !== null,
);
if (teamsWithOrdinal.length === 0) {
return { tierNumber: null, rawScore: null, adjustedScore: null };
}
const topTeams = teamsWithOrdinal
.sort((a, b) => b.avgOrdinal - a.avgOrdinal)
.slice(0, TOP_TEAMS_COUNT);
const rawScore =
topTeams.reduce((sum, t) => sum + t.avgOrdinal, 0) / topTeams.length;
const adjustedScore = calculateAdjustedScore(rawScore, totalTeamCount);
const tierNumber = calculateTierNumber(adjustedScore);
return { tierNumber, rawScore, adjustedScore };
}
export function calculateTentativeTier(
tierHistory: TournamentTierNumber[],
): TournamentTierNumber | null {
if (tierHistory.length === 0) return null;
const sorted = [...tierHistory].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return Math.ceil(
(sorted[mid - 1] + sorted[mid]) / 2,
) as TournamentTierNumber;
}
return sorted[mid];
}
export function updateTierHistory(
currentHistory: TournamentTierNumber[] | null,
newTier: TournamentTierNumber,
): TournamentTierNumber[] {
const history = currentHistory ?? [];
const updated = [...history, newTier];
return updated.length > TIER_HISTORY_LENGTH
? updated.slice(-TIER_HISTORY_LENGTH)
: updated;
}