mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
skillsToLeaderboard logic
This commit is contained in:
126
app/core/mmr/leaderboards.test.ts
Normal file
126
app/core/mmr/leaderboards.test.ts
Normal file
@@ -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();
|
||||
64
app/core/mmr/leaderboards.ts
Normal file
64
app/core/mmr/leaderboards.ts
Normal file
@@ -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<Skill, "mu" | "sigma" | "userId"> & {
|
||||
match: { createdAt: Date } | null;
|
||||
user: UserLean;
|
||||
};
|
||||
type UserId = string;
|
||||
|
||||
export function skillsToLeaderboard(skills: SkillInput[]): LeaderboardEntry[] {
|
||||
const counts: Record<UserId, number> = {};
|
||||
const peakMMR: Record<UserId, LeaderboardEntry> = {};
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -12,3 +12,7 @@ export function findAllMostRecent() {
|
||||
distinct: "userId",
|
||||
});
|
||||
}
|
||||
|
||||
export function findAll() {
|
||||
return db.skill.findMany({ include: { match: true, user: true } });
|
||||
}
|
||||
|
||||
29
app/routes/leaderboards.tsx
Normal file
29
app/routes/leaderboards.tsx
Normal file
@@ -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<LookingLoaderDataGroup>({ players: skillsToLeaderboard(skills) });
|
||||
};
|
||||
|
||||
export default function LeaderboardsPage() {
|
||||
const data = useLoaderData<LookingLoaderDataGroup>();
|
||||
|
||||
console.log({ data });
|
||||
|
||||
return <div>boards lol</div>;
|
||||
}
|
||||
@@ -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",
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user