mmday-firebase/src/services/rankSnapshotService.ts
윤정민 ffafca1338 Unify prediction ranking on baseball tier codes
- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
2026-07-23 14:13:19 +09:00

160 lines
4.4 KiB
TypeScript

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<RankSnapshot | null> {
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<void> {
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<void> {
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<ScoreboardScopeCache> {
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<void> {
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);
}
}
}