Fix tournament gets blocked in RR with certain parameters

Bracket with 3 rounds but only 1 actual match couldn't be finished
(5 teams, 2 groups)
This commit is contained in:
Kalle 2026-06-27 08:03:54 +03:00
parent e33a3c2f97
commit 154534744d
5 changed files with 178 additions and 32 deletions

View File

@ -91,9 +91,16 @@ export const action: ActionFunction = async ({ params, request }) => {
? abDivisionsForSeeding(seeding, tournament, groupCount)
: undefined;
// in rr/swiss every group shares one map list per round number, and
// groups can have different round counts when teams divide unevenly,
// so compare against the number of distinct round numbers
const distinctRoundNumberCount = new Set(
bracket.data.round.map((round) => round.number),
).size;
errorToastIfFalsy(
bracket.type === "round_robin" || bracket.type === "swiss"
? bracket.data.round.length / groupCount === maps.length
? distinctRoundNumberCount === maps.length
: bracket.data.round.length === maps.length,
"Invalid map count",
);

View File

@ -97,9 +97,29 @@ function getFilteredRounds(
) {
if (type !== "round_robin" && type !== "swiss") return rounds;
// highest group id because lower group id's can have byes that higher don't
const highestGroupId = Math.max(...rounds.map((x) => x.group_id));
return rounds.filter((x) => x.group_id === highestGroupId);
// Groups can have different round counts when teams don't divide evenly
// (e.g. groups of 3 and 2). Use the group with the most rounds: it covers
// every round number and its map list is shared with the smaller groups.
const fullestGroupId = fullestGroupIdByRounds(rounds);
return rounds.filter((x) => x.group_id === fullestGroupId);
}
function fullestGroupIdByRounds(rounds: Round[]) {
const roundCountByGroup = new Map<number, number>();
for (const round of rounds) {
roundCountByGroup.set(
round.group_id,
(roundCountByGroup.get(round.group_id) ?? 0) + 1,
);
}
let fullestGroupId = rounds[0].group_id;
for (const [groupId, count] of roundCountByGroup) {
if (count > roundCountByGroup.get(fullestGroupId)!)
fullestGroupId = groupId;
}
return fullestGroupId;
}
function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) {
@ -135,11 +155,11 @@ function resolveRoundMapCount(
counts: BracketMapCounts,
type: Tables["TournamentStage"]["type"],
) {
// with rr/swiss we just take the first group id
// as every group has the same map list
// with rr/swiss every group shares the same map list, so use the group with
// the most rounds since it is the one that covers every round number
const groupId =
type === "round_robin" || type === "swiss"
? Math.max(...Array.from(counts.keys()))
? fullestGroupIdByCounts(counts)
: round.group_id;
const count = counts.get(groupId)?.get(round.number)?.count;
@ -152,3 +172,13 @@ function resolveRoundMapCount(
return count;
}
function fullestGroupIdByCounts(counts: BracketMapCounts) {
let fullestGroupId = counts.keys().next().value as number;
for (const [groupId, roundCounts] of counts) {
if (roundCounts.size > counts.get(fullestGroupId)!.size)
fullestGroupId = groupId;
}
return fullestGroupId;
}

View File

@ -252,8 +252,17 @@ class Create {
if (groupId === -1) throw Error("Could not insert the group.");
// Groups can be padded with empty slots when teams don't divide evenly
// (`null`) or by the seed ordering (`undefined`). An empty slot is just an
// absent team, so drop it to round-robin only the present teams — otherwise
// the padding becomes BYE rounds that strand real matches in later rounds.
// TBD slots (`{ id: null }`) are kept; only nullish placeholders are removed.
const presentSlots = slots.filter(
(slot) => slot !== null && slot !== undefined,
);
const rounds = helpers.makeRoundRobinMatches(
slots,
presentSlots,
this.stage.settings?.roundRobinMode,
);

View File

@ -93,7 +93,7 @@ describe("Create a round-robin stage", () => {
).toThrow("Not enough seeds in at least one group of the manual ordering.");
});
test("should create a round-robin stage without BYE vs. BYE matches", () => {
test("should drop empty slots instead of creating BYE matches", () => {
const example = {
name: "Example",
tournamentId: 0,
@ -104,8 +104,50 @@ describe("Create a round-robin stage", () => {
manager.create(example);
// One match must be missing.
expect(storage.select("match")!.length).toBe(11);
// 5 teams in 2 groups -> groups of 3 and 2 with no BYE matches at all.
const matches = storage.select<any>("match")!;
expect(matches.length).toBe(4);
for (const match of matches) {
expect(match.opponent1?.id).not.toBeNull();
expect(match.opponent2?.id).not.toBeNull();
}
});
test("should not pad a short group with empty rounds when teams divide unevenly", () => {
// 5 teams in 2 groups -> groups of 3 and 2. The 2-team group must be a
// clean single-round single-match group, not padded with BYE-only rounds
// that strand the real match in a later round.
manager.create({
name: "Uneven groups",
tournamentId: 0,
type: "round_robin",
seeding: [1, 2, 3, 4, 5],
settings: { groupCount: 2, seedOrdering: ["groups.seed_optimized"] },
} as any);
const isRealMatch = (match: any) =>
match.opponent1?.id != null && match.opponent2?.id != null;
const shortGroup = storage.select<any>("group")!.find((group: any) => {
const matches = storage.select<any>("match", { group_id: group.id })!;
return matches.filter(isRealMatch).length === 1;
})!;
const rounds = storage.select<any>("round", {
group_id: shortGroup.id,
})!;
const matches = storage.select<any>("match", {
group_id: shortGroup.id,
})!;
const realMatch = matches.find(isRealMatch)!;
const realMatchRound = rounds.find(
(round: any) => round.id === realMatch.round_id,
)!;
// No BYE matches and no empty rounds, just the single match in round 1.
expect(matches.length).toBe(1);
expect(rounds.length).toBe(1);
expect(realMatchRound.number).toBe(1);
});
test("should create a round-robin stage with to be determined participants", () => {
@ -394,6 +436,34 @@ describe("Update scores in a round-robin stage", () => {
).not.toThrow();
});
test("should let the only real match be played in a group with fewer teams than slots", () => {
storage.reset();
// Group sized for 3 but only 2 teams placed (the 3rd slot is a BYE).
// The two real teams only meet in round 3, preceded by two BYE rounds
// that can never be reported. The real match must still be playable.
manager.create({
name: "Two teams in a three-slot group",
tournamentId: 0,
type: "round_robin",
seeding: [1, 2, null],
settings: { groupCount: 1 },
} as any);
const realMatch = storage
.select<any>("match")!
.find((m: any) => m.opponent1?.id && m.opponent2?.id)!;
expect(realMatch.status).toBe(2); // Ready
expect(() =>
manager.update.match({
id: realMatch.id,
opponent1: { score: 16, result: "win" },
opponent2: { score: 9 },
}),
).not.toThrow();
});
test("should unlock next round matches with BYE participants", () => {
storage.reset();
// Create a round robin with 3 teams (odd number creates rounds where one team doesn't play)

View File

@ -280,7 +280,7 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
for (const id of [2, 4, 6, 7, 8, 9, 10, 11, 12]) {
for (const id of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
await navigateToMatch(page, id);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
@ -319,7 +319,7 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
await navigateToMatch(page, 13);
await navigateToMatch(page, 10);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 3 });
@ -329,7 +329,7 @@ test.describe("Tournament bracket", () => {
});
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
for (const matchId of [14, 15, 16, 17]) {
for (const matchId of [11, 12, 13, 14]) {
await navigateToMatch(page, matchId);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 3 });
@ -342,12 +342,42 @@ test.describe("Tournament bracket", () => {
// after finalizing the tournament, the admin tab disappears so the
// reopen action is no longer reachable
await navigateToMatch(page, 14);
await navigateToMatch(page, 11);
await isNotVisible(page.getByRole("tab", { name: "Admin" }));
await isNotVisible(page.getByTestId("reopen-match-button"));
await backToBracket(page);
});
test("starts a round robin bracket with unevenly sized groups (5 teams)", async ({
page,
}) => {
const tournamentId = 3;
await seed(page);
await impersonate(page);
await navigate({
page,
url: tournamentAdminPage(tournamentId),
});
// leave 5 checked in teams -> groups of 3 and 2 with different round counts
await checkOutTeamRows(page, 6, 15);
await navigate({
page,
url: tournamentBracketsPage({
tournamentId,
}),
});
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
// bracket starts without an "Invalid map count" error -> matches are rendered
await expect(page.locator("[data-match-id]").first()).toBeVisible();
});
test("shows tournament results on user profile after finalized tournament", async ({
page,
}) => {
@ -570,7 +600,7 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
await navigateToMatch(page, 2);
await navigateToMatch(page, 1);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
@ -635,30 +665,30 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-bracket-button").click();
await submit(page, "confirm-finalize-bracket-button");
// needs also to be completed so 9 unlocks
await navigateToMatch(page, 7);
// needs also to be completed so 6 unlocks
await navigateToMatch(page, 4);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
// set situation where match A is completed and its participants also completed their follow up matches B & C
// and then we go back and change the winner of A
await navigateToMatch(page, 8);
await navigateToMatch(page, 5);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
await navigateToMatch(page, 9);
await navigateToMatch(page, 6);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
await navigateToMatch(page, 10);
await navigateToMatch(page, 7);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
await navigateToMatch(page, 8);
await navigateToMatch(page, 5);
await goToTab(page, "admin");
await submit(page, "reopen-match-button");
await goToTab(page, "action");
@ -687,36 +717,36 @@ test.describe("Tournament bracket", () => {
await submit(page, "confirm-finalize-bracket-button");
// Use Group B which has 4 teams and 2 matches per round
// Group B Round 1: Match 7 (Whatcha Say vs Come Together), Match 8 (We Are Champions vs Please Mr Postman)
// Group B Round 2: Match 9 (Please Mr Postman vs Come Together), Match 10 (Whatcha Say vs We Are Champions)
// Group B Round 1: Match 4, Match 5
// Group B Round 2: Match 6, Match 7
// Complete R1 matches in group B (matches 7 and 8) to unlock R2 matches
await navigateToMatch(page, 7);
// Complete R1 matches in group B (matches 4 and 5) to unlock R2 matches
await navigateToMatch(page, 4);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
await navigateToMatch(page, 8);
await navigateToMatch(page, 5);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 2 });
await backToBracket(page);
// Match 9 is R2 in group B - should now be unlocked since R1 is complete
// Match 6 is R2 in group B - should now be unlocked since R1 is complete
// Start it but don't complete it
await navigateToMatch(page, 9);
await navigateToMatch(page, 6);
await goToTab(page, "action");
await reportResult(page, { mapsToReport: 1, setEnds: false });
await backToBracket(page);
// Reopen match 7 (R1 match) - simulating a score misreport correction
await navigateToMatch(page, 7);
// Reopen match 4 (R1 match) - simulating a score misreport correction
await navigateToMatch(page, 4);
await goToTab(page, "admin");
await submit(page, "reopen-match-button");
await backToBracket(page);
// Verify the R2 match that was already in progress is still playable
// Before the fix, this would become locked and unplayable
await navigateToMatch(page, 9);
await navigateToMatch(page, 6);
await expectScore(page, [1, 0]);
await goToTab(page, "action");
await expect(page.getByTestId("winner-radio-1")).toBeVisible();