import { logger } from "firebase-functions"; import { firestore } from "../firebase"; import { countRankedUsers, countUsersAboveTierPoints, getUser, 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 } from "../types/panit"; import type { ScoreboardEntry, ScoreboardScopeCache, } from "../types/scoreboard"; /** * 유저 1명의 현재 `tierPoints` 기반 rank를 count aggregation으로 계산해 * `rankSnapshot`에 기록한다. `dailyArchive`에서 `judgeDay` **이전에** 호출해 * 스냅샷이 "직전 상태의 rank"를 담도록 한다. * * `tierPoints === 0`이면 스냅샷을 남기지 않는다. */ export async function computeRankSnapshot( uid: string, date: DateString ): Promise { const user = await getUser(uid); if (!user) return null; const tierPoints = user.tierPoints ?? 0; if (tierPoints <= 0) return null; const overallAbove = await countUsersAboveTierPoints(tierPoints); const snapshot: RankSnapshot = { date, overall: overallAbove + 1, }; if (user.favoriteTeamCode) { const teamAbove = await countUsersAboveTierPoints( tierPoints, user.favoriteTeamCode ); snapshot.team = teamAbove + 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팀 각각의 top 10 / totalCount를 RTDB에 기록한다. * `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함. */ export async function precomputeScoreboardCache( date: DateString ): Promise { const scopes: Scope[] = [ { kind: "overall" }, ...Object.values(TeamCode).map( (teamCode) => ({ kind: "team" as const, teamCode }) ), ]; 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); } } }