feat: add realtime Discord leaderboards
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import "server-only";
|
||||
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
buildVoiceLeaderboard,
|
||||
buildXpLeaderboard,
|
||||
type VoiceSourceRow,
|
||||
} from "@/lib/leaderboard/ranking";
|
||||
|
||||
const userLevels = pgTable("user_levels", {
|
||||
userId: text("user_id").notNull(),
|
||||
username: text("username").notNull(),
|
||||
displayName: text("display_name"),
|
||||
xp: numeric("xp", { precision: 40, scale: 0 }).notNull(),
|
||||
level: integer("level").notNull(),
|
||||
});
|
||||
|
||||
const voiceTime = pgTable("voice_time", {
|
||||
userId: text("user_id").notNull(),
|
||||
channelId: text("channel_id").notNull(),
|
||||
totalSeconds: numeric("total_seconds", { precision: 40, scale: 0 }).notNull(),
|
||||
});
|
||||
|
||||
type LeaderboardClient = ReturnType<typeof postgres>;
|
||||
|
||||
const globalForLeaderboard = globalThis as typeof globalThis & {
|
||||
erikaLeaderboardReadOnlyClient?: LeaderboardClient;
|
||||
};
|
||||
|
||||
function getLeaderboardDatabase() {
|
||||
const connectionString = process.env.LEADERBOARD_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("LEADERBOARD_DATABASE_URL is not configured.");
|
||||
}
|
||||
|
||||
if (!globalForLeaderboard.erikaLeaderboardReadOnlyClient) {
|
||||
globalForLeaderboard.erikaLeaderboardReadOnlyClient = postgres(connectionString, {
|
||||
max: 3,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 5,
|
||||
connection: {
|
||||
application_name: "erika-leaderboard",
|
||||
default_transaction_read_only: true,
|
||||
statement_timeout: 15_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
|
||||
}
|
||||
|
||||
export async function getXpLeaderboardPage(requestedPage: number) {
|
||||
const rows = await getLeaderboardDatabase()
|
||||
.select({
|
||||
userId: userLevels.userId,
|
||||
username: userLevels.username,
|
||||
displayName: userLevels.displayName,
|
||||
level: userLevels.level,
|
||||
totalXp: userLevels.xp,
|
||||
})
|
||||
.from(userLevels)
|
||||
.where(gte(userLevels.level, 1));
|
||||
|
||||
return buildXpLeaderboard(rows, requestedPage);
|
||||
}
|
||||
|
||||
export async function getVoiceLeaderboardPage(
|
||||
currentChannelIds: string[],
|
||||
requestedPage: number,
|
||||
selectedChannelId?: string,
|
||||
) {
|
||||
const scopedChannelIds = selectedChannelId
|
||||
? [selectedChannelId]
|
||||
: currentChannelIds;
|
||||
|
||||
if (scopedChannelIds.length === 0) {
|
||||
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
|
||||
}
|
||||
|
||||
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
|
||||
.select({
|
||||
userId: voiceTime.userId,
|
||||
channelId: voiceTime.channelId,
|
||||
totalSeconds: voiceTime.totalSeconds,
|
||||
username: userLevels.username,
|
||||
displayName: userLevels.displayName,
|
||||
})
|
||||
.from(voiceTime)
|
||||
.leftJoin(userLevels, eq(voiceTime.userId, userLevels.userId))
|
||||
.where(
|
||||
and(
|
||||
inArray(voiceTime.channelId, scopedChannelIds),
|
||||
gte(voiceTime.totalSeconds, "0"),
|
||||
),
|
||||
);
|
||||
|
||||
return buildVoiceLeaderboard(
|
||||
rows,
|
||||
currentChannelIds,
|
||||
requestedPage,
|
||||
selectedChannelId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLeaderboardDatabaseReadOnlyStatus() {
|
||||
const result = await getLeaderboardDatabase().execute<{
|
||||
transaction_read_only: string;
|
||||
}>(sql`show transaction_read_only`);
|
||||
return result[0]?.transaction_read_only ?? null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCurrentVoiceChannels } from "@/lib/leaderboard/discord";
|
||||
|
||||
describe("Discord current voice channels", () => {
|
||||
test("keeps voice and Stage channels in stable position order", () => {
|
||||
expect(parseCurrentVoiceChannels([
|
||||
{ id: "text", name: "general", type: 0, position: 0 },
|
||||
{ id: "stage", name: "Stage", type: 13, position: 3 },
|
||||
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
|
||||
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
|
||||
])).toEqual([
|
||||
{ id: "voice-a", name: "Alpha", type: 2, position: 2 },
|
||||
{ id: "voice-b", name: "Beta", type: 2, position: 2 },
|
||||
{ id: "stage", name: "Stage", type: 13, position: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects malformed top-level data", () => {
|
||||
expect(() => parseCurrentVoiceChannels({ channels: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type DiscordVoiceChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 2 | 13;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type DiscordChannelPayload = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const channelCache: {
|
||||
data: DiscordVoiceChannel[] | null;
|
||||
expiresAt: number;
|
||||
} = {
|
||||
data: null,
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
export function parseCurrentVoiceChannels(payload: unknown): DiscordVoiceChannel[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error("Discord returned an invalid channel list.");
|
||||
}
|
||||
|
||||
return payload
|
||||
.filter((channel): channel is DiscordChannelPayload => {
|
||||
if (!channel || typeof channel !== "object") return false;
|
||||
const type = (channel as DiscordChannelPayload).type;
|
||||
return type === 2 || type === 13;
|
||||
})
|
||||
.filter((channel) => (
|
||||
typeof channel.id === "string"
|
||||
&& typeof channel.name === "string"
|
||||
))
|
||||
.map((channel) => ({
|
||||
id: channel.id as string,
|
||||
name: channel.name as string,
|
||||
type: channel.type as 2 | 13,
|
||||
position: typeof channel.position === "number" ? channel.position : 0,
|
||||
}))
|
||||
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, "th"));
|
||||
}
|
||||
|
||||
export async function getCurrentDiscordVoiceChannels() {
|
||||
if (channelCache.data && Date.now() < channelCache.expiresAt) {
|
||||
return channelCache.data;
|
||||
}
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId) {
|
||||
throw new Error("Discord channel access is not configured.");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://discord.com/api/v10/guilds/${guildId}/channels`,
|
||||
{
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord channel request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const channels = parseCurrentVoiceChannels(await response.json());
|
||||
channelCache.data = channels;
|
||||
channelCache.expiresAt = Date.now() + CHANNEL_CACHE_TTL_MS;
|
||||
return channels;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildVoiceLeaderboard,
|
||||
buildXpLeaderboard,
|
||||
formatInteger,
|
||||
formatVoiceDuration,
|
||||
normalizeRequestedPage,
|
||||
shortenDiscordId,
|
||||
type VoiceSourceRow,
|
||||
type XpSourceRow,
|
||||
} from "@/lib/leaderboard/ranking";
|
||||
|
||||
function xpRow(
|
||||
userId: string,
|
||||
totalXp: string,
|
||||
level = 1,
|
||||
displayName = userId,
|
||||
): XpSourceRow {
|
||||
return {
|
||||
userId,
|
||||
username: `${userId}-username`,
|
||||
displayName,
|
||||
level,
|
||||
totalXp,
|
||||
};
|
||||
}
|
||||
|
||||
function voiceRow(
|
||||
userId: string,
|
||||
channelId: string,
|
||||
totalSeconds: string,
|
||||
displayName: string | null = userId,
|
||||
): VoiceSourceRow {
|
||||
return {
|
||||
userId,
|
||||
channelId,
|
||||
totalSeconds,
|
||||
displayName,
|
||||
username: displayName ? `${userId}-username` : null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("XP leaderboard", () => {
|
||||
test("filters level zero and assigns shared competition ranks", () => {
|
||||
const result = buildXpLeaderboard([
|
||||
xpRow("b", "900", 2, "Beta"),
|
||||
xpRow("a", "1000", 3, "Alpha"),
|
||||
xpRow("c", "900", 2, "Charlie"),
|
||||
xpRow("hidden", "9999", 0),
|
||||
xpRow("d", "800", 1, "Delta"),
|
||||
], 1);
|
||||
|
||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
["c", 2],
|
||||
["d", 4],
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns ten rows and normalizes pages beyond the result", () => {
|
||||
const rows = Array.from({ length: 101 }, (_, index) =>
|
||||
xpRow(String(index).padStart(3, "0"), String(1_000 - index)));
|
||||
|
||||
const middle = buildXpLeaderboard(rows, 2);
|
||||
expect(middle.rows).toHaveLength(10);
|
||||
expect(middle.rows[0]?.rank).toBe(11);
|
||||
expect(middle.totalPages).toBe(11);
|
||||
|
||||
const last = buildXpLeaderboard(rows, 99);
|
||||
expect(last.page).toBe(11);
|
||||
expect(last.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("uses stable name then user ID ordering for ties", () => {
|
||||
const result = buildXpLeaderboard([
|
||||
xpRow("3", "100", 1, "Zulu"),
|
||||
xpRow("2", "100", 1, "Alpha"),
|
||||
xpRow("1", "100", 1, "Alpha"),
|
||||
], 1);
|
||||
|
||||
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice leaderboard", () => {
|
||||
const rows = [
|
||||
voiceRow("a", "voice-1", "100"),
|
||||
voiceRow("a", "voice-2", "200"),
|
||||
voiceRow("a", "deleted-channel", "9_999"),
|
||||
voiceRow("b", "voice-1", "300"),
|
||||
voiceRow("missing", "voice-2", "60", null),
|
||||
];
|
||||
|
||||
test("aggregates current channels and excludes historical channels", () => {
|
||||
const result = buildVoiceLeaderboard(rows, ["voice-1", "voice-2"], 1);
|
||||
|
||||
expect(result.rows.map(({ userId, totalSeconds, rank }) => ({
|
||||
userId,
|
||||
totalSeconds,
|
||||
rank,
|
||||
}))).toEqual([
|
||||
{ userId: "a", totalSeconds: "300", rank: 1 },
|
||||
{ userId: "b", totalSeconds: "300", rank: 1 },
|
||||
{ userId: "missing", totalSeconds: "60", rank: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters to a selected current channel", () => {
|
||||
const result = buildVoiceLeaderboard(
|
||||
rows,
|
||||
["voice-1", "voice-2"],
|
||||
1,
|
||||
"voice-1",
|
||||
);
|
||||
|
||||
expect(result.rows.map(({ userId, totalSeconds }) => [
|
||||
userId,
|
||||
totalSeconds,
|
||||
])).toEqual([
|
||||
["b", "300"],
|
||||
["a", "100"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps unresolved identities and formats their shortened ID", () => {
|
||||
const result = buildVoiceLeaderboard(rows, ["voice-2"], 1);
|
||||
const unresolved = result.rows.find((row) => row.userId === "missing");
|
||||
|
||||
expect(unresolved?.displayName).toBeNull();
|
||||
expect(unresolved?.username).toBeNull();
|
||||
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaderboard formatting", () => {
|
||||
test("normalizes invalid page values", () => {
|
||||
expect(normalizeRequestedPage(undefined)).toBe(1);
|
||||
expect(normalizeRequestedPage("0")).toBe(1);
|
||||
expect(normalizeRequestedPage("2.5")).toBe(1);
|
||||
expect(normalizeRequestedPage(["2", "3"])).toBe(1);
|
||||
expect(normalizeRequestedPage("3")).toBe(3);
|
||||
});
|
||||
|
||||
test("formats large integer and voice duration values", () => {
|
||||
expect(formatInteger("1234567")).toBe("1,234,567");
|
||||
expect(formatVoiceDuration("90061")).toBe("1 วัน 1 ชม. 1 นาที");
|
||||
expect(formatVoiceDuration("30")).toBe("น้อยกว่า 1 นาที");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
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 rankAndPaginate<T extends LeaderboardIdentity & { score: string }>(
|
||||
sourceRows: T[],
|
||||
requestedPage: number,
|
||||
): 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 totalRows = ranked.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: ranked.slice(start, start + LEADERBOARD_PAGE_SIZE),
|
||||
podium: ranked.slice(0, 3),
|
||||
page,
|
||||
pageSize: LEADERBOARD_PAGE_SIZE,
|
||||
totalRows,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXpLeaderboard(
|
||||
rows: XpSourceRow[],
|
||||
requestedPage: number,
|
||||
): LeaderboardPage<XpLeaderboardRow> {
|
||||
return rankAndPaginate(
|
||||
rows
|
||||
.filter((row) => row.level >= 1)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
totalXp: row.totalXp,
|
||||
score: row.totalXp,
|
||||
})),
|
||||
requestedPage,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildVoiceLeaderboard(
|
||||
rows: VoiceSourceRow[],
|
||||
currentChannelIds: string[],
|
||||
requestedPage: number,
|
||||
selectedChannelId?: 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,
|
||||
);
|
||||
}
|
||||
|
||||
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 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 นาที";
|
||||
}
|
||||
Reference in New Issue
Block a user