diff --git a/app/core/mmr/leaderboards.test.ts b/app/core/mmr/leaderboards.test.ts new file mode 100644 index 000000000..620512edc --- /dev/null +++ b/app/core/mmr/leaderboards.test.ts @@ -0,0 +1,126 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { UserLean } from "~/utils"; +import { skillsToLeaderboard } from "./leaderboards"; +import { muSigmaToSP } from "./utils"; + +const SkillsToLeaderboard = suite("skillsToLeaderboard()"); + +const USER: UserLean = { + discordAvatar: "", + discordDiscriminator: "1234", + discordId: "12341234", + discordName: "Sendou", + id: "1", +}; + +const USER_2: UserLean = { + discordAvatar: "", + discordDiscriminator: "12342", + discordId: "123412342", + discordName: "Sendou2", + id: "2", +}; + +SkillsToLeaderboard("Works with empty array", () => { + const players = skillsToLeaderboard([]); + + assert.not.ok(players.length); +}); + +SkillsToLeaderboard("Ignores skills if below amount required", () => { + const players = skillsToLeaderboard([ + { + match: { createdAt: new Date() }, + mu: 10, + sigma: 2, + userId: "1", + user: USER, + }, + ]); + + assert.not.ok(players.length); +}); + +SkillsToLeaderboard("Gets peak", () => { + const players = skillsToLeaderboard( + new Array(10).fill(null).map((_) => ({ + match: { createdAt: new Date() }, + mu: 10, + sigma: 2, + userId: "1", + user: USER, + })) + ); + + assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 })); +}); + +SkillsToLeaderboard("Ignores peaks at the start", () => { + const players = skillsToLeaderboard( + new Array(10).fill(null).map((_, i) => ({ + match: { createdAt: new Date() }, + mu: i === 0 ? 30 : 10, + sigma: 2, + userId: "1", + user: USER, + })) + ); + + assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 10 })); +}); + +SkillsToLeaderboard("Gets peak from in between", () => { + const players = skillsToLeaderboard( + new Array(10).fill(null).map((_, i) => ({ + match: { createdAt: new Date() }, + mu: i === 8 ? 30 : 10, + sigma: 2, + userId: "1", + user: USER, + })) + ); + + assert.equal(players[0].MMR, muSigmaToSP({ sigma: 2, mu: 30 })); +}); + +SkillsToLeaderboard("Calculates entries", () => { + const players = skillsToLeaderboard( + new Array(10).fill(null).map((_, i) => ({ + match: { createdAt: new Date() }, + mu: i === 8 ? 30 : 10, + sigma: 2, + userId: "1", + user: USER, + })) + ); + + assert.equal(players[0].entries, 10); +}); + +SkillsToLeaderboard("Orders by MMR", () => { + const players = skillsToLeaderboard( + new Array(10) + .fill(null) + .map((_, i) => ({ + match: { createdAt: new Date() }, + mu: i === 8 ? 30 : 10, + sigma: 2, + userId: "1", + user: USER, + })) + .concat( + new Array(10).fill(null).map((_) => ({ + match: { createdAt: new Date() }, + mu: 40, + sigma: 2, + userId: "2", + user: USER_2, + })) + ) + ); + + assert.equal(players[0].user.id, USER_2.id); +}); + +SkillsToLeaderboard.run(); diff --git a/app/core/mmr/leaderboards.ts b/app/core/mmr/leaderboards.ts new file mode 100644 index 000000000..21311cfab --- /dev/null +++ b/app/core/mmr/leaderboards.ts @@ -0,0 +1,64 @@ +import { Skill } from "@prisma/client"; +import { AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD } from "~/constants"; +import { UserLean } from "~/utils"; +import { muSigmaToSP } from "./utils"; + +export interface LeaderboardEntry { + MMR: number; + user: UserLean; + createdAtTimestamp: number; + entries: number; +} +type SkillInput = Pick & { + match: { createdAt: Date } | null; + user: UserLean; +}; +type UserId = string; + +export function skillsToLeaderboard(skills: SkillInput[]): LeaderboardEntry[] { + const counts: Record = {}; + const peakMMR: Record = {}; + + for (const skill of skills.sort(sortSkillsByCreatedAt)) { + if (!counts[skill.userId]) { + counts[skill.userId] = 1; + } else { + counts[skill.userId]++; + } + + if (counts[skill.userId] < AMOUNT_OF_ENTRIES_REQUIRED_FOR_LEADERBOARD) { + continue; + } + + const MMR = muSigmaToSP(skill); + + if (!peakMMR[skill.userId] || peakMMR[skill.userId].MMR < MMR) { + peakMMR[skill.userId] = { + MMR, + createdAtTimestamp: skill.match?.createdAt.getTime() ?? 0, + user: { + discordAvatar: skill.user.discordAvatar, + discordDiscriminator: skill.user.discordDiscriminator, + discordId: skill.user.discordId, + discordName: skill.user.discordName, + id: skill.user.id, + }, + // we set this below + entries: 0, + }; + } + } + + for (const [userId, count] of Object.entries(counts)) { + if (!peakMMR[userId]) continue; + peakMMR[userId].entries = count; + } + + return Object.values(peakMMR).sort((a, b) => b.MMR - a.MMR); +} + +function sortSkillsByCreatedAt(a: SkillInput, b: SkillInput) { + return ( + (a.match?.createdAt.getTime() ?? 0) - (b.match?.createdAt.getTime() ?? 0) + ); +} diff --git a/app/models/Skill.server.ts b/app/models/Skill.server.ts index 017d16547..58d96dd38 100644 --- a/app/models/Skill.server.ts +++ b/app/models/Skill.server.ts @@ -12,3 +12,7 @@ export function findAllMostRecent() { distinct: "userId", }); } + +export function findAll() { + return db.skill.findMany({ include: { match: true, user: true } }); +} diff --git a/app/routes/leaderboards.tsx b/app/routes/leaderboards.tsx new file mode 100644 index 000000000..6d83aeb63 --- /dev/null +++ b/app/routes/leaderboards.tsx @@ -0,0 +1,29 @@ +import { json, LoaderFunction, MetaFunction, useLoaderData } from "remix"; +import { makeTitle } from "~/utils"; +import * as Skill from "~/models/Skill.server"; +import { LeaderboardEntry, skillsToLeaderboard } from "~/core/mmr/leaderboards"; + +export const meta: MetaFunction = () => { + return { + title: makeTitle("Leaderboards"), + }; +}; + +export type LookingLoaderDataGroup = { + players: LeaderboardEntry[]; +}; + +export const loader: LoaderFunction = async () => { + // TODO: only get skills of March + const skills = await Skill.findAll(); + + return json({ players: skillsToLeaderboard(skills) }); +}; + +export default function LeaderboardsPage() { + const data = useLoaderData(); + + console.log({ data }); + + return
boards lol
; +} diff --git a/app/routes/play/looking.tsx b/app/routes/play/looking.tsx index 9191f8c02..01f607caa 100644 --- a/app/routes/play/looking.tsx +++ b/app/routes/play/looking.tsx @@ -53,7 +53,7 @@ export const meta: MetaFunction = ({ return { title: makeTitle([ data - ? `(${data.likedGroups.length}/${data.neutralGroups.length}/${data.likerGroups.length})` + ? `(${data.likedGroups?.length}/${data.neutralGroups?.length}/${data.likerGroups?.length})` : "(???)", "Looking", ]),