import { logger } from "firebase-functions"; import { listByDate } from "../repositories/gameRepository"; import { getRange, setDay } from "../repositories/voteHistoryRepository"; import { applyDailyJudgmentTx, applyWeeklyMasterTx, getUser, } from "../repositories/userRepository"; import { judgeByCounts, streakBonus, thresholdsFor, } from "../constants/judgment"; import type { DailyJudgment, VoteHistoryDoc } from "../types/panit"; import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString"; /** * `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가 * `"skip"`이 아닌 날)이 한 번이라도 있었는지 검사한다. 있으면 그 날을 결석한 것이므로 * streak이 끊겼다고 본다. 모두 skip이거나 해당 구간이 비어있으면 끊기지 않음. * * KBO 휴장일(월요일, 우천 전체 취소, 올스타브레이크 등)을 자연스럽게 무시한다. * * 비용: 구간 일수만큼 `games` 컬렉션 read. 14일까지만 거슬러 올라가고 그 이상이면 * 결석으로 간주한다(현실적으로 14일 연속 휴장은 없음). */ export async function hasMissedGameDayBetween( lastJudged: DateString, upTo: DateString ): Promise { const MAX_LOOKBACK = 14; let cursor = addDaysKst(upTo, -1); for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) { const games = await listByDate(cursor); const completed = games.filter((g) => g.status === "completed").length; if (thresholdsFor(completed) !== "skip") return true; cursor = addDaysKst(cursor, -1); } // MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다. return cursor > lastJudged; } /** * 지정 날짜의 투표 이력을 판정하고 결과를 voteHistory와 user doc에 반영한다. * * - `dailyArchive`가 해당 날짜의 voteHistory 도큐먼트를 기록한 이후에 호출한다. * - 전부 취소되어 투표 데이터가 비어있는 날에도 skip 판정을 남기기 위해 호출 가능. * * @param uid - 유저 ID * @param date - 판정 대상 날짜 (KST) * @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함) */ export async function judgeDay( uid: string, date: DateString, voteDoc: VoteHistoryDoc ): Promise { const games = await 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 < addDaysKst(date, -1) && (await hasMissedGameDayBetween(lastJudgedPre, date)); const tx = await applyDailyJudgmentTx(uid, date, { judgment, correctCount, completedCount, streakBrokenIn, computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter), }); if (tx.skippedByGuard) { logger.info(`judgeDay: ${uid} ${date} already judged, skipping`); return; } await setDay(uid, date, { ...voteDoc, judgment, correctCount, completedCount, streakAfter: tx.streakAfter, }); } /** * 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두 * `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다. * * @param uid - 유저 ID * @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜 */ export async function judgeWeekIfNeeded( uid: string, sundayDate: DateString ): Promise { const tuesday = tuesdayOf(sundayDate); const entries = await getRange(uid, tuesday, sundayDate); if (entries.length === 0) return; const byDate = new Map(entries.map((e) => [e.date, e.doc] as const)); for (let i = 0; i < 6; i++) { const d = addDaysKst(tuesday, i); const doc = byDate.get(d); if (!doc || !doc.judgment) return; // 판정 누락 if (doc.judgment === "fail") return; } const granted = await applyWeeklyMasterTx(uid, tuesday); if (granted) { logger.info(`weekly-master granted: uid=${uid} tuesday=${tuesday}`); } }