import { logger } from "firebase-functions"; import { firestore } from "../firebase"; import { countRankedUsers, countUsersAboveTierPoints, getUser, listAllRankedUsers, listTopByTierPoints, type ScoreboardUserEntry, } from "../repositories/userRepository"; import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository"; import { tierOf } from "../constants/tiers"; import { computePercentile, deltaFor } from "./scoreboardHelpers"; import type { DateString } from "../types/dateString"; import { TeamCode, type RankSnapshot, type User } from "../types/panit"; import type { ScoreboardEntry, ScoreboardScopeCache, } from "../types/scoreboard"; /** * run 스코프 순위표 — `tierPoints`만으로 순위를 이진 탐색으로 구한다. * * `dailyArchive`처럼 유저 N명을 한 run에서 처리하는 경로에서, 유저마다 1~2회씩 * count aggregation을 발행하던 O(N×M) 비용을 순위표 1회 로드로 대체한다. */ export interface RankStandings { /** 동점자 동일 순위("초과 인원 + 1"). `teamCode` 지정 시 팀 내 순위. */ rankOf(tierPoints: number, teamCode?: TeamCode): number; } /** 내림차순 배열에서 `threshold` 초과 원소 개수(= 첫 `<= threshold` 위치). */ function countAbove(sortedDesc: number[], threshold: number): number { let lo = 0; let hi = sortedDesc.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (sortedDesc[mid] > threshold) lo = mid + 1; else hi = mid; } return lo; } /** 랭킹 대상 전원 목록으로 순위표를 만든다. */ export function buildRankStandings( users: Array<{ tierPoints: number; favoriteTeamCode?: TeamCode }> ): RankStandings { const overall = users.map((u) => u.tierPoints).sort((a, b) => b - a); const byTeam = new Map(); for (const u of users) { if (!u.favoriteTeamCode) continue; const list = byTeam.get(u.favoriteTeamCode); if (list) list.push(u.tierPoints); else byTeam.set(u.favoriteTeamCode, [u.tierPoints]); } for (const list of byTeam.values()) list.sort((a, b) => b - a); return { rankOf(tierPoints: number, teamCode?: TeamCode): number { const list = teamCode ? byTeam.get(teamCode) ?? [] : overall; return countAbove(list, tierPoints) + 1; }, }; } /** 랭킹 대상 전원을 읽어 순위표를 만든다. run 시작 시 1회만 호출할 것. */ export async function loadRankStandings(): Promise { return buildRankStandings(await listAllRankedUsers()); } /** * 유저 1명의 현재 `tierPoints` 기반 rank를 계산한다. `dailyArchive`에서 * `judgeDay` **이전에** 호출해 스냅샷이 "직전 상태의 rank"를 담도록 한다. * * `tierPoints === 0`이면 스냅샷을 남기지 않는다. * * @param opts.user - 호출자가 이미 읽은 user 문서. 넘기면 재조회하지 않는다. * @param opts.standings - run 스코프 순위표. 넘기면 count aggregation을 쓰지 않는다. */ export async function computeRankSnapshot( uid: string, date: DateString, opts?: { user?: User | null; standings?: RankStandings } ): Promise { const user = opts && "user" in opts ? opts.user : await getUser(uid); if (!user) return null; const tierPoints = user.tierPoints ?? 0; if (tierPoints <= 0) return null; const standings = opts?.standings; const snapshot: RankSnapshot = { date, overall: standings ? standings.rankOf(tierPoints) : (await countUsersAboveTierPoints(tierPoints)) + 1, }; if (user.favoriteTeamCode) { snapshot.team = standings ? standings.rankOf(tierPoints, user.favoriteTeamCode) : (await countUsersAboveTierPoints(tierPoints, user.favoriteTeamCode)) + 1; snapshot.teamCode = user.favoriteTeamCode; } return snapshot; } export async function snapshotRankForUser( uid: string, date: DateString ): Promise { const snapshot = await computeRankSnapshot(uid, date); if (!snapshot) return; await firestore .collection("users") .doc(uid) .set({ rankSnapshot: snapshot }, { merge: true }); } /** * 여러 유저의 rank 스냅샷을 순차 기록. 한 명 실패해도 나머지는 계속. */ export async function snapshotRanksForUsers( uids: string[], date: DateString ): Promise { for (const uid of uids) { try { await snapshotRankForUser(uid, date); } catch (err) { logger.error(`snapshotRank failed for uid=${uid}`, err); } } } /** 동점자 동일 rank ("above count + 1"). 상위가 1위임을 가정한 in-memory 계산. */ function assignRanks(entries: ScoreboardUserEntry[]): number[] { const ranks: number[] = []; for (let i = 0; i < entries.length; i++) { if (i === 0) { ranks.push(1); continue; } if (entries[i].tierPoints === entries[i - 1].tierPoints) { ranks.push(ranks[i - 1]); } else { ranks.push(i + 1); } } return ranks; } function enrichTop( entries: ScoreboardUserEntry[], totalCount: number, scope: Scope ): ScoreboardEntry[] { const ranks = assignRanks(entries); return entries.map((e, i) => { const rank = ranks[i]; const entry: ScoreboardEntry = { uid: e.uid, displayName: e.displayName, tierPoints: e.tierPoints, tier: tierOf(e.tierPoints), rank, rankDelta: deltaFor(e.rankSnapshot, rank, scope), percentile: computePercentile(rank, totalCount), }; if (e.photoUrl) entry.photoUrl = e.photoUrl; if (e.favoriteTeamCode) entry.favoriteTeamCode = e.favoriteTeamCode; return entry; }); } async function buildScopeCache(scope: Scope): Promise { const teamCode = scope.kind === "team" ? scope.teamCode : undefined; const [top, totalCount] = await Promise.all([ listTopByTierPoints(10, teamCode), countRankedUsers(teamCode), ]); return { top: enrichTop(top, totalCount, scope), totalCount, generatedAt: Date.now(), }; } /** overall + 10팀 = 전체 11개 스코프. */ export function allScoreboardScopes(): Scope[] { return [ { kind: "overall" }, ...Object.values(TeamCode).map( (teamCode) => ({ kind: "team" as const, teamCode }) ), ]; } /** * overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다. * `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함. * * @param scopes 재계산할 스코프. 한 유저의 변경처럼 영향 범위가 좁을 땐 해당 * 스코프만 넘겨 11개 전체 재계산(~110 read + 11 aggregation)을 피한다. * 생략 시 전체(크론 경로). */ export async function precomputeScoreboardCache( date: DateString, scopes: Scope[] = allScoreboardScopes() ): Promise { for (const scope of scopes) { try { const doc = await buildScopeCache(scope); await writeScope(date, scope, doc); } catch (err) { const label = scope.kind === "overall" ? "overall" : `team/${scope.teamCode}`; logger.error(`precomputeScoreboardCache failed scope=${label}`, err); } } }