188 lines
5.8 KiB
TypeScript
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);
|
|
}
|