From 6aa9802e4556350da6bcc083718fa5d65413c4f6 Mon Sep 17 00:00:00 2001 From: Henry <31442053+HenrySpartGlobal@users.noreply.github.com> Date: Sun, 25 Aug 2024 13:58:15 +0100 Subject: [PATCH] fix: ensure players only appear once in leaderboard (#113) * fix: ensure players only appear once in leaderboard * fix: add comment back --- src/db/queries/leaderboard.ts | 157 ++++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 43 deletions(-) diff --git a/src/db/queries/leaderboard.ts b/src/db/queries/leaderboard.ts index d5f4c9f..912c165 100644 --- a/src/db/queries/leaderboard.ts +++ b/src/db/queries/leaderboard.ts @@ -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"; @@ -7,7 +14,7 @@ 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 @@ -15,12 +22,21 @@ import { }; 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 { @@ -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`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`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`SUM(${sql.ref('points.wins')})`.as("total_wins"), + sql`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`(${eb.ref("points.wins")} + 0.0) / (GREATEST(${eb.ref( - "points.wins" - )} + ${eb.ref("points.losses")}, 1.0) + 0.0)`.as("winrate"), + sql`(${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`(${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`ROW_NUMBER() OVER(ORDER BY ${eb.ref("points.wins")} DESC)`.as("position") @@ -98,6 +166,7 @@ export async function fetchLeaderboardRaw( // .as("position") ]); + // Search filter const searchFor = options.searchFor; if (searchFor !== undefined) { try { @@ -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}%`}`); } } @@ -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(), @@ -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(), @@ -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 @@ -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, });