Better team sorting for LUTI play-offs (#2192)

* WIP

* Finished

* Adjust
This commit is contained in:
Kalle 2025-04-16 12:10:56 +03:00 committed by GitHub
parent 3e1b7dfb67
commit eb95870b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 4573 additions and 22 deletions

View File

@ -14,7 +14,7 @@ import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ParticipantResult } from "~/modules/brackets-model";
import type { ParticipantResult, SeedOrdering } from "~/modules/brackets-model";
import type {
Ability,
MainWeaponId,
@ -626,6 +626,9 @@ export interface TournamentStageSettings {
groupCount?: number;
// SWISS
roundCount?: number;
// Not exposed as user setting currently, applies to all brackets except swiss
seedOrdering?: SeedOrdering[];
}
export const TOURNAMENT_STAGE_TYPES = [

View File

@ -31,7 +31,7 @@ interface CreateBracketArgs {
bracketIdx: number;
placements: number[];
}[];
seeding?: number[];
seeding?: (number | null)[];
settings: TournamentStageSettings | null;
requiresCheckIn: boolean;
startTime: Date | null;
@ -101,10 +101,10 @@ export abstract class Bracket {
this.id = id;
this.idx = idx;
this.preview = preview;
this.seeding = seeding;
this.seeding = seeding?.filter((teamId) => typeof teamId === "number");
this.tournament = tournament;
this.settings = settings;
this.data = data ?? this.generateMatchesData(this.seeding!);
this.data = data ?? this.generateMatchesData(seeding!);
this.canBeStarted = canBeStarted;
this.name = name;
this.teamsPendingCheckIn = teamsPendingCheckIn;
@ -288,7 +288,7 @@ export abstract class Bracket {
});
}
generateMatchesData(teams: number[]) {
generateMatchesData(teams: (number | null)[]) {
const manager = getTournamentManager();
// we need some number but does not matter what it is as the manager only contains one tournament
@ -300,7 +300,7 @@ export abstract class Bracket {
name: "Virtual",
type: this.type,
seeding:
this.type === "round_robin"
this.type === "round_robin" || teams.includes(null)
? teams
: fillWithNullTillPowerOfTwo(teams),
settings: this.tournament.bracketManagerSettings(

View File

@ -30,13 +30,28 @@ export function resolvePreparedForTheBracket({
anotherBracketIdx,
bracket,
] of tournament.ctx.settings.bracketProgression.entries()) {
const bracketSettingKeysToConsiderForEquivalence: Array<
keyof typeof bracket.settings
> = [
"groupCount",
"roundCount",
"teamsPerGroup",
"thirdPlaceMatch",
] as const;
if (
bracket.type === bracketPreparingFor.type &&
R.isDeepEqual(
bracket.sources?.map((s) => s.bracketIdx),
bracketPreparingFor.sources?.map((s) => s.bracketIdx),
) &&
R.isDeepEqual(bracket.settings, bracketPreparingFor.settings)
R.isDeepEqual(
R.pick(bracket.settings, bracketSettingKeysToConsiderForEquivalence),
R.pick(
bracketPreparingFor.settings ?? {},
bracketSettingKeysToConsiderForEquivalence,
),
)
) {
const bracketMaps = preparedByBracket?.[anotherBracketIdx];

View File

@ -7,6 +7,7 @@ import {
PADDLING_POOL_255_TOP_CUT_INITIAL_MATCHES,
PADDLING_POOL_257,
} from "./tests/mocks";
import { LUTI_S16_DIV_1 } from "./tests/mocks-luti";
import { SWIM_OR_SINK_167 } from "./tests/mocks-sos";
import {
progressions,
@ -27,6 +28,7 @@ describe("Follow-up bracket progression", () => {
hasCheckedOutTeam: true,
}),
);
const tournamentLUTIS16Div1 = new Tournament(LUTI_S16_DIV_1);
test("correct amount of teams in the top cut", () => {
expect(tournamentPP257.brackets[1].seeding?.length).toBe(18);
@ -183,6 +185,55 @@ describe("Follow-up bracket progression", () => {
// 1 team should get swapped meaning two matches are now different
expect(different, "Amount of different matches is incorrect").toBe(2);
});
test("avoids rematches in RR -> SE (LUTI S16 Div 1) - avoid as long as possible", () => {
const groupsMatches = tournamentLUTIS16Div1.brackets[0].data.match;
const newTopCutMatches = tournamentLUTIS16Div1.brackets[1].data.match;
const topHalfTeams = newTopCutMatches
.slice(0, 2)
.flatMap((match) => [match.opponent1, match.opponent2]);
const bottomHalfTeams = newTopCutMatches
.slice(2, 4)
.flatMap((match) => [match.opponent1, match.opponent2]);
for (const half of [topHalfTeams, bottomHalfTeams]) {
for (const team of half) {
if (!team?.id) {
throw new Error("Unexpected no team in the test data");
}
for (const otherTeam of half) {
if (!otherTeam || otherTeam.id === team.id) {
continue;
}
if (
groupsMatches.some(
(match) =>
match.opponent1?.id === team.id &&
match.opponent2?.id === otherTeam.id,
)
) {
throw new Error(
`Teams would meet each other earlier than necessary: ${team.id} vs ${otherTeam.id}`,
);
}
if (
groupsMatches.some(
(match) =>
match.opponent1?.id === otherTeam.id &&
match.opponent2?.id === team.id,
)
) {
throw new Error(
`Teams would meet each other earlier than necessary: ${otherTeam.id} vs ${team.id}`,
);
}
}
}
}
});
});
describe("Bracket progression override", () => {

View File

@ -220,7 +220,7 @@ export class Tournament {
requiresCheckIn,
});
const checkedInTeamsWithReplaysAvoided =
const { teams: checkedInTeamsWithReplaysAvoided, preseeded } =
this.avoidReplaysOfPreviousBracketOpponent(
checkedInTeams,
{
@ -240,7 +240,12 @@ export class Tournament {
name,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
settings: settings
? {
...settings,
seedOrdering: preseeded ? ["natural"] : undefined,
}
: null,
type,
sources,
createdAt: null,
@ -369,28 +374,47 @@ export class Tournament {
type: Tables["TournamentStage"]["type"];
},
settings: TournamentStageSettings,
) {
// rather arbitrary limit, but with smaller brackets avoiding replays is not possible
// and then later while loop hits iteration limit
if (teams.length < 8) return teams;
): { teams: (number | null)[]; preseeded: boolean } {
// can't have replays from previous brackets in the first bracket
// & no support yet for avoiding replays if many sources
if (bracket.sources?.length !== 1) return teams;
if (bracket.sources?.length !== 1) return { teams, preseeded: false };
const sourceBracket = this.bracketByIdx(bracket.sources[0].bracketIdx);
if (!sourceBracket) {
logger.warn(
"avoidReplaysOfPreviousBracketOpponent: Source bracket not found",
);
return teams;
return { teams, preseeded: false };
}
// should not happen but just in case
if (bracket.type === "round_robin" || bracket.type === "swiss") {
return teams;
return { teams, preseeded: false };
}
// special case for LUTI, not optimal for other brackets as it puts all the top seeds in one side of the bracket
// in LUTI this is okay because teams are more grouped by region
// eventually a robust solution should be developed possibly by arranging teams in groups in a specific way https://github.com/Drarig29/brackets-manager.js/issues/8
if (
sourceBracket.type === "round_robin" &&
["single_elimination", "double_elimination"].includes(bracket.type) &&
bracket.sources[0].placements.length === 2 &&
teams.length > 4 &&
this.isLeagueDivision
) {
// if the source bracket is round robin and the team is seeded in the same group
// as the other team, we can't avoid replays
return {
teams: this.optimizeTeamOrderFromRoundRobin(teams),
preseeded: true,
};
}
// rather arbitrary limit, but with smaller brackets avoiding replays is not possible
// and then later while loop hits iteration limit
if (teams.length < 8) return { teams, preseeded: false };
const sourceBracketEncounters = sourceBracket.data.match.reduce(
(acc, cur) => {
const oneId = cur.opponent1?.id;
@ -458,7 +482,7 @@ export class Tournament {
"avoidReplaysOfPreviousBracketOpponent: Avoiding replays failed, too many iterations",
);
return teams;
return { teams, preseeded: false };
}
const [oneId, twoId] = replays[0];
@ -474,7 +498,7 @@ export class Tournament {
`Avoiding replays failed, no potential switch candidates found in match: ${oneId} vs. ${twoId}`,
);
return teams;
return { teams, preseeded: false };
}
for (const candidate of potentialSwitchCandidates) {
@ -504,7 +528,47 @@ export class Tournament {
}
}
return newOrder;
return { teams: newOrder, preseeded: false };
}
/** Set teams in optimal order when they come from RR avoiding replays as late as possible
*
* Teams come in order of their group placement e.g. Group A 1st, Group B 1st, Group C 1st, Group A 2nd, Group B 2nd, Group C 2nd
* and the order is optimized so that every winner plays 2nd placer in first round and replays happen as lately as possible i.e.
* every groups 1st and 2nd placer are in the different halves of the bracket.
* If teams is not a power of two, nulls are added to represent byes, ensuring no bye is against another bye in the first round.
*/
private optimizeTeamOrderFromRoundRobin(_teams: number[]): (number | null)[] {
invariant(_teams.length > 4, "Not enough teams to optimize");
// adds BYEs represented with null at the end of the array if needed
const teams = fillWithNullTillPowerOfTwo(_teams);
const teamsPerHalf = Math.ceil(teams.length / 2);
const groupWinners = teams.slice(0, teamsPerHalf);
const groupRunnersUp = teams.slice(teamsPerHalf);
invariant(
groupWinners.length === groupRunnersUp.length,
"1st and 2nd placer count not even",
);
/*
E.g. here 'A' is the winner of group A and 'a' is the second place finisher of group A
A B C D d c b a
turns into pairings:
A B C D
d c b a
*/
const optimizedOrder: (number | null)[] = [];
for (let i = 0; i < teamsPerHalf; i++) {
optimizedOrder.push(groupWinners[i]);
optimizedOrder.push(groupRunnersUp[teamsPerHalf - i - 1]);
}
return optimizedOrder;
}
private divideTeamsToCheckedInAndNotCheckedIn({
@ -570,18 +634,23 @@ export class Tournament {
switch (type) {
case "single_elimination": {
if (participantsCount < 4) {
return { consolationFinal: false };
return {
consolationFinal: false,
seedOrdering: selectedSettings?.seedOrdering,
};
}
return {
consolationFinal:
selectedSettings?.thirdPlaceMatch ??
TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH,
seedOrdering: selectedSettings?.seedOrdering,
};
}
case "double_elimination": {
return {
grandFinal: "double",
seedOrdering: selectedSettings?.seedOrdering,
};
}
case "round_robin": {
@ -591,7 +660,9 @@ export class Tournament {
return {
groupCount: Math.ceil(participantsCount / teamsPerGroup),
seedOrdering: ["groups.seed_optimized"],
seedOrdering: selectedSettings?.seedOrdering ?? [
"groups.seed_optimized",
],
};
}
case "swiss": {

File diff suppressed because it is too large Load Diff