Skip to content

Commit

Permalink
fix: ensure players only appear once in leaderboard (#113)
Browse files Browse the repository at this point in the history
* fix: ensure players only appear once in leaderboard

* fix: add comment back
  • Loading branch information
HenrySpartGlobal authored Aug 25, 2024
1 parent 20b4d92 commit 6aa9802
Showing 1 changed file with 114 additions and 43 deletions.
157 changes: 114 additions & 43 deletions src/db/queries/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-argument */

import { sql } from "kysely";
import { db } from "..";
import { cache } from "../../cache";
Expand All @@ -7,20 +14,29 @@ import {
type LeaderboardResponse,
} from "./leaderboard.types";

// patch json seralization with bigints
// patch json serialization with bigints
// eslint-disable-next-line @typescript-eslint/no-redeclare, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(BigInt.prototype as any).toJSON = function () {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return this.toString();
};

const orderByMap = {
wins: "points.wins",
losses: "points.losses",
wins: "merged_points.total_wins",
losses: "merged_points.total_losses",
mmr: "mmr",
winrate: "winrate",
} as const;

interface LeaderboardResult {
ign: string | null;
user_id: string;
wins: number;
losses: number;
mmr: number;
winrate: number;
}

export async function fetchLeaderboardRaw(
opts: FetchLeaderboardInput
): Promise<LeaderboardResponse> {
Expand All @@ -46,45 +62,97 @@ export async function fetchLeaderboardRaw(
let builder = db
.selectFrom("mmr_rating")
.where("mmr_rating.guild_id", "=", guildId)
.where("mmr_rating.time", "=",
(qb) => qb.selectFrom("mmr_rating as latest_mmr")
.select(sql<number>`MAX(${sql.ref('latest_mmr.time')})`.as("max_time"))
.whereRef("latest_mmr.user_id", "=", "mmr_rating.user_id")
.where("latest_mmr.guild_id", "=", guildId)
// Filter for the latest mmr rating per user
.where(
"mmr_rating.time",
"=",
(qb) =>
qb
.selectFrom("mmr_rating as latest_mmr")
.select(sql<number>`MAX(${sql.ref('latest_mmr.time')})`.as("max_time"))
.whereRef("latest_mmr.user_id", "=", "mmr_rating.user_id")
.where("latest_mmr.guild_id", "=", guildId)
.where((eb) =>
eb.or([
eb("latest_mmr.queue_channel_id", "=", 0),
eb(
"latest_mmr.queue_channel_id",
"in",
qb
.selectFrom("queuechannels")
.select("queuechannels.channel_id")
.where("queuechannels.guild_id", "=", guildId)
.where("queuechannels.unique_leaderboard", "=", false)
),
])
)
)
// Join with summed points for global leaderboard (non-unique queues)
.leftJoin(
(qb) =>
qb
.selectFrom("points")
.select([
"points.user_id",
sql<number>`SUM(${sql.ref('points.wins')})`.as("total_wins"),
sql<number>`SUM(${sql.ref('points.losses')})`.as("total_losses"),
])
.where("points.guild_id", "=", guildId)
.where((eb) =>
eb.or([
eb("points.queue_channel_id", "=", 0),
eb(
"points.queue_channel_id",
"in",
qb
.selectFrom("queuechannels")
.select("queuechannels.channel_id")
.where("queuechannels.guild_id", "=", guildId)
.where("queuechannels.unique_leaderboard", "=", false)
),
])
)
.groupBy("points.user_id")
.as("merged_points"),
// @ts-expect-error Error with Kysely type inference, safe in runtime context
"merged_points.user_id",
"mmr_rating.user_id"
)
// Join with igns table
// @ts-expect-error Error with Kysely type inference, safe in runtime context
.leftJoin("igns", (eb) =>
// @ts-expect-error Error with Kysely type inference, safe in runtime context
eb.on((b) =>
b.and([
b(b.ref("igns.user_id"), "=", b.ref("mmr_rating.user_id")),
b(b.ref("igns.guild_id"), "=", b.ref("mmr_rating.guild_id")),
])
)
)
// join with points, same user_id and guild_id
.leftJoin("points", (eb) => eb.on(b => b.and([
b(b.ref("points.user_id"), "=", b.ref("mmr_rating.user_id")),
b(b.ref("points.guild_id"), "=", b.ref("mmr_rating.guild_id"))
])))
// join with igns, same user_id and guild_id
.leftJoin("igns", (eb) => eb.on(b => b.and([
b(b.ref("igns.user_id"), "=", b.ref("mmr_rating.user_id")),
b(b.ref("igns.guild_id"), "=", b.ref("mmr_rating.guild_id"))
])))
// calculate winrate and mmr
// Calculate winrate and mmr
.select([
// (wins + 0.0) / (GREATEST(wins + losses, 1.0) + 0.0)
// (total_wins + 0.0) / (GREATEST(total_wins + total_losses, 1.0) + 0.0)
// @ts-expect-error Error with Kysely type inference, safe in runtime context
(eb) =>
sql<string>`(${eb.ref("points.wins")} + 0.0) / (GREATEST(${eb.ref(
"points.wins"
)} + ${eb.ref("points.losses")}, 1.0) + 0.0)`.as("winrate"),
sql<string>`(${eb.ref("merged_points.total_wins")} + 0.0) / (GREATEST(${eb.ref(
"merged_points.total_wins"
)} + ${eb.ref("merged_points.total_losses")}, 1.0) + 0.0)`.as("winrate"),
// (mu - 2 * sigma) * 100
// @ts-expect-error Error with Kysely type inference, safe in runtime context
(eb) =>
sql<string>`(${eb.ref("mmr_rating.mu")} - 2 * ${eb.ref(
"mmr_rating.sigma"
)}) * 100`.as("mmr"),
])
// add in sortBy and sortDirection
// Add in sortBy and sortDirection
.orderBy(orderBy, options.sortDirection)
// select the other fields we need
// Select the other fields we need
.select([
"igns.ign",
"mmr_rating.user_id",
"points.wins",
"points.losses",
"merged_points.total_wins as wins",
"merged_points.total_losses as losses",

// calculate the position
// Maybe someone smarter can figure out how to get the position
// the ROW_NUMBER approach does not seem to give correct numbers
// sql<string>`ROW_NUMBER() OVER(ORDER BY ${eb.ref("points.wins")} DESC)`.as("position")
Expand All @@ -98,6 +166,7 @@ export async function fetchLeaderboardRaw(
// .as("position")
]);

// Search filter
const searchFor = options.searchFor;
if (searchFor !== undefined) {
try {
Expand All @@ -106,16 +175,20 @@ export async function fetchLeaderboardRaw(

// if successful, it could be an ID
builder = builder
.where(eb => eb.or([
// kysely says it doesnt support bigint, but it does
eb(eb.ref("mmr_rating.user_id"), "=", id as unknown as number),
eb(eb.ref("igns.ign"), "like", `%${searchFor}%`)
]))
// @ts-expect-error Error with Kysely type inference, safe in runtime context
.where((eb) =>
eb.or([
// Kysely says it doesn't support bigint, but it does
eb(eb.ref("mmr_rating.user_id"), "=", id as unknown as number),
eb(eb.ref("igns.ign"), "like", `%${searchFor}%`),
])
);
} catch (error) {
builder = builder
// The following line only works if igns.ign is FULLTEXT indexed
// .where(eb => sql`MATCH(${eb.ref("igns.ign")}) against(${searchFor})`)
// so for now, we just do a LIKE search
// @ts-expect-error Error with Kysely type inference, safe in runtime context
.where((eb) => sql`${eb.ref("igns.ign")} LIKE ${`%${searchFor}%`}`);
}
}
Expand All @@ -133,7 +206,7 @@ export async function fetchLeaderboardRaw(
.select([db.fn.count("mmr_rating.user_id").distinct().as("total")])
: undefined;

// // execute the queries
// execute the queries
const startTime = Date.now();
const [data, totalEntries] = await Promise.all([
requestedData.execute(),
Expand All @@ -158,15 +231,13 @@ export async function fetchLeaderboardRaw(
);

return {
data: data.map((row) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ign: row.ign ?? row.user_id!.toString(),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user_id: row.user_id!.toString(),
data: data.map((row: LeaderboardResult) => ({
ign: row.ign ?? row.user_id,
user_id: row.user_id,
wins: row.wins,
losses: row.losses,
mmr: parseFloat(row.mmr),
winrate: parseFloat(row.winrate) * 100,
mmr: row.mmr,
winrate: row.winrate * 100,
})),
total: totalEntriesParsed,
fetched: Date.now(),
Expand All @@ -175,7 +246,7 @@ export async function fetchLeaderboardRaw(

export const fetchLeaderboard = cache(fetchLeaderboardRaw, {
cacheKey: (params) => {
// dont cache searches
// don't cache searches
if (params.searchFor) return false;

// hash the params so we get a unique id for the given options
Expand All @@ -186,5 +257,5 @@ export const fetchLeaderboard = cache(fetchLeaderboardRaw, {
return `leaderboard:${hash}`;
},
staleTime: /* 30 Seconds */ 30,
expireTime: /* 1 Hour */ 60 * 60
expireTime: /* 1 Hour */ 60 * 60,
});

0 comments on commit 6aa9802

Please sign in to comment.