mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-19 21:50:52 -05:00
Better team sorting for LUTI play-offs (#2192)
* WIP * Finished * Adjust
This commit is contained in:
parent
3e1b7dfb67
commit
eb95870b8b
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
4411
app/features/tournament-bracket/core/tests/mocks-luti.ts
Normal file
4411
app/features/tournament-bracket/core/tests/mocks-luti.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user