Round robin only tournament support (#2996)

This commit is contained in:
Kalle
2026-04-24 18:03:53 +03:00
committed by GitHub
parent 002a03e86d
commit d2440c4a32
8 changed files with 506 additions and 99 deletions

View File

@@ -1,5 +1,6 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
@@ -278,22 +279,8 @@ export class RoundRobinBracket extends Bracket {
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return this.standingsWithoutNonParticipants(
sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
stats: team.stats,
};
}),
Standings.reNumberPlacements(sorted),
);
}

View File

@@ -1,5 +1,6 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import invariant from "~/utils/invariant";
@@ -443,22 +444,8 @@ export class SwissBracket extends Bracket {
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return this.standingsWithoutNonParticipants(
sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
stats: team.stats,
};
}),
Standings.reNumberPlacements(sorted),
);
}

View File

@@ -274,22 +274,36 @@ const getValidatedBrackets = (
);
describe("validatedSources - other rules", () => {
it("handles NOT_RESOLVING_WINNER (only round robin)", () => {
const error = getValidatedBrackets([
it("accepts a single round robin with no follow-ups", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
]) as Progression.ValidationError;
]);
expect(error.type).toBe("NOT_RESOLVING_WINNER");
expect(Array.isArray(result)).toBe(true);
});
it("handles NOT_RESOLVING_WINNER (ends in round robin)", () => {
const error = getValidatedBrackets([
it("accepts a single A/B round robin with no follow-ups", () => {
const result = getValidatedBrackets([
{
settings: {
hasAbDivisions: true,
teamsPerGroup: 6,
},
type: "round_robin",
},
]);
expect(Array.isArray(result)).toBe(true);
});
it("accepts a swiss to round robin progression", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "single_elimination",
type: "swiss",
},
{
settings: {},
@@ -301,9 +315,30 @@ describe("validatedSources - other rules", () => {
},
],
},
]) as Progression.ValidationError;
]);
expect(error.type).toBe("NOT_RESOLVING_WINNER");
expect(Array.isArray(result)).toBe(true);
});
it("accepts a round robin to round robin progression", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "round_robin",
sources: [
{
bracketId: "0",
placements: "1,2",
},
],
},
]);
expect(Array.isArray(result)).toBe(true);
});
it("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => {

View File

@@ -405,7 +405,6 @@ function resolvesWinner(brackets: ParsedBracket[]) {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
if (finals?.type === "round_robin") return false;
if (
finals.type === "swiss" &&
(finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1
@@ -668,6 +667,16 @@ export function isFinals(idx: number, brackets: ParsedBracket[]) {
return resolveMainBracketProgression(brackets).at(-1) === idx;
}
/** Returns true if the finals bracket of the tournament is an A/B divisions round robin. */
export function hasAbDivisionsFinals(brackets: ParsedBracket[]): boolean {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
return (
finals.type === "round_robin" && finals.settings?.hasAbDivisions === true
);
}
/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket".
* Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers
*/

View File

@@ -18,6 +18,7 @@ import {
} from "../tournament-bracket-utils";
import type { Standing } from "./Bracket";
import type { ParsedBracket } from "./Progression";
import * as Progression from "./Progression";
export interface TournamentSummary {
skills: Omit<
@@ -41,6 +42,7 @@ type TeamsArg = Array<{
id: number;
members: Array<{ userId: number }>;
startingBracketIdx?: number | null;
abDivision?: number | null;
}>;
type Rating = Pick<Tables["Skill"], "mu" | "sigma">;
@@ -574,25 +576,31 @@ function tournamentResults({
}) {
const result: TournamentSummary["tournamentResults"] = [];
const firstPlaceFinishesCount = finalStandings.filter(
(s) => s.placement === 1,
).length;
const isMultiStartingBracket = firstPlaceFinishesCount > 1;
const isMultiStartingBracket =
Progression.startingBrackets(progression).length > 1;
const isAbDivisionsFinals = Progression.hasAbDivisionsFinals(progression);
for (const standing of finalStandings) {
const team = teams.find((t) => t.id === standing.team.id);
invariant(team);
const div =
// second check should be redundant, but just here in case
typeof team.startingBracketIdx === "number" && isMultiStartingBracket
? getBracketProgressionLabel(team.startingBracketIdx, progression)
: null;
const divisionParticipantCount =
div !== null
? teams.filter((t) => t.startingBracketIdx === team.startingBracketIdx)
.length
: participantCount;
let div: string | null = null;
let divisionParticipantCount = participantCount;
if (isAbDivisionsFinals && typeof team.abDivision === "number") {
div = team.abDivision === 0 ? "A" : "B";
divisionParticipantCount = teams.filter(
(t) => t.abDivision === team.abDivision,
).length;
} else if (
isMultiStartingBracket &&
typeof team.startingBracketIdx === "number"
) {
div = getBracketProgressionLabel(team.startingBracketIdx, progression);
divisionParticipantCount = teams.filter(
(t) => t.startingBracketIdx === team.startingBracketIdx,
).length;
}
for (const player of standing.team.members) {
result.push({

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import invariant from "~/utils/invariant";
import type { Tables } from "../../../db/tables";
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
import type { ParsedBracket } from "./Progression";
import { tournamentSummary } from "./summarizer.server";
import type { TournamentDataTeam } from "./Tournament.server";
@@ -65,6 +66,7 @@ describe("tournamentSummary()", () => {
seedingSkillCountsFor,
withMemberInTwoTeams = false,
teamsWithStartingBrackets,
teamsWithAbDivisions,
progression,
finalStandings,
}: {
@@ -75,13 +77,11 @@ describe("tournamentSummary()", () => {
id: number;
startingBracketIdx: number | null;
}>;
progression?: Array<{
name: string;
type: "single_elimination";
settings: Record<string, never>;
requiresCheckIn: boolean;
sources?: Array<{ bracketIdx: number; placements: number[] }>;
teamsWithAbDivisions?: Array<{
id: number;
abDivision: 0 | 1;
}>;
progression?: ParsedBracket[];
finalStandings?: Array<{
placement: number;
team: TournamentDataTeam;
@@ -122,17 +122,19 @@ describe("tournamentSummary()", () => {
},
];
const teams = teamsWithStartingBrackets
? defaultTeams.map((team) => {
const startingBracket = teamsWithStartingBrackets.find(
(t) => t.id === team.id,
);
return {
...team,
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
};
})
: defaultTeams;
const teams = defaultTeams.map((team) => {
const startingBracket = teamsWithStartingBrackets?.find(
(t) => t.id === team.id,
);
const abDivisionEntry = teamsWithAbDivisions?.find(
(t) => t.id === team.id,
);
return {
...team,
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
abDivision: abDivisionEntry?.abDivision ?? null,
};
});
return tournamentSummary({
finalStandings: finalStandings ?? [
@@ -779,6 +781,102 @@ describe("tournamentSummary()", () => {
expect(team4Results.every((r) => r.participantCount === 2)).toBeTruthy();
});
test("div is set from abDivision when finals is an A/B divisions round robin", () => {
const summary = summarize({
teamsWithAbDivisions: [
{ id: 1, abDivision: 0 },
{ id: 2, abDivision: 1 },
{ id: 3, abDivision: 0 },
{ id: 4, abDivision: 1 },
],
progression: [
{
name: "Groups stage",
type: "round_robin",
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
requiresCheckIn: false,
},
],
finalStandings: [
{
placement: 1,
team: createTeam(1, [1, 2, 3, 4]),
},
{
placement: 1,
team: createTeam(2, [5, 6, 7, 8]),
},
{
placement: 2,
team: createTeam(3, [9, 10, 11, 12]),
},
{
placement: 2,
team: createTeam(4, [13, 14, 15, 16]),
},
],
});
const team1Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 1,
);
const team2Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 2,
);
const team3Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 3,
);
const team4Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 4,
);
expect(team1Results.every((r) => r.div === "A")).toBeTruthy();
expect(team2Results.every((r) => r.div === "B")).toBeTruthy();
expect(team3Results.every((r) => r.div === "A")).toBeTruthy();
expect(team4Results.every((r) => r.div === "B")).toBeTruthy();
});
test("participantCount counts teams per abDivision for A/B finals", () => {
const summary = summarize({
teamsWithAbDivisions: [
{ id: 1, abDivision: 0 },
{ id: 2, abDivision: 1 },
{ id: 3, abDivision: 0 },
{ id: 4, abDivision: 1 },
],
progression: [
{
name: "Groups stage",
type: "round_robin",
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
requiresCheckIn: false,
},
],
finalStandings: [
{
placement: 1,
team: createTeam(1, [1, 2, 3, 4]),
},
{
placement: 1,
team: createTeam(2, [5, 6, 7, 8]),
},
{
placement: 2,
team: createTeam(3, [9, 10, 11, 12]),
},
{
placement: 2,
team: createTeam(4, [13, 14, 15, 16]),
},
],
});
for (const result of summary.tournamentResults) {
expect(result.participantCount).toBe(2);
}
});
test("excludes matches ended early by organizer from calculations", () => {
const summary = summarize({
results: [

View File

@@ -0,0 +1,253 @@
import { describe, expect, it } from "vitest";
import {
progressions,
testTournament,
tournamentCtxTeam,
} from "~/features/tournament-bracket/core/tests/test-utils";
import { BracketsManager } from "~/modules/brackets-manager";
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
import invariant from "~/utils/invariant";
import { reNumberPlacements, tournamentStandings } from "./Standings";
describe("tournamentStandings", () => {
it("returns single-division standings for a tournament with one starting bracket", () => {
const tournament = singleEliminationTournament();
const result = tournamentStandings(tournament);
expect(result.type).toBe("single");
invariant(result.type === "single");
expect(result.standings.length).toBeGreaterThan(0);
expect(result.standings[0].placement).toBe(1);
expect(result.standings[0].team.id).toBe(1);
});
it("returns one div per starting bracket for a tournament with multiple starting brackets", () => {
const tournament = testTournament({
ctx: {
settings: { bracketProgression: progressions.manyStartBrackets },
teams: [
tournamentCtxTeam(1, { startingBracketIdx: 0, seed: 1 }),
tournamentCtxTeam(2, { startingBracketIdx: 0, seed: 2 }),
tournamentCtxTeam(3, { startingBracketIdx: 1, seed: 3 }),
tournamentCtxTeam(4, { startingBracketIdx: 1, seed: 4 }),
],
},
});
const result = tournamentStandings(tournament);
expect(result.type).toBe("multi");
invariant(result.type === "multi");
expect(result.standings).toHaveLength(2);
for (const { div } of result.standings) {
expect(typeof div).toBe("string");
expect(div.length).toBeGreaterThan(0);
}
const divs = result.standings.map((s) => s.div);
expect(new Set(divs).size).toBe(2);
});
it("splits A/B divisions finals into 'A' and 'B' divs with teams partitioned by abDivision", () => {
const tournament = abDivisionsTournament();
const result = tournamentStandings(tournament);
expect(result.type).toBe("multi");
invariant(result.type === "multi");
expect(result.standings.map((s) => s.div)).toEqual(["A", "B"]);
const [a, b] = result.standings;
expect(a.standings.map((s) => s.team.id)).toEqual([1, 3]);
expect(b.standings.map((s) => s.team.id)).toEqual([2, 4]);
expect(a.standings.every((s) => s.team.abDivision === 0)).toBe(true);
expect(b.standings.every((s) => s.team.abDivision === 1)).toBe(true);
});
it("re-numbers placements within each A/B division starting from 1", () => {
const tournament = abDivisionsTournament();
const result = tournamentStandings(tournament);
invariant(result.type === "multi");
const [a, b] = result.standings;
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
});
});
describe("reNumberPlacements", () => {
it("keeps already contiguous placements unchanged", () => {
const result = reNumberPlacements([
{ placement: 1 },
{ placement: 2 },
{ placement: 3 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 2, 3]);
});
it("groups tied placements and skips numbers to match team count", () => {
const result = reNumberPlacements([
{ placement: 1 },
{ placement: 1 },
{ placement: 3 },
{ placement: 3 },
{ placement: 5 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 3, 5]);
});
it("re-numbers from 1 when the input has been filtered (e.g. top finishers removed)", () => {
const result = reNumberPlacements([
{ placement: 3 },
{ placement: 3 },
{ placement: 5 },
{ placement: 7 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 4]);
});
it("adds the offset to every placement", () => {
const result = reNumberPlacements(
[{ placement: 1 }, { placement: 1 }, { placement: 3 }],
10,
);
expect(result.map((s) => s.placement)).toEqual([11, 11, 13]);
});
it("preserves non-placement fields on each standing", () => {
const result = reNumberPlacements([
{ placement: 1, team: { id: 7 }, note: "a" },
{ placement: 2, team: { id: 8 }, note: "b" },
]);
expect(result).toEqual([
{ placement: 1, team: { id: 7 }, note: "a" },
{ placement: 2, team: { id: 8 }, note: "b" },
]);
});
it("returns an empty array when given an empty array", () => {
expect(reNumberPlacements([])).toEqual([]);
expect(reNumberPlacements([], 5)).toEqual([]);
});
});
function singleEliminationTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "Main Bracket",
tournamentId: 1,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: { seedOrdering: ["natural"] },
});
while (true) {
const pending = storage
.select<any>("match")!
.find(
(m) =>
typeof m.opponent1?.id === "number" &&
typeof m.opponent2?.id === "number" &&
m.opponent1.result !== "win" &&
m.opponent2.result !== "win",
);
if (!pending) break;
const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id;
manager.update.match({
id: pending.id,
opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 },
opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" },
});
}
return testTournament({
ctx: {
settings: {
bracketProgression: progressions.singleElimination,
},
teams: [
tournamentCtxTeam(1, { seed: 1 }),
tournamentCtxTeam(2, { seed: 2 }),
tournamentCtxTeam(3, { seed: 3 }),
tournamentCtxTeam(4, { seed: 4 }),
],
},
data: manager.get.tournamentData(1),
});
}
function abDivisionsTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "AB RR",
tournamentId: 1,
type: "round_robin",
seeding: [1, 2, 3, 4],
abDivisions: [0, 1, 0, 1],
settings: {
groupCount: 1,
hasAbDivisions: true,
seedOrdering: ["groups.seed_optimized"],
},
});
const winnerByMatchup: Record<string, number> = {
"1-2": 1,
"1-4": 1,
"2-3": 2,
"3-4": 3,
};
for (const match of storage.select<any>("match")!) {
const a = match.opponent1.id as number;
const b = match.opponent2.id as number;
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
const winnerId = winnerByMatchup[key];
invariant(winnerId, `unexpected matchup ${key}`);
const loserScore = key === "2-3" || key === "3-4" ? 1 : 0;
const winnerIsOpp1 = match.opponent1.id === winnerId;
manager.update.match({
id: match.id,
opponent1: winnerIsOpp1
? { score: 2, result: "win" }
: { score: loserScore },
opponent2: winnerIsOpp1
? { score: loserScore }
: { score: 2, result: "win" },
});
}
const data = manager.get.tournamentData(1);
return testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "round_robin",
name: "AB RR",
requiresCheckIn: false,
settings: { hasAbDivisions: true },
},
],
},
teams: [
tournamentCtxTeam(1, { abDivision: 0, seed: 1 }),
tournamentCtxTeam(2, { abDivision: 1, seed: 2 }),
tournamentCtxTeam(3, { abDivision: 0, seed: 3 }),
tournamentCtxTeam(4, { abDivision: 1, seed: 4 }),
],
},
data,
});
}

View File

@@ -24,6 +24,34 @@ export function flattenStandings(
: standingsResult.standings.flatMap((div) => div.standings);
}
/**
* Re-numbers placements in a sorted standings array so that tied placements stay
* grouped (e.g. `[1, 1, 3, 3, 5]`) while non-tied positions reflect the true
* number of teams above them. Useful after filtering or merging standings where
* the original placement numbers no longer match the team count.
*
* Pass `offset` to shift every placement downwards — used when the returned
* standings will be appended below standings from another bracket.
*/
export function reNumberPlacements<T extends { placement: number }>(
standings: T[],
offset = 0,
): T[] {
let lastOriginalPlacement = 0;
let currentPlacement = 0;
return standings.map((standing, index) => {
if (standing.placement !== lastOriginalPlacement) {
lastOriginalPlacement = standing.placement;
currentPlacement = index + 1;
}
return {
...standing,
placement: currentPlacement + offset,
};
});
}
/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */
export function calculateSPR({
standings,
@@ -143,24 +171,42 @@ export function matchesPlayed({
export function tournamentStandings(
tournament: Tournament,
): TournamentStandingsResult {
const startingBracketIdxs = Progression.startingBrackets(
tournament.ctx.settings.bracketProgression,
);
const progression = tournament.ctx.settings.bracketProgression;
const startingBracketIdxs = Progression.startingBrackets(progression);
if (startingBracketIdxs.length <= 1) {
const standings = tournamentStandingsForBracket(tournament, undefined);
if (Progression.hasAbDivisionsFinals(progression)) {
return {
type: "multi",
standings: [
{
div: "A",
standings: reNumberPlacements(
standings.filter((s) => s.team.abDivision === 0),
),
},
{
div: "B",
standings: reNumberPlacements(
standings.filter((s) => s.team.abDivision === 1),
),
},
],
};
}
return {
type: "single",
standings: tournamentStandingsForBracket(tournament, undefined),
standings,
};
}
return {
type: "multi",
standings: startingBracketIdxs.map((bracketIdx) => ({
div: getBracketProgressionLabel(
bracketIdx,
tournament.ctx.settings.bracketProgression,
),
div: getBracketProgressionLabel(bracketIdx, progression),
standings: tournamentStandingsForBracket(tournament, bracketIdx),
})),
};
@@ -241,8 +287,6 @@ function standingsToMergeable<
standings: T[];
teamsAboveFromAnotherBracketsCount: number;
}) {
const result: T[] = [];
const filtered = standings.filter(
(standing) => !alreadyIncludedTeamIds.has(standing.team.id),
);
@@ -250,22 +294,8 @@ function standingsToMergeable<
// e.g. if standings start at 3rd place, this must mean there is 2 teams left to finish _this_ bracket
const unfinishedTeamsCount = (standings.at(0)?.placement ?? 1) - 1;
let placement = 1;
for (const [i, standing] of filtered.entries()) {
const placementChanged =
i !== 0 && standing.placement !== filtered[i - 1].placement;
if (placementChanged) {
placement = i + 1;
}
result.push({
...standing,
placement:
placement + teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
});
}
return result;
return reNumberPlacements(
filtered,
teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
);
}