mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-12 22:32:51 -05:00
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:
parent
e33a3c2f97
commit
154534744d
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user