Optimize standings calculation

This commit is contained in:
Kalle
2026-08-29 17:15:02 +03:00
parent d7adbea25b
commit 955caa95f4
4 changed files with 177 additions and 87 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import * as Engine from "~/features/tournament-bracket/core/engine";
import { createResolved } from "~/features/tournament-bracket/core/engine/create";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
@@ -10,8 +11,9 @@ import {
} from "~/features/tournament-bracket/core/tests/test-utils";
import invariant from "~/utils/invariant";
import {
matchesPlayed,
matchesPlayedByTeamId,
reNumberPlacements,
sprByTeamId,
tournamentStandings,
} from "./Standings";
@@ -230,11 +232,67 @@ describe("reNumberPlacements", () => {
});
});
describe("matchesPlayed", () => {
describe("sprByTeamId", () => {
test("gives every team an SPR of 0 when they all place exactly as seeded", () => {
const result = sprByTeamId(
standings([
{ id: 1, seed: 1, placement: 1 },
{ id: 2, seed: 2, placement: 2 },
{ id: 3, seed: 3, placement: 3 },
{ id: 4, seed: 4, placement: 4 },
]),
);
expect([...result.values()]).toEqual([0, 0, 0, 0]);
});
test("rewards a team for every placement it beat its seed by", () => {
const result = sprByTeamId(
standings([
{ id: 4, seed: 4, placement: 1 },
{ id: 1, seed: 1, placement: 2 },
{ id: 2, seed: 2, placement: 3 },
{ id: 3, seed: 3, placement: 4 },
]),
);
expect(result.get(4)).toBe(3);
expect(result.get(1)).toBe(-1);
expect(result.get(2)).toBe(-1);
expect(result.get(3)).toBe(-1);
});
test("counts tied placements as one step", () => {
const result = sprByTeamId(
standings([
{ id: 3, seed: 3, placement: 1 },
{ id: 1, seed: 1, placement: 2 },
{ id: 2, seed: 2, placement: 3 },
{ id: 4, seed: 4, placement: 3 },
]),
);
expect(result.get(3)).toBe(2);
expect(result.get(4)).toBe(0);
});
test("returns 0 for a team whose seed is outside the standings", () => {
const result = sprByTeamId(
standings([
{ id: 1, seed: 1, placement: 1 },
{ id: 2, seed: 9, placement: 2 },
]),
);
expect(result.get(2)).toBe(0);
});
});
describe("matchesPlayedByTeamId", () => {
test("tags each match with the bracket index it was actually played in", () => {
const tournament = roundRobinToSingleEliminationTournament();
const matches = matchesPlayed({ tournament, teamId: 1 });
const matches = matchesPlayedByTeamId(tournament).get(1) ?? [];
// team 1 plays 3 round robin matches (bracket idx 0)
// and 1 single elimination match (bracket idx 1)
@@ -248,7 +306,7 @@ describe("matchesPlayed", () => {
test("includes matches of brackets that are not part of the standings, in the order they were played", () => {
const tournament = roundRobinWithRedemptionTournament();
const matches = matchesPlayed({ tournament, teamId: 4 });
const matches = matchesPlayedByTeamId(tournament).get(4) ?? [];
// 3 round robin matches, the redemption bracket match and the final stage match
expect(matches.map((match) => match.bracketIdx)).toEqual([0, 0, 0, 2, 1]);
@@ -621,3 +679,12 @@ function playOut(
return played;
}
function standings(
teams: Array<{ id: number; seed: number; placement: number }>,
): Standing[] {
return teams.map(({ id, seed, placement }) => ({
team: tournamentCtxTeam(id, { seed }),
placement,
}));
}

View File

@@ -5,6 +5,8 @@ import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import invariant from "~/utils/invariant";
import { getBracketProgressionLabel } from "../tournament-utils";
const MATCH_SIDES = ["opponent1", "opponent2"] as const;
export type TournamentStandingsResult =
| { type: "single"; standings: Standing[] }
| {
@@ -52,87 +54,98 @@ export function reNumberPlacements<T extends { placement: number }>(
});
}
/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */
export function calculateSPR({
standings,
teamId,
}: {
standings: Standing[];
teamId: number;
}) {
const uniquePlacements = R.unique(
standings.map((standing) => standing.placement),
).sort((a, b) => a - b);
const teamStanding = standings.find(
(standing) => standing.team.id === teamId,
/**
* SPR (Seed Performance Rating) of every team in the standings, keyed by tournament team id.
* See https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf
*/
export function sprByTeamId(standings: Standing[]): Map<number, number> {
const indexByPlacement = new Map(
R.unique(standings.map((standing) => standing.placement))
.sort((a, b) => a - b)
.map((placement, index) => [placement, index]),
);
// defensive check to avoid crashing
if (!teamStanding) {
return 0;
const result = new Map<number, number>();
for (const standing of standings) {
const expectedPlacement =
standings[(standing.team.seed ?? 0) - 1]?.placement;
const expectedIndex = expectedPlacement
? indexByPlacement.get(expectedPlacement)
: undefined;
const actualIndex = indexByPlacement.get(standing.placement);
// defensive check to avoid crashing
if (typeof expectedIndex !== "number" || typeof actualIndex !== "number") {
result.set(standing.team.id, 0);
continue;
}
result.set(standing.team.id, expectedIndex - actualIndex);
}
const expectedPlacement =
standings[(teamStanding.team.seed ?? 0) - 1]?.placement;
// defensive check to avoid crashing
if (!expectedPlacement) {
return 0;
}
const teamPlacement = teamStanding.placement;
const actualIndex = uniquePlacements.indexOf(teamPlacement);
const expectedIndex = uniquePlacements.indexOf(expectedPlacement);
return expectedIndex - actualIndex;
return result;
}
/** Every match the team played, in the order they were played in */
export function matchesPlayed({
tournament,
teamId,
}: {
tournament: Tournament;
teamId: number;
}) {
export type MatchPlayed = {
id: number;
vsSeed: number;
result: "win" | "loss";
bracketIdx: number;
};
/**
* Every match each team played, in the order they were played in, keyed by tournament team id.
* Teams that played no match are absent from the map.
*/
export function matchesPlayedByTeamId(
tournament: Tournament,
): Map<number, MatchPlayed[]> {
const bracketsInPlayedOrder = R.sortBy(
tournament.brackets,
(bracket) => bracket.createdAt ?? Number.POSITIVE_INFINITY,
(bracket) => bracket.idx,
);
const matches = bracketsInPlayedOrder.flatMap((bracket) =>
bracket.data.match
.filter(
(match) =>
match.opponent1 &&
match.opponent2 &&
(match.opponent1?.id === teamId || match.opponent2?.id === teamId) &&
match.winnerSide,
)
.map((match) => ({
...match,
bracketIdx: bracket.idx,
})),
);
const seeds = new Map<number, number>();
const seedOf = (teamId: number) => {
const cached = seeds.get(teamId);
if (typeof cached === "number") return cached;
return matches.map((match) => {
const opponentId = (
match.opponent1?.id === teamId ? match.opponent2?.id : match.opponent1?.id
)!;
const team = tournament.teamById(opponentId);
// defensive fallback
const seed = tournament.teamById(teamId)?.seed ?? 0;
seeds.set(teamId, seed);
const teamSide = match.opponent1?.id === teamId ? "opponent1" : "opponent2";
const result: "win" | "loss" =
match.winnerSide === teamSide ? "win" : "loss";
return seed;
};
return {
id: match.id,
// defensive fallback
vsSeed: team?.seed ?? 0,
result,
bracketIdx: match.bracketIdx,
};
});
const result = new Map<number, MatchPlayed[]>();
for (const bracket of bracketsInPlayedOrder) {
for (const match of bracket.data.match) {
if (!match.winnerSide) continue;
for (const side of MATCH_SIDES) {
const teamId = match[side]?.id;
const opponentId =
match[side === "opponent1" ? "opponent2" : "opponent1"]?.id;
if (typeof teamId !== "number" || typeof opponentId !== "number") {
continue;
}
const played = result.get(teamId) ?? [];
played.push({
id: match.id,
vsSeed: seedOf(opponentId),
result: match.winnerSide === side ? "win" : "loss",
bracketIdx: bracket.idx,
});
result.set(teamId, played);
}
}
}
return result;
}
type PersistedResultRow = {

View File

@@ -28,20 +28,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
]),
);
const toRow = (standings: Standing[]) => (standing: Standing) => ({
placement: standing.placement,
spr: Standings.calculateSPR({ standings, teamId: standing.team.id }),
team: {
id: standing.team.id,
name: standing.team.name,
seed: standing.team.seed,
logoUrl: standing.team.logoUrl,
},
roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) =>
standing.team.memberUserIds.includes(member.userId),
),
matches: Standings.matchesPlayed({ tournament, teamId: standing.team.id }),
});
const matchesByTeamId = Standings.matchesPlayedByTeamId(tournament);
const toRows = (standings: Standing[]) => {
const sprByTeamId = Standings.sprByTeamId(standings);
return standings.map((standing) => ({
placement: standing.placement,
spr: sprByTeamId.get(standing.team.id) ?? 0,
team: {
id: standing.team.id,
name: standing.team.name,
seed: standing.team.seed,
logoUrl: standing.team.logoUrl,
},
roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) =>
standing.team.memberUserIds.includes(member.userId),
),
matches: matchesByTeamId.get(standing.team.id) ?? [],
}));
};
const persistedStandings = tournament.ctx.isFinalized
? Standings.standingsFromPersistedResults({
@@ -58,13 +64,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
result.type === "single"
? {
type: "single" as const,
standings: result.standings.map(toRow(result.standings)),
standings: toRows(result.standings),
}
: {
type: "multi" as const,
standings: result.standings.map(({ div, standings }) => ({
div,
standings: standings.map(toRow(standings)),
standings: toRows(standings),
})),
},
};

View File

@@ -0,0 +1,4 @@
---
type: feature
---
Various performance improvements