Skip to content

Commit

Permalink
Adjust Round Robin & Swiss standings logic (#2036)
Browse files Browse the repository at this point in the history
* Test fails

* Add & fix tests
  • Loading branch information
Sendouc authored Jan 18, 2025
1 parent 86b50ce commit 85d1f3f
Show file tree
Hide file tree
Showing 4 changed files with 464 additions and 100 deletions.
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

0 comments on commit 85d1f3f

Please sign in to comment.