Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjust Round Robin & Swiss standings logic #2036

Merged
merged 2 commits into from
Jan 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,26 @@ export function PlacementsTable({
<abbr title="Losses against tied opponents">TB</abbr>
</th>
) : null}
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Score summed up">Scr</abbr>
</th>
) : null}
{bracket.type === "swiss" ? (
<>
<th>
<abbr title="Buchholz (summed set wins of opponents)">
Buch.
</abbr>
<abbr title="Opponents' set win percentage average">OW%</abbr>
</th>
<th>
<abbr title="Buchholz (summed map wins of opponents)">
Buch. (M)
<abbr title="Opponents' map win percentage average">
OW% (M)
</abbr>
</th>
</>
) : null}
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Score summed up">Scr</abbr>
</th>
) : null}
<th>Seed</th>
<th />
{canEditDestination ? <th /> : null}
Expand Down Expand Up @@ -199,6 +197,16 @@ export function PlacementsTable({
<span>{(stats.lossesAgainstTied ?? 0) * -1}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<>
<td>
<span>{stats.opponentSetWinPercentage?.toFixed(2)}</span>
</td>
<td>
<span>{stats.opponentMapWinPercentage?.toFixed(2)}</span>
</td>
</>
) : null}
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
Expand All @@ -209,16 +217,6 @@ export function PlacementsTable({
<span>{stats.points}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<>
<td>
<span>{stats.buchholzSets}</span>
</td>
<td>
<span>{stats.buchholzMaps}</span>
</td>
</>
) : null}
<td>{team?.seed}</td>
<EditableDestination
key={overridenDestinationBracket?.idx}
Expand Down
68 changes: 68 additions & 0 deletions app/features/tournament-bracket/core/Bracket.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { removeDuplicates } from "../../../utils/arrays";
import invariant from "../../../utils/invariant";
import { Tournament } from "./Tournament";
import { PADDLING_POOL_255 } from "./tests/mocks";
import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li";

const TEAM_ERROR_404_ID = 17354;
Expand Down Expand Up @@ -38,3 +40,69 @@ describe("swiss standings", () => {
expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round
});
});

describe("round robin standings", () => {
it("should sort teams primarily by set wins (per group) in paddling pool 255", () => {
const tournamentPP255 = new Tournament(PADDLING_POOL_255());

const standings = tournamentPP255.bracketByIdx(0)!.standings;

const groupIds = removeDuplicates(
standings.map((standing) => standing.groupId),
);
expect(
groupIds.length,
"Paddling Pool 255 should have groups from Group A to Group I",
).toBe(9);

for (const groupId of groupIds) {
const groupStandings = standings.filter(
(standing) => standing.groupId === groupId,
);

for (let i = 0; i < groupStandings.length; i++) {
const current = groupStandings[i];
const next = groupStandings[i + 1];

if (!next) {
break;
}

expect(
current.stats!.setWins,
`Team with ID ${current.team.id} in wrong spot relative to ${next.team.id}`,
).toBeGreaterThanOrEqual(next.stats!.setWins);
}
}
});

it("has ascending order from lower group id to higher group id for same placements", () => {
const tournamentPP255 = new Tournament(PADDLING_POOL_255());

const standings = tournamentPP255.bracketByIdx(0)!.standings;

const placements = removeDuplicates(
standings.map((standing) => standing.placement),
).sort((a, b) => a - b);

for (const placement of placements) {
const placementStandings = standings.filter(
(standing) => standing.placement === placement,
);

for (let i = 0; i < placementStandings.length; i++) {
const current = placementStandings[i];
const next = placementStandings[i + 1];

if (!next) {
break;
}

expect(
current.groupId,
`Team with ID ${current.team.id} in wrong spot relative to ${next.team.id}`,
).toBeLessThan(next.groupId!);
}
}
});
});
104 changes: 77 additions & 27 deletions app/features/tournament-bracket/core/Bracket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { removeDuplicates } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import { cutToNDecimalPlaces } from "../../../utils/number";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
import * as Progression from "./Progression";
import type { OptionalIdObject, Tournament } from "./Tournament";
Expand Down Expand Up @@ -50,11 +51,16 @@ export interface Standing {
winsAgainstTied: number;
// first tiebreaker in swiss
lossesAgainstTied?: number;
buchholzSets?: number;
buchholzMaps?: number;
opponentSetWinPercentage?: number;
opponentMapWinPercentage?: number;
};
}

interface TeamTrackRecord {
wins: number;
losses: number;
}

export abstract class Bracket {
id;
idx;
Expand Down Expand Up @@ -1059,6 +1065,9 @@ class RoundRobinBracket extends Bracket {
if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;

if (a.mapLosses < b.mapLosses) return -1;
if (a.mapLosses > b.mapLosses) return 1;

if (a.points > b.points) return -1;
if (a.points < b.points) return 1;

Expand Down Expand Up @@ -1223,8 +1232,8 @@ class SwissBracket extends Bracket {
mapLosses: number;
winsAgainstTied: number;
lossesAgainstTied: number;
buchholzSets: number;
buchholzMaps: number;
opponentSets: TeamTrackRecord;
opponentMaps: TeamTrackRecord;
}[] = [];

const updateTeam = ({
Expand All @@ -1233,25 +1242,28 @@ class SwissBracket extends Bracket {
setLosses = 0,
mapWins = 0,
mapLosses = 0,
buchholzSets = 0,
buchholzMaps = 0,
opponentSets = { wins: 0, losses: 0 },
opponentMaps = { wins: 0, losses: 0 },
}: {
teamId: number;
setWins?: number;
setLosses?: number;
mapWins?: number;
mapLosses?: number;
buchholzSets?: number;
buchholzMaps?: number;
opponentSets?: TeamTrackRecord;
opponentMaps?: TeamTrackRecord;
}) => {
const team = teams.find((team) => team.id === teamId);
if (team) {
team.setWins += setWins;
team.setLosses += setLosses;
team.mapWins += mapWins;
team.mapLosses += mapLosses;
team.buchholzSets += buchholzSets;
team.buchholzMaps += buchholzMaps;

team.opponentSets.wins += opponentSets.wins;
team.opponentSets.losses += opponentSets.losses;
team.opponentMaps.wins += opponentMaps.wins;
team.opponentMaps.losses += opponentMaps.losses;
} else {
teams.push({
id: teamId,
Expand All @@ -1261,8 +1273,8 @@ class SwissBracket extends Bracket {
mapLosses,
winsAgainstTied: 0,
lossesAgainstTied: 0,
buchholzMaps,
buchholzSets,
opponentMaps,
opponentSets,
});
}
};
Expand Down Expand Up @@ -1357,12 +1369,18 @@ class SwissBracket extends Bracket {
});
}

// buchholz
// opponent win %
for (const team of teams) {
const teamsWhoPlayedAgainst = matchUps.get(team.id) ?? [];

let buchholzSets = 0;
let buchholzMaps = 0;
const opponentSets = {
wins: 0,
losses: 0,
};
const opponentMaps = {
wins: 0,
losses: 0,
};

for (const teamId of teamsWhoPlayedAgainst) {
const opponent = teams.find((t) => t.id === teamId);
Expand All @@ -1373,14 +1391,17 @@ class SwissBracket extends Bracket {
continue;
}

buchholzSets += opponent.setWins;
buchholzMaps += opponent.mapWins;
opponentSets.wins += opponent.setWins;
opponentSets.losses += opponent.setLosses;

opponentMaps.wins += opponent.mapWins;
opponentMaps.losses += opponent.mapLosses;
}

updateTeam({
teamId: team.id,
buchholzSets,
buchholzMaps,
opponentSets,
opponentMaps,
});
}

Expand Down Expand Up @@ -1443,14 +1464,32 @@ class SwissBracket extends Bracket {
if (a.lossesAgainstTied > b.lossesAgainstTied) return 1;
if (a.lossesAgainstTied < b.lossesAgainstTied) return -1;

if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;
const aOpponentSetWinPercentage = this.trackRecordToWinPercentage(
a.opponentSets,
);
const bOpponentSetWinPercentage = this.trackRecordToWinPercentage(
b.opponentSets,
);

if (a.buchholzSets > b.buchholzSets) return -1;
if (a.buchholzSets < b.buchholzSets) return 1;
if (aOpponentSetWinPercentage > bOpponentSetWinPercentage) {
return -1;
}
if (aOpponentSetWinPercentage < bOpponentSetWinPercentage) return 1;

const aOpponentMapWinPercentage = this.trackRecordToWinPercentage(
a.opponentMaps,
);
const bOpponentMapWinPercentage = this.trackRecordToWinPercentage(
b.opponentMaps,
);

if (a.buchholzMaps > b.buchholzMaps) return -1;
if (a.buchholzMaps < b.buchholzMaps) return 1;
if (aOpponentMapWinPercentage > bOpponentMapWinPercentage) {
return -1;
}
if (aOpponentMapWinPercentage < bOpponentMapWinPercentage) return 1;

if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;

const aSeed = Number(this.tournament.teamById(a.id)?.seed);
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
Expand All @@ -1472,8 +1511,12 @@ class SwissBracket extends Bracket {
mapLosses: team.mapLosses,
winsAgainstTied: team.winsAgainstTied,
lossesAgainstTied: team.lossesAgainstTied,
buchholzSets: team.buchholzSets,
buchholzMaps: team.buchholzMaps,
opponentSetWinPercentage: this.trackRecordToWinPercentage(
team.opponentSets,
),
opponentMapWinPercentage: this.trackRecordToWinPercentage(
team.opponentMaps,
),
points: 0,
},
};
Expand Down Expand Up @@ -1510,6 +1553,13 @@ class SwissBracket extends Bracket {
);
}

private trackRecordToWinPercentage(trackRecord: TeamTrackRecord) {
return cutToNDecimalPlaces(
(trackRecord.wins / (trackRecord.wins + trackRecord.losses)) * 100,
2,
);
}

get type(): Tables["TournamentStage"]["type"] {
return "swiss";
}
Expand Down
Loading
Loading