import { logger } from "firebase-functions"; import { createGameDayCache, type GameDayCache, } from "../repositories/gameRepository"; import { getDay, setDay } from "../repositories/voteHistoryRepository"; import { applyDailyJudgmentTx, getUser, } from "../repositories/userRepository"; import { judgeByCounts, streakBonus, thresholdsFor, } from "../constants/judgment"; import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit"; import { addDays, type DateString } from "../types/dateString"; import { settleDailyReward } from "./rewardSettlementService"; /** * `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가 * `"skip"`이 아닌 날)이 한 번이라도 있었는지 검사한다. 있으면 그 날을 결석한 것이므로 * streak이 끊겼다고 본다. 모두 skip이거나 해당 구간이 비어있으면 끊기지 않음. * * KBO 휴장일(월요일, 우천 전체 취소, 올스타브레이크 등)을 자연스럽게 무시한다. * * 비용: 구간 일수만큼 `games` 컬렉션 read. 14일까지만 거슬러 올라가고 그 이상이면 * 결석으로 간주한다(현실적으로 14일 연속 휴장은 없음). */ export async function hasMissedGameDayBetween( lastJudged: DateString, upTo: DateString, gameCache?: GameDayCache ): Promise { const fetch = gameCache ?? createGameDayCache(); const MAX_LOOKBACK = 14; let cursor = addDays(upTo, -1); for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) { const games = await fetch.listByDate(cursor); const completed = games.filter((g) => g.status === "completed").length; if (thresholdsFor(completed) !== "skip") return true; cursor = addDays(cursor, -1); } // MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다. return cursor > lastJudged; } /** * 지정 날짜의 투표 이력을 판정하고 결과를 voteHistory와 user doc에 반영한다. * * - `dailyArchive`가 해당 날짜의 voteHistory 도큐먼트를 기록한 이후에 호출한다. * - 전부 취소되어 투표 데이터가 비어있는 날에도 skip 판정을 남기기 위해 호출 가능. * * @param uid - 유저 ID * @param date - 판정 대상 날짜 (KST) * @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함) * @param opts.gameCache - 여러 유저를 처리할 때 날짜별 games read를 공유하는 캐시 * @param opts.rankSnapshot - 판정 직전 rank 스냅샷. 제공 시 판정 트랜잭션에 함께 기록한다. */ export async function judgeDay( uid: string, date: DateString, voteDoc: VoteHistoryDoc, opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null } ): Promise { const fetch = opts?.gameCache ?? createGameDayCache(); const games = await fetch.listByDate(date); const completedCount = games.filter((g) => g.status === "completed").length; const threshold = thresholdsFor(completedCount); let judgment: DailyJudgment; let correctCount: number; if (threshold === "skip") { judgment = "skip"; correctCount = 0; } else { correctCount = voteDoc.data.filter((v) => v.result).length; judgment = judgeByCounts(correctCount, threshold); } // 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제 // 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김. const userPre = await getUser(uid); const lastJudgedPre = userPre?.lastJudgedDate; const streakBrokenIn = lastJudgedPre != null && lastJudgedPre < addDays(date, -1) && (await hasMissedGameDayBetween(lastJudgedPre, date, fetch)); const tx = await applyDailyJudgmentTx(uid, date, { judgment, correctCount, completedCount, streakBrokenIn, rankSnapshot: opts?.rankSnapshot ?? undefined, computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter), }); if (tx.skippedByGuard) { logger.info(`judgeDay: ${uid} ${date} already judged, skipping`); // 복구 경로: 앞선 run에서 트랜잭션은 커밋됐지만(=lastJudgedDate 갱신) voteHistory // 기록 직전에 크래시한 경우 doc이 비어있을 수 있다. 그 때만 data를 복원한다. // 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다. const existing = await getDay(uid, date); if (!existing) await setDay(uid, date, voteDoc); await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err)); return; } await setDay(uid, date, { ...voteDoc, judgment, correctCount, completedCount, streakAfter: tx.streakAfter, }); await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err)); } /** * 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두 * `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다. * * @param uid - 유저 ID * @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜 */