Files
flxn-app/src/features/tournaments/utils/group-config.ts
yohlo c138442530
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m0s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 45s
fix
2026-03-01 21:46:59 -06:00

188 lines
5.8 KiB
TypeScript

import { GroupConfig } from "../types";
function isPowerOfTwo(n: number): boolean {
return n > 0 && (n & (n - 1)) === 0;
}
function getNextPowerOfTwo(n: number): number {
if (n <= 0) return 1;
if (isPowerOfTwo(n)) return n;
return Math.pow(2, Math.ceil(Math.log2(n)));
}
export interface GroupConfigOption extends GroupConfig {
groups_with_extra: number;
knockout_size: number;
wildcards_needed: number;
total_group_matches: number;
description: string;
}
export function calculateGroupConfigurations(teamCount: number): GroupConfigOption[] {
if (teamCount < 4) {
throw new Error("Need at least 4 teams for group stage");
}
const configs: GroupConfigOption[] = [];
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
const numGroups = Math.floor(teamCount / teamsPerGroup);
const remainder = teamCount % teamsPerGroup;
if (numGroups < 2) continue;
if (remainder > numGroups) continue;
const groupsWithExtra = remainder;
const groupsAtBaseSize = numGroups - groupsWithExtra;
const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1;
const matchesGuaranteed = minGroupSize - 1;
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
const teamsAdvancing = numGroups * advancePerGroup;
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
const wildcardsNeeded = knockoutSize - teamsAdvancing;
if (wildcardsNeeded > teamsAdvancing / 2) continue;
let totalGroupMatches = 0;
for (let i = 0; i < numGroups; i++) {
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
}
const description = generateDescription({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
groups_with_extra: groupsWithExtra,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
});
configs.push({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
seeding_method: "random",
groups_with_extra: groupsWithExtra,
knockout_size: knockoutSize,
wildcards_needed: wildcardsNeeded,
total_group_matches: totalGroupMatches,
description,
});
}
}
const uniqueConfigs = new Map<string, GroupConfigOption>();
for (const config of configs) {
const groupSizes: number[] = [];
for (let i = 0; i < config.num_groups; i++) {
const size = config.teams_per_group + (i < config.groups_with_extra ? 1 : 0);
groupSizes.push(size);
}
groupSizes.sort((a, b) => b - a);
const key = `${groupSizes.join(',')}_advance${config.advance_per_group}`;
if (!uniqueConfigs.has(key)) {
uniqueConfigs.set(key, config);
}
}
return Array.from(uniqueConfigs.values()).sort((a, b) => {
if (a.matches_guaranteed !== b.matches_guaranteed) {
return b.matches_guaranteed - a.matches_guaranteed;
}
if (a.wildcards_needed !== b.wildcards_needed) {
return a.wildcards_needed - b.wildcards_needed;
}
if (a.knockout_size !== b.knockout_size) {
return b.knockout_size - a.knockout_size;
}
return b.num_groups - a.num_groups;
});
}
function generateDescription(config: Partial<GroupConfigOption>): string {
const { num_groups, teams_per_group, groups_with_extra, matches_guaranteed, advance_per_group, knockout_size, wildcards_needed } = config;
let desc = '';
if (groups_with_extra && groups_with_extra > 0 && teams_per_group) {
const largerGroupSize = teams_per_group + 1;
const smallerGroupCount = num_groups! - groups_with_extra;
if (smallerGroupCount > 0) {
desc += `${groups_with_extra} group${groups_with_extra > 1 ? 's' : ''} of ${largerGroupSize}, `;
desc += `${smallerGroupCount} group${smallerGroupCount > 1 ? 's' : ''} of ${teams_per_group}`;
} else {
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${largerGroupSize}`;
}
} else {
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${teams_per_group}`;
}
desc += `${matches_guaranteed} match${matches_guaranteed! > 1 ? 'es' : ''} guaranteed`;
desc += ` • Top ${advance_per_group} advance`;
if (wildcards_needed && wildcards_needed > 0) {
desc += ` + ${wildcards_needed} wildcard${wildcards_needed > 1 ? 's' : ''}`;
}
desc += `${knockout_size}-team knockout`;
return desc;
}
export function assignTeamsToGroups(
teamIds: string[],
config: GroupConfigOption,
seed?: number
): string[][] {
const shuffled = shuffleArray([...teamIds], seed);
const groups: string[][] = [];
let teamIndex = 0;
for (let groupIndex = 0; groupIndex < config.num_groups; groupIndex++) {
const groupSize = config.teams_per_group + (groupIndex < config.groups_with_extra ? 1 : 0);
const groupTeams = shuffled.slice(teamIndex, teamIndex + groupSize);
groups.push(groupTeams);
teamIndex += groupSize;
}
return groups;
}
function shuffleArray<T>(array: T[], seed?: number): T[] {
const arr = [...array];
const random = seed !== undefined ? seededRandom(seed) : Math.random;
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function seededRandom(seed: number): () => number {
let value = seed;
return () => {
value = (value * 9301 + 49297) % 233280;
return value / 233280;
};
}
export function getGroupName(index: number): string {
return String.fromCharCode(65 + index);
}