mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 03:35:40 -05:00
Team leaderboard - migrate to Kysely & fix duplicate member bug
This commit is contained in:
117
app/features/leaderboards/LeaderboardRepository.server.ts
Normal file
117
app/features/leaderboards/LeaderboardRepository.server.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user