Optimize tournament series leaderboard, tiebreak by placements

This commit is contained in:
Kalle
2026-08-22 07:30:15 +03:00
parent 35eb90356b
commit bee8cee3aa
6 changed files with 403 additions and 139 deletions

View File

@@ -401,6 +401,31 @@ export async function findResultsByEventId(eventId: number) {
.execute();
}
/**
* Players of the podium teams of the given events, one row per player. Players reported as plain
* text rather than linked to an account have a `null` id.
*/
export async function findTopThreeResultsByEventIds(eventIds: number[]) {
if (eventIds.length === 0) return [];
return db
.selectFrom("CalendarEventResultTeam")
.innerJoin(
"CalendarEventResultPlayer",
"CalendarEventResultPlayer.teamId",
"CalendarEventResultTeam.id",
)
.leftJoin("User", "User.id", "CalendarEventResultPlayer.userId")
.select((eb) => [
"CalendarEventResultTeam.id as teamId",
"CalendarEventResultTeam.placement",
...commonUserSelect(eb),
])
.where("CalendarEventResultTeam.eventId", "in", eventIds)
.where("CalendarEventResultTeam.placement", "<=", 3)
.execute();
}
type CreateArgs = Pick<
Tables["CalendarEvent"],
| "name"

View File

@@ -333,7 +333,6 @@ const mapEvent = <
T extends {
tournamentId: number | null;
logoUrl: string;
name: string;
},
>(
event: T,
@@ -420,6 +419,11 @@ export async function findPaginatedEventsBySeries({
return events.map(mapEvent);
}
/**
* Every event of the series, newest first. Selects only what the leaderboard and the series header
* need - the winners of {@link findPaginatedEventsBySeries} are far too costly across a whole
* series.
*/
export async function findAllEventsBySeries({
organizationId,
substringMatches,
@@ -427,10 +431,31 @@ export async function findAllEventsBySeries({
organizationId: number;
substringMatches: string[];
}) {
const events = await findSeriesEventsBaseQuery({
organizationId,
substringMatches,
}).execute();
const events = await db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ eb }) => [
"CalendarEvent.id as eventId",
"CalendarEvent.tournamentId",
eb.fn.min("CalendarEventDate.startsAt").as("startsAt"),
tournamentLogoWithDefault(eb).as("logoUrl"),
])
.where("CalendarEvent.organizationId", "=", organizationId)
.where("CalendarEvent.hidden", "=", 0)
.where((eb) =>
eb.or(
substringMatches.map((match) =>
eb("CalendarEvent.name", "like", `%${match}%`),
),
),
)
.groupBy("CalendarEvent.id")
.orderBy("CalendarEventDate.startsAt", "desc")
.execute();
return events.map(mapEvent);
}

View File

@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { eventLeaderboards } from "./leaderboards.server";
const SERIES_SUBSTRING = "series";
const users = UserFactory.pool();
const authorId = () => users.id(1);
describe("eventLeaderboards", () => {
let organizationId: number;
beforeEach(async () => {
await users.create(18);
organizationId = (
await TournamentOrganizationFactory.create({ ownerId: authorId() })
).id;
});
const seedEvent = async (
results: Array<{ placement: number; playerIds: Array<number | null> }>,
) => {
const { id: eventId } = await CalendarEventFactory.create({
authorId: authorId(),
organizationId,
name: `${SERIES_SUBSTRING} event`,
});
await CalendarEventResultFactory.create({
eventId,
results: results.map((result, i) => ({
teamName: `Team ${i + 1}`,
placement: result.placement,
players: result.playerIds.map((userId) => ({
userId,
name: userId ? null : "reported as text",
})),
})),
});
};
const seedTournament = async (teamRosters: Array<Array<number>>) => {
await TournamentFactory.createPlayed(
{
authorId: authorId(),
organizationId,
name: `${SERIES_SUBSTRING} tournament`,
minMembersPerTeam: 1,
},
{ teamRosters, playedOut: "all" },
);
};
const leaderboard = async () =>
eventLeaderboards(
await TournamentOrganizationRepository.findAllEventsBySeries({
organizationId,
substringMatches: [SERIES_SUBSTRING],
}),
);
test("awards 4, 2 and 1 points for the podium places only", async () => {
await seedEvent([
{ placement: 1, playerIds: [users.id(1)] },
{ placement: 2, playerIds: [users.id(2)] },
{ placement: 3, playerIds: [users.id(3)] },
{ placement: 4, playerIds: [users.id(4)] },
]);
const result = await leaderboard();
expect(result.map((entry) => entry.user.id)).toEqual([
users.id(1),
users.id(2),
users.id(3),
]);
expect(result.map((entry) => entry.points)).toEqual([
"4.00",
"2.00",
"1.00",
]);
});
test("splits the points of teams larger than four", async () => {
await seedEvent([
{ placement: 1, playerIds: users.ids(6) },
{ placement: 2, playerIds: [users.id(1)] },
]);
const result = await leaderboard();
expect(result[0].points).toBe("4.67");
expect(result[1].points).toBe("2.67");
});
test("ignores players reported as plain text", async () => {
await seedEvent([{ placement: 1, playerIds: [users.id(1), null, null] }]);
const result = await leaderboard();
expect(result).toHaveLength(1);
expect(result[0].user.id).toBe(users.id(1));
});
test("sums points and placements across the events of the series", async () => {
await seedEvent([{ placement: 1, playerIds: [users.id(1)] }]);
await seedEvent([{ placement: 3, playerIds: [users.id(1)] }]);
const result = await leaderboard();
expect(result[0].points).toBe("5.00");
expect(result[0].placements).toEqual({ first: 1, second: 0, third: 1 });
});
test("breaks a points tie by first places", async () => {
await seedEvent([{ placement: 2, playerIds: [users.id(2)] }]);
await seedEvent([{ placement: 2, playerIds: [users.id(2)] }]);
await seedEvent([{ placement: 1, playerIds: [users.id(1)] }]);
const result = await leaderboard();
expect(result.map((entry) => entry.points)).toEqual(["4.00", "4.00"]);
expect(result.map((entry) => entry.user.id)).toEqual([
users.id(1),
users.id(2),
]);
});
test("breaks a points and first places tie by second places", async () => {
await seedEvent([{ placement: 1, playerIds: [users.id(1)] }]);
await seedEvent([{ placement: 3, playerIds: [users.id(1)] }]);
await seedEvent([{ placement: 3, playerIds: [users.id(1)] }]);
await seedEvent([{ placement: 1, playerIds: [users.id(2)] }]);
await seedEvent([{ placement: 2, playerIds: [users.id(2)] }]);
const result = await leaderboard();
expect(result.map((entry) => entry.points)).toEqual(["6.00", "6.00"]);
expect(result.map((entry) => entry.user.id)).toEqual([
users.id(2),
users.id(1),
]);
});
test("breaks a points, first and second places tie by third places", async () => {
const onceThirdIds = users.ids(18).slice(0, 6);
const twiceThirdIds = users.ids(18).slice(6);
await seedEvent([{ placement: 3, playerIds: onceThirdIds }]);
await seedEvent([{ placement: 3, playerIds: twiceThirdIds }]);
await seedEvent([{ placement: 3, playerIds: twiceThirdIds }]);
const result = await leaderboard();
expect(result.every((entry) => entry.points === "0.67")).toBe(true);
expect(
result
.slice(0, twiceThirdIds.length)
.map((entry) => entry.user.id)
.sort(),
).toEqual([...twiceThirdIds].sort());
});
test("scores the podium of a played tournament of the series", async () => {
await seedTournament([[users.id(1)], [users.id(2)]]);
const result = await leaderboard();
expect(result.map((entry) => entry.user.id)).toEqual([
users.id(1),
users.id(2),
]);
expect(result.map((entry) => entry.points)).toEqual(["4.00", "2.00"]);
});
test("sums points and placements across tournaments and reported events", async () => {
await seedTournament([[users.id(1)], [users.id(2)]]);
await seedEvent([{ placement: 3, playerIds: [users.id(1)] }]);
const result = await leaderboard();
expect(result[0].user.id).toBe(users.id(1));
expect(result[0].points).toBe("5.00");
expect(result[0].placements).toEqual({ first: 1, second: 0, third: 1 });
});
});

View File

@@ -1,3 +1,4 @@
import * as R from "remeda";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import type { CommonUser } from "~/utils/kysely.server";
@@ -8,6 +9,17 @@ const THIRD_PLACE_POINTS = 1;
const SECOND_PLACE_POINTS = THIRD_PLACE_POINTS * 2;
const FIRST_PLACE_POINTS = SECOND_PLACE_POINTS * 2;
const POINTS_TIE_EPSILON = 0.0001;
const USER_KEYS = [
"id",
"username",
"discordId",
"discordAvatar",
"customUrl",
"customAvatarUrl",
] as const;
type EventLeaderboardEvent = Unpacked<
Awaited<
ReturnType<typeof TournamentOrganizationRepository.findAllEventsBySeries>
@@ -24,114 +36,139 @@ interface LeaderboardInfo {
};
}
export async function eventLeaderboards(
events: Awaited<
ReturnType<typeof TournamentOrganizationRepository.findAllEventsBySeries>
>,
) {
const points = new Map<number, LeaderboardInfo>();
export async function eventLeaderboards(events: EventLeaderboardEvent[]) {
const tournamentIds: number[] = [];
const calendarEventIds: number[] = [];
for (const event of events) {
const eventsPoints = event.tournamentId
? await tournamentPoints(event as typeof event & { tournamentId: number })
: await calendarEventPoints(event);
mergeMaps(points, eventsPoints);
}
return Array.from(points.values())
.sort((a, b) => b.points - a.points)
.map((info) => ({ ...info, points: info.points.toFixed(2) }));
}
async function tournamentPoints(
event: EventLeaderboardEvent & { tournamentId: number },
): Promise<Map<number, LeaderboardInfo>> {
const results = await TournamentRepository.findTopThreeResultsByTournamentId(
event.tournamentId,
);
const leaderboardInfo = new Map<number, LeaderboardInfo>();
if (results.length === 0) return leaderboardInfo;
for (const result of results) {
const teamSize = results.filter(
(result2) => result.tournamentTeamId === result2.tournamentTeamId,
).length;
leaderboardInfo.set(result.user.id, {
user: result.user,
points: pointsAdjustedToTeamSize({
basePoints:
result.placement === 1
? FIRST_PLACE_POINTS
: result.placement === 2
? SECOND_PLACE_POINTS
: THIRD_PLACE_POINTS,
teamSize: teamSize,
}),
placements: {
first: result.placement === 1 ? 1 : 0,
second: result.placement === 2 ? 1 : 0,
third: result.placement === 3 ? 1 : 0,
},
});
}
return leaderboardInfo;
}
async function calendarEventPoints(
event: EventLeaderboardEvent,
): Promise<Map<number, LeaderboardInfo>> {
const results = await CalendarRepository.findResultsByEventId(event.eventId);
const leaderboardInfo = new Map<number, LeaderboardInfo>();
if (results.length === 0) return leaderboardInfo;
for (const placement of [1, 2, 3]) {
const placementResults = results.filter(
(result) => result.placement === placement,
);
for (const team of placementResults) {
const teamSize = team.players.filter((player) => player.id).length;
for (const player of team.players) {
// not a connected user, reported as simple text
if (!player.id) continue;
leaderboardInfo.set(player.id, {
user: {
customUrl: player.customUrl,
customAvatarUrl: player.customAvatarUrl,
discordAvatar: player.discordAvatar,
discordId: player.discordId!,
id: player.id,
username: player.username!,
},
points: pointsAdjustedToTeamSize({
basePoints:
placement === 1
? FIRST_PLACE_POINTS
: placement === 2
? SECOND_PLACE_POINTS
: THIRD_PLACE_POINTS,
teamSize: teamSize,
}),
placements: {
first: placement === 1 ? 1 : 0,
second: placement === 2 ? 1 : 0,
third: placement === 3 ? 1 : 0,
},
});
}
if (event.tournamentId) {
tournamentIds.push(event.tournamentId);
} else {
calendarEventIds.push(event.eventId);
}
}
return leaderboardInfo;
const [tournamentResults, calendarEventResults] = await Promise.all([
TournamentRepository.findTopThreeResultsByTournamentIds(tournamentIds),
CalendarRepository.findTopThreeResultsByEventIds(calendarEventIds),
]);
const points = new Map<number, LeaderboardInfo>();
addTournamentPoints(points, tournamentResults);
addCalendarEventPoints(points, calendarEventResults);
return Array.from(points.values())
.sort(byPointsThenPlacements)
.map((info) => ({ ...info, points: info.points.toFixed(2) }));
}
/** Most points first, ties broken by most first places, then seconds, then thirds. */
function byPointsThenPlacements(a: LeaderboardInfo, b: LeaderboardInfo) {
const pointsDifference = b.points - a.points;
// team size adjusted points are fractional, so equal totals can differ in the last bits
if (Math.abs(pointsDifference) > POINTS_TIE_EPSILON) return pointsDifference;
return (
b.placements.first - a.placements.first ||
b.placements.second - a.placements.second ||
b.placements.third - a.placements.third
);
}
type TournamentResult = Unpacked<
Awaited<
ReturnType<typeof TournamentRepository.findTopThreeResultsByTournamentIds>
>
>;
function addTournamentPoints(
acc: Map<number, LeaderboardInfo>,
results: TournamentResult[],
) {
const teamSizes = R.countBy(results, (result) => result.tournamentTeamId);
for (const result of results) {
addPlacement(acc, {
user: R.pick(result, USER_KEYS),
placement: result.placement,
teamSize: teamSizes[result.tournamentTeamId],
});
}
}
type CalendarEventResult = Unpacked<
Awaited<ReturnType<typeof CalendarRepository.findTopThreeResultsByEventIds>>
>;
function addCalendarEventPoints(
acc: Map<number, LeaderboardInfo>,
results: CalendarEventResult[],
) {
// players reported as simple text don't score
const teamSizes = R.countBy(
results.filter((result) => result.id),
(result) => result.teamId,
);
for (const result of results) {
if (!result.id) continue;
addPlacement(acc, {
user: {
...R.pick(result, USER_KEYS),
id: result.id,
username: result.username!,
discordId: result.discordId!,
},
placement: result.placement,
teamSize: teamSizes[result.teamId],
});
}
}
function addPlacement(
acc: Map<number, LeaderboardInfo>,
{
user,
placement,
teamSize,
}: {
user: CommonUser;
placement: number;
teamSize: number;
},
) {
const points = pointsAdjustedToTeamSize({
basePoints:
placement === 1
? FIRST_PLACE_POINTS
: placement === 2
? SECOND_PLACE_POINTS
: THIRD_PLACE_POINTS,
teamSize,
});
const existing = acc.get(user.id);
if (!existing) {
acc.set(user.id, {
user,
points,
placements: {
first: placement === 1 ? 1 : 0,
second: placement === 2 ? 1 : 0,
third: placement === 3 ? 1 : 0,
},
});
return;
}
existing.points += points;
if (placement === 1) existing.placements.first += 1;
if (placement === 2) existing.placements.second += 1;
if (placement === 3) existing.placements.third += 1;
}
function pointsAdjustedToTeamSize({
@@ -145,22 +182,3 @@ function pointsAdjustedToTeamSize({
return (basePoints * 4) / teamSize;
}
function mergeMaps(
accMap: Map<number, LeaderboardInfo>,
newMap: Map<number, LeaderboardInfo>,
) {
for (const [userId, info] of newMap) {
const accPoints = accMap.get(userId);
if (!accPoints) {
accMap.set(userId, info);
continue;
}
accPoints.points += info.points;
accPoints.placements.first += info.placements.first;
accPoints.placements.second += info.placements.second;
accPoints.placements.third += info.placements.third;
}
}

View File

@@ -982,22 +982,22 @@ export function findAllBetweenTwoTimestamps({
.execute();
}
export function findTopThreeResultsByTournamentId(tournamentId: number) {
/** Podium placements of the given tournaments, one row per placed player. */
export async function findTopThreeResultsByTournamentIds(
tournamentIds: number[],
) {
if (tournamentIds.length === 0) return [];
return db
.selectFrom("TournamentResult")
.select(({ eb }) => [
.innerJoin("User", "User.id", "TournamentResult.userId")
.select((eb) => [
"TournamentResult.placement",
"TournamentResult.tournamentTeamId",
jsonObjectFrom(
eb
.selectFrom("User")
.select((eb) => commonUserSelect(eb))
.whereRef("User.id", "=", "TournamentResult.userId"),
).as("user"),
...commonUserSelect(eb),
])
.where("tournamentId", "=", tournamentId)
.where("TournamentResult.tournamentId", "in", tournamentIds)
.where("TournamentResult.placement", "<=", 3)
.$narrowType<{ user: NotNull }>()
.execute();
}

View File

@@ -226,6 +226,11 @@ export function buildCases(fx: Fixtures): {
add("CalendarRepository.findResultsByEventId", fx.resultsEventId, (eventId) =>
CalendarRepository.findResultsByEventId(eventId),
);
add(
"CalendarRepository.findTopThreeResultsByEventIds",
fx.resultsEventId,
(eventId) => CalendarRepository.findTopThreeResultsByEventIds([eventId]),
);
// FriendRepository
add("FriendRepository.findByUserIdWithActivity", fx.heavyFriendPair, (pair) =>
@@ -1091,10 +1096,10 @@ export function buildCases(fx: Fixtures): {
(window) => TournamentRepository.findAllBetweenTwoTimestamps(window),
);
add(
"TournamentRepository.findTopThreeResultsByTournamentId",
"TournamentRepository.findTopThreeResultsByTournamentIds",
fx.heavyTournamentId,
(tournamentId) =>
TournamentRepository.findTopThreeResultsByTournamentId(tournamentId),
TournamentRepository.findTopThreeResultsByTournamentIds([tournamentId]),
);
add(
"TournamentRepository.findFriendCodesByTournamentId",