Team leaderboard - migrate to Kysely & fix duplicate member bug

This commit is contained in:
Kalle
2023-11-07 21:21:37 +02:00
parent 571687a9a9
commit 730cb6b3fc
4 changed files with 130 additions and 135 deletions

View File

@@ -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<T extends { ordinal: number }>(entries: T[]) {
return entries.map((entry) => ({
...entry,
power: ordinalToSp(entry.ordinal),
}));
}
function addPlacementRank<T>(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<typeof teamLeaderboardBySeasonQuery>
>;
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<number>();
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<typeof filterOneEntryPerUser>) {
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,
};
});
}

View File

@@ -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<number>();
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<T>(entries: T[]) {
return entries.map((entry, index) => ({
...entry,

View File

@@ -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,
};
},
);
}

View File

@@ -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<SerializeFrom<typeof loader>["teamLeaderboard"]>;
}) {
return (
<div className="placements__table">
{entries.map((entry) => {
@@ -406,14 +401,16 @@ function TeamTable({ entries }: { entries: TeamSPLeaderboardItem[] }) {
<div className="placements__table__rank">
{entry.placementRank}
</div>
{entry.team?.avatarImgUrl ? (
{entry.team?.avatarUrl ? (
<Link
to={teamPage(entry.team.customUrl)}
title={entry.team.name}
// TODO: can be made better when $narrowNotNull lands
to={teamPage(entry.team.customUrl!)}
// TODO: can be made better when $narrowNotNull lands
title={entry.team.name!}
>
<Avatar
size="xxs"
url={userSubmittedImage(entry.team.avatarImgUrl)}
url={userSubmittedImage(entry.team.avatarUrl)}
className="placements__avatar"
/>
</Link>