mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
Optimize standings calculation
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
4
changelog/2026-08-29-performance-improvements.md
Normal file
4
changelog/2026-08-29-performance-improvements.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
Various performance improvements
|
||||
Reference in New Issue
Block a user