This commit is contained in:
2025-04-03 21:25:23 +02:00
parent 88c298787a
commit 9163c453af
13 changed files with 721 additions and 282 deletions

View File

@@ -1,6 +1,8 @@
import { createTRPCRouter, publicProcedure } from '@/server/api/trpc'
import { LeaderboardService } from '@/server/services/leaderboard'
import { neatqueue_service } from '@/server/services/neatqueue.service'
import { z } from 'zod'
const service = new LeaderboardService()
export const leaderboard_router = createTRPCRouter({
get_leaderboard: publicProcedure
@@ -10,6 +12,16 @@ export const leaderboard_router = createTRPCRouter({
})
)
.query(async ({ input }) => {
return await neatqueue_service.get_leaderboard(input.channel_id)
return await service.getLeaderboard(input.channel_id)
}),
get_user_rank: publicProcedure
.input(
z.object({
channel_id: z.string(),
user_id: z.string(),
})
)
.query(async ({ input }) => {
return await service.getUserRank(input.channel_id, input.user_id)
}),
})

4
src/server/redis.ts Normal file
View File

@@ -0,0 +1,4 @@
import { env } from '@/env'
import { Redis } from 'ioredis'
export const redis = new Redis(env.REDIS_URL)

View File

@@ -0,0 +1,93 @@
import { redis } from '../redis'
import { neatqueue_service } from './neatqueue.service'
export class LeaderboardService {
private getZSetKey(channel_id: string) {
return `zset:leaderboard:${channel_id}`
}
private getRawKey(channel_id: string) {
return `raw:leaderboard:${channel_id}`
}
async refreshLeaderboard(channel_id: string) {
const fresh = await neatqueue_service.get_leaderboard(channel_id)
const zsetKey = this.getZSetKey(channel_id)
const rawKey = this.getRawKey(channel_id)
// store raw data for full queries
await redis.setex(rawKey, 180, JSON.stringify(fresh))
// store sorted set for rank queries
const pipeline = redis.pipeline()
pipeline.del(zsetKey) // clear existing
for (const entry of fresh.alltime) {
// store by mmr for ranking
pipeline.zadd(zsetKey, entry.data.mmr, entry.id)
// store user data separately for quick lookups
pipeline.hset(`user:${entry.id}`, {
name: entry.name,
mmr: entry.data.mmr,
wins: entry.data.wins,
losses: entry.data.losses,
// add other fields you need for quick lookup
})
}
pipeline.expire(zsetKey, 180)
await pipeline.exec()
}
async getLeaderboard(channel_id: string) {
const cached = await redis.get(this.getRawKey(channel_id))
if (cached) return JSON.parse(cached)
// if not cached, refresh and return
await this.refreshLeaderboard(channel_id)
// @ts-ignore
return redis.get(this.getRawKey(channel_id)).then(JSON.parse)
}
async getUserRank(channel_id: string, user_id: string) {
const zsetKey = this.getZSetKey(channel_id)
// zrevrank because higher mmr = better rank
const rank = await redis.zrevrank(zsetKey, user_id)
if (rank === null) return null
// get user data
const userData = await redis.hgetall(`user:${user_id}`)
if (!userData) return null
return {
rank: rank + 1, // zero-based -> one-based
...userData,
}
}
// get users around a specific rank
async getRankRange(channel_id: string, rank: number, range = 5) {
const zsetKey = this.getZSetKey(channel_id)
// get ids
const ids = await redis.zrevrange(
zsetKey,
Math.max(0, rank - range),
rank + range
)
// get data for each id
const pipeline = redis.pipeline()
// biome-ignore lint/complexity/noForEach: <explanation>
ids.forEach((id) => pipeline.hgetall(`user:${id}`))
const results = await pipeline.exec()
return ids.map((id, i) => ({
id,
rank: rank - range + i + 1,
// @ts-ignore
...results[i][1],
}))
}
}

View File

@@ -4,10 +4,10 @@ const NEATQUEUE_URL = 'https://api.neatqueue.com/api'
const instance = ky.create({
prefixUrl: NEATQUEUE_URL,
timeout: 10000,
timeout: 60000,
})
const BMM_SERVER_ID = '1352157545547960350'
const BMM_SERVER_ID = '1226193436521267223'
export const neatqueue_service = {
get_leaderboard: async (channel_id: string) => {
@@ -15,7 +15,7 @@ export const neatqueue_service = {
`leaderboard/${BMM_SERVER_ID}/${channel_id}`
)
return response.json()
return response.json<LeaderboardResponse>()
},
get_history: async (
player_ids: string[],
@@ -31,3 +31,25 @@ export const neatqueue_service = {
return response
},
}
export type Data = {
mmr: number
wins: number
losses: number
streak: number
totalgames: number
decay: number
ign?: any
peak_mmr: number
peak_streak: number
rank: number
winrate: number
}
export type LeaderboardEntry = {
id: string
data: Data
name: string
}
export type LeaderboardResponse = {
alltime: LeaderboardEntry[]
}