diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index dc67a758e..4e7177b9d 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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" diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index fe2fabee9..a4864a8ed 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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); } diff --git a/app/features/tournament-organization/core/leaderboards.server.test.ts b/app/features/tournament-organization/core/leaderboards.server.test.ts new file mode 100644 index 000000000..c74d4300a --- /dev/null +++ b/app/features/tournament-organization/core/leaderboards.server.test.ts @@ -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 }>, + ) => { + 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>) => { + 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 }); + }); +}); diff --git a/app/features/tournament-organization/core/leaderboards.server.ts b/app/features/tournament-organization/core/leaderboards.server.ts index f9f570cb9..05a0a390a 100644 --- a/app/features/tournament-organization/core/leaderboards.server.ts +++ b/app/features/tournament-organization/core/leaderboards.server.ts @@ -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 @@ -24,114 +36,139 @@ interface LeaderboardInfo { }; } -export async function eventLeaderboards( - events: Awaited< - ReturnType - >, -) { - const points = new Map(); +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> { - const results = await TournamentRepository.findTopThreeResultsByTournamentId( - event.tournamentId, - ); - - const leaderboardInfo = new Map(); - - 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> { - const results = await CalendarRepository.findResultsByEventId(event.eventId); - - const leaderboardInfo = new Map(); - - 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(); + + 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 + > +>; + +function addTournamentPoints( + acc: Map, + 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> +>; + +function addCalendarEventPoints( + acc: Map, + 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, + { + 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, - newMap: Map, -) { - 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; - } -} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index c73aebdf6..77138214f 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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(); } diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 7134853f9..65a7d47ae 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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",