mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 07:44:43 -05:00
Break standings ties with underground bracket results, handle UG source not as first bracket in the order
This commit is contained in:
parent
2c9cc7502a
commit
b94b5fd503
|
|
@ -1392,6 +1392,14 @@ describe("bracketIdxsForStandings", () => {
|
|||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when SE is the source
|
||||
});
|
||||
|
||||
it("does not treat a bracket as intermediate just because an underground bracket sources from it", () => {
|
||||
expect(
|
||||
Progression.bracketIdxsForStandings(
|
||||
progressions.swissToTwoSingleEliminationsWithUnderground,
|
||||
),
|
||||
).toEqual([1, 2, 0]); // missing 3 because it's underground
|
||||
});
|
||||
});
|
||||
|
||||
describe("startingBrackets", () => {
|
||||
|
|
|
|||
|
|
@ -931,11 +931,18 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) {
|
|||
const bracketsToConsider = bracketsReachableFrom(0, progression);
|
||||
|
||||
const withoutIntermediateBrackets = bracketsToConsider.filter(
|
||||
(bracket, bracketIdx) => {
|
||||
(bracketIdx) => {
|
||||
if (bracketIdx === 0) return true;
|
||||
|
||||
// underground brackets don't make their source bracket an intermediate one
|
||||
const undergrounds = new Set(
|
||||
undergroundBracketIdxs(bracketIdx, progression),
|
||||
);
|
||||
|
||||
return progression.every(
|
||||
(b) => !b.sources?.some((s) => s.bracketIdx === bracket),
|
||||
(b, idx) =>
|
||||
undergrounds.has(idx) ||
|
||||
!b.sources?.some((s) => s.bracketIdx === bracketIdx),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1033,6 +1040,23 @@ export function destinationsFromBracketIdx(
|
|||
return destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the indexes of the underground brackets sourced from the given bracket.
|
||||
* An underground bracket is one that takes teams eliminated from its source bracket (negative placements).
|
||||
*/
|
||||
export function undergroundBracketIdxs(
|
||||
bracketIdx: number,
|
||||
progression: ParsedBracket[],
|
||||
): number[] {
|
||||
return destinationsFromBracketIdx(bracketIdx, progression).filter((idx) =>
|
||||
progression[idx].sources?.some(
|
||||
(source) =>
|
||||
source.bracketIdx === bracketIdx &&
|
||||
source.placements.some((placement) => placement < 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function destinationByPlacement({
|
||||
sourceBracketIdx,
|
||||
placement,
|
||||
|
|
|
|||
|
|
@ -336,4 +336,46 @@ export const progressions = {
|
|||
],
|
||||
},
|
||||
],
|
||||
swissToTwoSingleEliminationsWithUnderground: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "swiss",
|
||||
settings: {
|
||||
groupCount: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Alpha",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Beta",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [9, 10, 11, 12, 13, 14, 15, 16],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Alpha UG",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 1,
|
||||
placements: [-1],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Progression.ParsedBracket[]>;
|
||||
|
|
|
|||
|
|
@ -80,6 +80,77 @@ describe("tournamentStandings", () => {
|
|||
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("breaks ties of a bracket with the results of its underground bracket", () => {
|
||||
const tournament = singleEliminationWithUndergroundTournament();
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
// teams 5-8 all lost the quarterfinals so they are tied in the main bracket,
|
||||
// the underground bracket (won by 8, then 7, 6, 5) decides their order
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 7, 6, 5,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps teams that skipped the underground bracket tied below those who played it", () => {
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundSeeding: [7, 8],
|
||||
});
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 7, 5, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 7,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not break ties with an underground bracket that is still in progress", () => {
|
||||
// only the semi-finals of the underground bracket have been played so the two teams
|
||||
// still alive there have no placement yet
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundConsolationFinal: false,
|
||||
undergroundMatchesPlayed: 2,
|
||||
});
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
// teams 5-8 keep the order & tied placement they have in the main bracket,
|
||||
// the teams eliminated from the underground bracket are not sorted above those still in it
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 5, 7, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 5, 5, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not break ties with an underground bracket that was never started", () => {
|
||||
// an underground bracket set in the progression can be skipped altogether
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundStarted: false,
|
||||
});
|
||||
expect(tournament.bracketByIdx(1)?.preview).toBe(true);
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 5, 7, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 5, 5, 5,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reNumberPlacements", () => {
|
||||
|
|
@ -216,6 +287,68 @@ function singleEliminationTournament() {
|
|||
});
|
||||
}
|
||||
|
||||
function singleEliminationWithUndergroundTournament({
|
||||
undergroundSeeding = [5, 6, 7, 8],
|
||||
undergroundConsolationFinal = undergroundSeeding.length > 2,
|
||||
undergroundMatchesPlayed,
|
||||
undergroundStarted = true,
|
||||
}: {
|
||||
undergroundSeeding?: number[];
|
||||
undergroundConsolationFinal?: boolean;
|
||||
undergroundMatchesPlayed?: number;
|
||||
undergroundStarted?: boolean;
|
||||
} = {}) {
|
||||
const mainBracket = playOut(
|
||||
createResolved({
|
||||
type: "single_elimination",
|
||||
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
settings: { consolationFinal: true },
|
||||
}),
|
||||
(one, two) => one < two,
|
||||
);
|
||||
|
||||
const data = undergroundStarted
|
||||
? mergeStages(
|
||||
mainBracket,
|
||||
playOut(
|
||||
createResolved({
|
||||
type: "single_elimination",
|
||||
seeding: undergroundSeeding,
|
||||
settings: { consolationFinal: undergroundConsolationFinal },
|
||||
}),
|
||||
(one, two) => one > two,
|
||||
undergroundMatchesPlayed,
|
||||
),
|
||||
)
|
||||
: mainBracket;
|
||||
|
||||
return testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Main Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: true },
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Underground",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: true },
|
||||
sources: [{ bracketIdx: 0, placements: [-1] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) =>
|
||||
tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }),
|
||||
),
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function abDivisionsTournament() {
|
||||
let data = createResolved({
|
||||
type: "round_robin",
|
||||
|
|
@ -273,9 +406,22 @@ function abDivisionsTournament() {
|
|||
|
||||
/** Plays every match of the bracket data, the lower team id always winning. */
|
||||
function playOutLowerIdWins(data: BracketData) {
|
||||
let played = data;
|
||||
return playOut(data, (one, two) => one < two);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
/**
|
||||
* Plays every match of the bracket data, `opponent1Wins` deciding each match by team id.
|
||||
* `maxMatches` can be given to leave the bracket in progress.
|
||||
*/
|
||||
function playOut(
|
||||
data: BracketData,
|
||||
opponent1Wins: (opponent1Id: number, opponent2Id: number) => boolean,
|
||||
maxMatches = Number.POSITIVE_INFINITY,
|
||||
) {
|
||||
let played = data;
|
||||
let playedCount = 0;
|
||||
|
||||
while (playedCount < maxMatches) {
|
||||
const pending = played.match.find(
|
||||
(match) =>
|
||||
typeof match.opponent1?.id === "number" &&
|
||||
|
|
@ -284,12 +430,16 @@ function playOutLowerIdWins(data: BracketData) {
|
|||
);
|
||||
if (!pending) break;
|
||||
|
||||
const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!;
|
||||
const winnerIsOpp1 = opponent1Wins(
|
||||
pending.opponent1!.id as number,
|
||||
pending.opponent2!.id as number,
|
||||
);
|
||||
played = Engine.reportResult(played, {
|
||||
matchId: pending.id,
|
||||
scores: [winnerIsOpp1 ? 2 : 0, winnerIsOpp1 ? 0 : 2],
|
||||
winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2",
|
||||
}).data;
|
||||
playedCount++;
|
||||
}
|
||||
|
||||
return played;
|
||||
|
|
|
|||
|
|
@ -257,7 +257,11 @@ function tournamentStandingsForBracket(
|
|||
|
||||
const standings = standingsToMergeable({
|
||||
alreadyIncludedTeamIds,
|
||||
standings: bracket.standings,
|
||||
standings: tiebrokenByUndergroundBrackets({
|
||||
tournament,
|
||||
bracketIdx: idx,
|
||||
standings: bracket.standings,
|
||||
}),
|
||||
teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size,
|
||||
});
|
||||
result.push(...standings);
|
||||
|
|
@ -273,6 +277,85 @@ function tournamentStandingsForBracket(
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Underground brackets are left out of the standings but the teams playing them are tied in their source
|
||||
* bracket (e.g. everyone who lost the quarterfinals shares the same placement), so their underground run
|
||||
* decides the order within each such tie. Teams that skipped the underground bracket stay tied last.
|
||||
*
|
||||
* An underground bracket that is still in progress is ignored, as the teams still alive in it have no
|
||||
* placement yet and would sort below the teams it already eliminated.
|
||||
*/
|
||||
function tiebrokenByUndergroundBrackets({
|
||||
tournament,
|
||||
bracketIdx,
|
||||
standings,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
bracketIdx: number;
|
||||
standings: Standing[];
|
||||
}): Standing[] {
|
||||
const undergroundPlacements = new Map<number, number>();
|
||||
|
||||
for (const undergroundIdx of Progression.undergroundBracketIdxs(
|
||||
bracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
)) {
|
||||
const underground = tournament.bracketByIdx(undergroundIdx);
|
||||
if (!underground?.everyMatchOver) continue;
|
||||
|
||||
for (const standing of underground.standings) {
|
||||
if (undergroundPlacements.has(standing.team.id)) continue;
|
||||
|
||||
undergroundPlacements.set(standing.team.id, standing.placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (undergroundPlacements.size === 0) return standings;
|
||||
|
||||
const result: Standing[] = [];
|
||||
|
||||
for (const tied of groupedByPlacement(standings)) {
|
||||
const sorted = R.sortBy(
|
||||
tied,
|
||||
(standing) =>
|
||||
undergroundPlacements.get(standing.team.id) ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
let placement = tied[0].placement;
|
||||
let previousUndergroundPlacement: number | null = null;
|
||||
|
||||
for (const [index, standing] of sorted.entries()) {
|
||||
const undergroundPlacement =
|
||||
undergroundPlacements.get(standing.team.id) ?? null;
|
||||
|
||||
if (index > 0 && undergroundPlacement !== previousUndergroundPlacement) {
|
||||
placement = tied[0].placement + index;
|
||||
}
|
||||
previousUndergroundPlacement = undergroundPlacement;
|
||||
|
||||
result.push({ ...standing, placement });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupedByPlacement(standings: Standing[]): Standing[][] {
|
||||
const result: Standing[][] = [];
|
||||
|
||||
for (const standing of standings) {
|
||||
const previous = result.at(-1);
|
||||
|
||||
if (previous && previous[0].placement === standing.placement) {
|
||||
previous.push(standing);
|
||||
} else {
|
||||
result.push([standing]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function standingsToMergeable<
|
||||
T extends { team: { id: number }; placement: number },
|
||||
>({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user