Files
erika/lib/leaderboard/ranking.ts
T

216 lines
5.8 KiB
TypeScript

export const LEADERBOARD_PAGE_SIZE = 10;
export type LeaderboardIdentity = {
userId: string;
displayName: string | null;
username: string | null;
};
export type RankedLeaderboardRow = LeaderboardIdentity & {
rank: number;
score: string;
};
export type LeaderboardPage<T extends RankedLeaderboardRow> = {
rows: T[];
podium: T[];
page: number;
pageSize: number;
totalRows: number;
totalPages: number;
};
export type XpSourceRow = LeaderboardIdentity & {
level: number;
totalXp: string;
};
export type XpLeaderboardRow = RankedLeaderboardRow & {
level: number;
totalXp: string;
};
export type VoiceSourceRow = LeaderboardIdentity & {
channelId: string;
totalSeconds: string;
};
export type VoiceLeaderboardRow = RankedLeaderboardRow & {
totalSeconds: string;
};
const nameCollator = new Intl.Collator("th", {
numeric: true,
sensitivity: "base",
});
function asBigInt(value: string) {
try {
return BigInt(value);
} catch {
return BigInt(0);
}
}
export function resolveLeaderboardName(identity: LeaderboardIdentity) {
return identity.displayName?.trim()
|| identity.username?.trim()
|| "ไม่พบชื่อผู้ใช้";
}
function compareIdentities(a: LeaderboardIdentity, b: LeaderboardIdentity) {
const byName = nameCollator.compare(
resolveLeaderboardName(a),
resolveLeaderboardName(b),
);
return byName || a.userId.localeCompare(b.userId);
}
function normalizeSearchQuery(value: string | undefined) {
return value?.trim().toLocaleLowerCase("th-TH") ?? "";
}
function matchesSearch(identity: LeaderboardIdentity, searchQuery: string) {
if (!searchQuery) return true;
return [
identity.displayName,
identity.username,
identity.userId,
].some((value) => value?.toLocaleLowerCase("th-TH").includes(searchQuery));
}
function rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
sourceRows: T[],
requestedPage: number,
searchQuery?: string,
): LeaderboardPage<T & { rank: number }> {
const sorted = [...sourceRows].sort((a, b) => {
const scoreA = asBigInt(a.score);
const scoreB = asBigInt(b.score);
if (scoreA !== scoreB) return scoreA > scoreB ? -1 : 1;
return compareIdentities(a, b);
});
let previousScore: string | undefined;
let sharedRank = 0;
const ranked = sorted.map((row, index) => {
if (previousScore === undefined || asBigInt(row.score) !== asBigInt(previousScore)) {
sharedRank = index + 1;
previousScore = row.score;
}
return { ...row, rank: sharedRank };
});
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
const filtered = normalizedSearchQuery
? ranked.filter((row) => matchesSearch(row, normalizedSearchQuery))
: ranked;
const totalRows = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalRows / LEADERBOARD_PAGE_SIZE));
const page = Math.min(Math.max(1, requestedPage), totalPages);
const start = (page - 1) * LEADERBOARD_PAGE_SIZE;
return {
rows: filtered.slice(start, start + LEADERBOARD_PAGE_SIZE),
podium: filtered.slice(0, 3),
page,
pageSize: LEADERBOARD_PAGE_SIZE,
totalRows,
totalPages,
};
}
export function buildXpLeaderboard(
rows: XpSourceRow[],
requestedPage: number,
searchQuery?: string,
): LeaderboardPage<XpLeaderboardRow> {
return rankAndPaginate(
rows
.filter((row) => row.level >= 1)
.map((row) => ({
...row,
totalXp: row.totalXp,
score: row.totalXp,
})),
requestedPage,
searchQuery,
);
}
export function buildVoiceLeaderboard(
rows: VoiceSourceRow[],
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
searchQuery?: string,
): LeaderboardPage<VoiceLeaderboardRow> {
const allowedChannels = new Set(
selectedChannelId ? [selectedChannelId] : currentChannelIds,
);
const totals = new Map<
string,
LeaderboardIdentity & { totalSeconds: bigint }
>();
for (const row of rows) {
if (!allowedChannels.has(row.channelId)) continue;
const existing = totals.get(row.userId);
const totalSeconds = (existing?.totalSeconds ?? BigInt(0))
+ asBigInt(row.totalSeconds);
totals.set(row.userId, {
userId: row.userId,
displayName: existing?.displayName || row.displayName,
username: existing?.username || row.username,
totalSeconds,
});
}
return rankAndPaginate(
Array.from(totals.values(), (row) => ({
...row,
totalSeconds: row.totalSeconds.toString(),
score: row.totalSeconds.toString(),
})),
requestedPage,
searchQuery,
);
}
export function normalizeRequestedPage(value: string | string[] | undefined) {
if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) return 1;
const page = Number(value);
return Number.isSafeInteger(page) ? page : 1;
}
export function normalizeLeaderboardSearch(value: string | string[] | undefined) {
if (typeof value !== "string") return "";
return value.trim().slice(0, 80);
}
export function shortenDiscordId(userId: string) {
return userId.length > 10
? `${userId.slice(0, 5)}…${userId.slice(-4)}`
: userId;
}
export function formatInteger(value: string) {
return asBigInt(value).toLocaleString("th-TH");
}
export function formatVoiceDuration(value: string) {
const totalSeconds = asBigInt(value);
const days = totalSeconds / BigInt(86_400);
const hours = (totalSeconds % BigInt(86_400)) / BigInt(3_600);
const minutes = (totalSeconds % BigInt(3_600)) / BigInt(60);
const parts = [
days > BigInt(0) ? `${formatInteger(days.toString())} วัน` : null,
hours > BigInt(0) ? `${formatInteger(hours.toString())} ชม.` : null,
minutes > BigInt(0) ? `${formatInteger(minutes.toString())} นาที` : null,
].filter((part): part is string => Boolean(part));
return parts.join(" ") || "น้อยกว่า 1 นาที";
}