- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가 - 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거 - 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강 - 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈 - statsService 테스트를 새 티어 코드·필드 구성으로 갱신
160 lines
4.4 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|