diff --git a/app/features/leaderboards/LeaderboardRepository.server.ts b/app/features/leaderboards/LeaderboardRepository.server.ts new file mode 100644 index 000000000..c9091dc50 --- /dev/null +++ b/app/features/leaderboards/LeaderboardRepository.server.ts @@ -0,0 +1,117 @@ +import type { InferResult } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; +import { + LEADERBOARD_MAX_SIZE, + MATCHES_COUNT_NEEDED_FOR_LEADERBOARD, +} from "./leaderboards-constants"; +import { ordinalToSp } from "../mmr"; + +function addPowers(entries: T[]) { + return entries.map((entry) => ({ + ...entry, + power: ordinalToSp(entry.ordinal), + })); +} + +function addPlacementRank(entries: T[]) { + return entries.map((entry, index) => ({ + ...entry, + placementRank: index + 1, + })); +} + +const teamLeaderboardBySeasonQuery = (season: number) => + db + .selectFrom("Skill") + .innerJoin( + (eb) => + eb + .selectFrom("Skill as InnerSkill") + .select(({ fn }) => [ + "InnerSkill.identifier", + fn.max("InnerSkill.id").as("maxId"), + ]) + .where("season", "=", season) + .groupBy("InnerSkill.identifier") + .as("Latest"), + (join) => + join + .onRef("Latest.identifier", "=", "Skill.identifier") + .onRef("Latest.maxId", "=", "Skill.id"), + ) + .select((eb) => [ + "Skill.id as entryId", + "Skill.ordinal", + jsonArrayFrom( + eb + .selectFrom("SkillTeamUser") + .innerJoin("User", "SkillTeamUser.userId", "User.id") + .select(COMMON_USER_FIELDS) + .whereRef("SkillTeamUser.skillId", "=", "Skill.id"), + ).as("members"), + jsonArrayFrom( + eb + .selectFrom("SkillTeamUser") + .innerJoin("User", "SkillTeamUser.userId", "User.id") + .leftJoin("TeamMember", "TeamMember.userId", "User.id") + .leftJoin("Team", "Team.id", "TeamMember.teamId") + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "Team.avatarImgId", + ) + .select([ + "Team.id", + "Team.name", + "UserSubmittedImage.url as avatarUrl", + "Team.customUrl", + ]) + .whereRef("SkillTeamUser.skillId", "=", "Skill.id"), + ).as("teams"), + ]) + .where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD) + .where("Skill.season", "=", season) + .orderBy("Skill.ordinal", "desc") + .limit(LEADERBOARD_MAX_SIZE); +type TeamLeaderboardBySeasonQueryReturnType = InferResult< + ReturnType +>; +export async function teamLeaderboardBySeason(season: number) { + const entries = await teamLeaderboardBySeasonQuery(season).execute(); + + const oneEntryPerUser = filterOneEntryPerUser(entries); + const withSharedTeam = resolveSharedTeam(oneEntryPerUser); + const withPower = addPowers(withSharedTeam); + return addPlacementRank(withPower); +} + +function filterOneEntryPerUser( + entries: TeamLeaderboardBySeasonQueryReturnType, +) { + const encounteredUserIds = new Set(); + return entries.filter((entry) => { + if (entry.members.some((m) => encounteredUserIds.has(m.id))) { + return false; + } + + for (const member of entry.members) { + encounteredUserIds.add(member.id); + } + + return true; + }); +} + +function resolveSharedTeam(entries: ReturnType) { + return entries.map(({ teams, ...entry }) => { + const sharedSameTeam = + teams.length === 4 && teams.every((team) => team.id === teams[0].id); + + return { + ...entry, + team: sharedSameTeam ? teams[0] : undefined, + }; + }); +} diff --git a/app/features/leaderboards/core/leaderboards.server.ts b/app/features/leaderboards/core/leaderboards.server.ts index f2e48ed0f..fbfc8bd11 100644 --- a/app/features/leaderboards/core/leaderboards.server.ts +++ b/app/features/leaderboards/core/leaderboards.server.ts @@ -3,7 +3,6 @@ import type { UserSPLeaderboardItem } from "../queries/userSPLeaderboard.server" import type { SeasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { weaponCategories } from "~/modules/in-game-lists"; -import type { TeamSPLeaderboardItem } from "../queries/teamSPLeaderboard.server"; import { seasonHasTopTen } from "../leaderboards-utils"; export function addTiers(entries: UserSPLeaderboardItem[], season: number) { @@ -88,21 +87,6 @@ export function filterByWeaponCategory< ); } -export function oneEntryPerUser(entries: TeamSPLeaderboardItem[]) { - const encounteredUserIds = new Set(); - return entries.filter((entry) => { - if (entry.members.some((m) => encounteredUserIds.has(m.id))) { - return false; - } - - for (const member of entry.members) { - encounteredUserIds.add(member.id); - } - - return true; - }); -} - export function addPlacementRank(entries: T[]) { return entries.map((entry, index) => ({ ...entry, diff --git a/app/features/leaderboards/queries/teamSPLeaderboard.server.ts b/app/features/leaderboards/queries/teamSPLeaderboard.server.ts deleted file mode 100644 index bcf2947dd..000000000 --- a/app/features/leaderboards/queries/teamSPLeaderboard.server.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { sql } from "~/db/sql"; -import { - LEADERBOARD_MAX_SIZE, - MATCHES_COUNT_NEEDED_FOR_LEADERBOARD, -} from "../leaderboards-constants"; -import type { Team, User, UserSubmittedImage } from "~/db/types"; -import { ordinalToSp } from "~/features/mmr"; -import { parseDBJsonArray } from "~/utils/sql"; - -const stm = sql.prepare(/* sql */ ` - select - "Skill"."id" as "entryId", - "Skill"."ordinal", - json_group_array( - json_object( - 'id', - "User"."id", - 'discordName', - "User"."discordName", - 'discordAvatar', - "User"."discordAvatar", - 'discordDiscriminator', - "User"."discordDiscriminator", - 'discordId', - "User"."discordId", - 'customUrl', - "User"."customUrl" - ) - ) as "members", - json_group_array( - json_object( - 'name', - "Team"."name", - 'avatarImgUrl', - "UserSubmittedImage"."url", - 'customUrl', - "Team"."customUrl" - ) - ) as "teams" - from - "Skill" - inner join "SkillTeamUser" on "SkillTeamUser"."skillId" = "Skill"."id" - left join "User" on "User"."id" = "SkillTeamUser"."userId" - left join "TeamMember" on "TeamMember"."userId" = "User"."id" - left join "Team" on "Team"."id" = "TeamMember"."teamId" - left join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Team"."avatarImgId" - inner join ( - select "identifier", max("id") as "maxId" - from "Skill" - where "season" = @season - group by "identifier" - ) "Latest" on "Skill"."identifier" = "Latest"."identifier" and "Skill"."id" = "Latest"."maxId" - where - "Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD} - and "Skill"."season" = @season - group by - "Skill"."identifier" - order by - "Skill"."ordinal" desc - limit - ${LEADERBOARD_MAX_SIZE} -`); - -export interface TeamSPLeaderboardItem { - entryId: number; - power: number; - members: Array<{ - id: User["id"]; - discordName: User["discordName"]; - discordAvatar: User["discordAvatar"]; - discordDiscriminator: User["discordDiscriminator"]; - discordId: User["discordId"]; - customUrl: User["customUrl"]; - teamId: Team["id"]; - teamName: Team["name"]; - teamAvatarImgUrl: UserSubmittedImage["url"]; - teamCustomUrl: Team["customUrl"]; - }>; - team?: { - name: Team["name"]; - avatarImgUrl: UserSubmittedImage["url"]; - customUrl: Team["customUrl"]; - }; - placementRank: number; -} - -export function teamSPLeaderboard(season: number): TeamSPLeaderboardItem[] { - return (stm.all({ season }) as any[]).map( - ({ ordinal, members, teams, ...rest }) => { - const parsedTeams = parseDBJsonArray(teams); - const sharesSameTeam = - parsedTeams.length === 4 && - parsedTeams.every((team: any) => team.id === parsedTeams[0].id); - - return { - ...rest, - power: ordinalToSp(ordinal), - members: parseDBJsonArray(members), - team: sharesSameTeam ? parsedTeams[0] : undefined, - }; - }, - ); -} diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index b76393fef..2fc854add 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -20,10 +20,6 @@ import { import styles from "../../top-search/top-search.css"; import { userSPLeaderboard } from "../queries/userSPLeaderboard.server"; import type { SendouRouteHandle } from "~/utils/remix"; -import { - type TeamSPLeaderboardItem, - teamSPLeaderboard, -} from "../queries/teamSPLeaderboard.server"; import React from "react"; import { LEADERBOARD_TYPES } from "../leaderboards-constants"; import { useTranslation } from "~/hooks/useTranslation"; @@ -48,11 +44,9 @@ import { } from "~/features/mmr/season"; import { addPendingPlusTiers, - addPlacementRank, addTiers, addWeapons, filterByWeaponCategory, - oneEntryPerUser, } from "../core/leaderboards.server"; import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server"; import { cachified } from "cachified"; @@ -61,6 +55,7 @@ import { HALF_HOUR_IN_MS } from "~/constants"; import { TopTenPlayer } from "../components/TopTenPlayer"; import { seasonHasTopTen } from "../leaderboards-utils"; import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "~/features/mmr/mmr-constants"; +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; export const handle: SendouRouteHandle = { i18n: ["vods"], @@ -141,12 +136,8 @@ export const loader = async ({ request }: LoaderArgs) => { key: `team-leaderboard-season-${season}`, cache, ttl: ttl(HALF_HOUR_IN_MS), - // eslint-disable-next-line @typescript-eslint/require-await async getFreshValue() { - const leaderboard = teamSPLeaderboard(season); - const filteredByUser = oneEntryPerUser(leaderboard); - - return addPlacementRank(filteredByUser); + return LeaderboardRepository.teamLeaderboardBySeason(season); }, }) : null; @@ -396,7 +387,11 @@ function PlayersTable({ ); } -function TeamTable({ entries }: { entries: TeamSPLeaderboardItem[] }) { +function TeamTable({ + entries, +}: { + entries: NonNullable["teamLeaderboard"]>; +}) { return (
{entries.map((entry) => { @@ -406,14 +401,16 @@ function TeamTable({ entries }: { entries: TeamSPLeaderboardItem[] }) {
{entry.placementRank}
- {entry.team?.avatarImgUrl ? ( + {entry.team?.avatarUrl ? (