From 154534744d4e1b24b401b39d7b65edbe376705ed Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:03:54 +0300 Subject: [PATCH] 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) --- .../actions/to.$id.brackets.server.ts | 9 ++- .../tournament-bracket/core/toMapList.ts | 42 ++++++++-- app/modules/brackets-manager/create.ts | 11 ++- .../brackets-manager/test/round-robin.test.ts | 76 ++++++++++++++++++- e2e/tournament-bracket.spec.ts | 72 +++++++++++++----- 5 files changed, 178 insertions(+), 32 deletions(-) diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index fe995a270..8d4b32356 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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", ); diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index cc5a64d1c..ab8a32b2e 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -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(); + 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; +} diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts index c599faa21..124a99046 100644 --- a/app/modules/brackets-manager/create.ts +++ b/app/modules/brackets-manager/create.ts @@ -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, ); diff --git a/app/modules/brackets-manager/test/round-robin.test.ts b/app/modules/brackets-manager/test/round-robin.test.ts index d5147395e..981424570 100644 --- a/app/modules/brackets-manager/test/round-robin.test.ts +++ b/app/modules/brackets-manager/test/round-robin.test.ts @@ -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("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("group")!.find((group: any) => { + const matches = storage.select("match", { group_id: group.id })!; + return matches.filter(isRealMatch).length === 1; + })!; + + const rounds = storage.select("round", { + group_id: shortGroup.id, + })!; + const matches = storage.select("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("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) diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 59a357df0..a10ef77a8 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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();