diff --git a/database-debug.log b/database-debug.log new file mode 100644 index 0000000..e69de29 diff --git a/firestore-debug.log b/firestore-debug.log new file mode 100644 index 0000000..e69de29 diff --git a/src/constants/judgment.ts b/src/constants/judgment.ts new file mode 100644 index 0000000..2cadfd7 --- /dev/null +++ b/src/constants/judgment.ts @@ -0,0 +1,46 @@ +import type { DailyJudgment } from "../types/panit.js"; + +export interface Thresholds { + success: number; + perfect: number; +} + +/** + * 그날 종료된(=completed) 경기 수를 기준으로 판정 기준을 반환한다. + * + * - 2경기 이하: `"skip"` — 스트릭 유지, 판정 유보. + * - 3경기 이상: 과반(=floor((n+1)/2)) 이상 적중이면 성공, 전부 적중이면 퍼펙트. + * + * 표(기획서 2.5.1) 일치 검증: + * - n=3 → success=2, perfect=3 + * - n=4 → success=2, perfect=4 + * - n=5 → success=3, perfect=5 + */ +export function thresholdsFor(completed: number): Thresholds | "skip" { + if (completed <= 2) return "skip"; + return { + success: Math.floor((completed + 1) / 2), + perfect: completed, + }; +} + +/** + * 적중 수와 임계값으로 일간 판정을 결정한다. + */ +export function judgeByCounts(correct: number, t: Thresholds): DailyJudgment { + if (correct >= t.perfect) return "perfect"; + if (correct >= t.success) return "success"; + return "fail"; +} + +/** + * 스트릭 유지·증가 시 지급되는 보너스 포인트. + * 스트릭이 길수록 가산폭이 커지도록 완만한 계단 함수. + */ +export function streakBonus(streakAfter: number): number { + if (streakAfter >= 30) return 20; + if (streakAfter >= 14) return 10; + if (streakAfter >= 7) return 5; + if (streakAfter >= 3) return 2; + return 0; +} diff --git a/src/constants/tiers.ts b/src/constants/tiers.ts new file mode 100644 index 0000000..bc20dec --- /dev/null +++ b/src/constants/tiers.ts @@ -0,0 +1,27 @@ +import type { TierName } from "../types/panit.js"; + +interface TierStep { + name: TierName; + minPoints: number; +} + +const TIERS: readonly TierStep[] = [ + { name: "bronze", minPoints: 0 }, + { name: "silver", minPoints: 100 }, + { name: "gold", minPoints: 300 }, + { name: "platinum", minPoints: 700 }, + { name: "diamond", minPoints: 1500 }, +] as const; + +/** + * 누적 티어 포인트로부터 현재 티어명을 파생한다. + */ +export function tierOf(points: number): TierName { + const p = Math.max(0, points); + let current: TierName = TIERS[0].name; + for (const step of TIERS) { + if (p >= step.minPoints) current = step.name; + else break; + } + return current; +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index e6ab4c8..e921847 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,11 +1,13 @@ import { FieldValue } from "firebase-admin/firestore"; import { firestore } from "../firebase"; import type { + DailyJudgment, KnowledgeLevel, Provider, TeamCode, User, } from "../types/panit"; +import type { DateString } from "../types/dateString"; const COLLECTION = "users"; @@ -78,3 +80,128 @@ export async function updateUser( export async function deleteUser(uid: string): Promise { await firestore.recursiveDelete(firestore.collection(COLLECTION).doc(uid)); } + +export interface DailyJudgmentResult { + judgment: DailyJudgment; + correctCount: number; + completedCount: number; + /** 판정 후 스트릭 값. `skip`이면 기존 값과 동일. */ + streakAfter: number; + /** 해당 판정이 멱등성 가드로 인해 생략됐는지 여부. */ + skippedByGuard: boolean; +} + +/** + * 일간 판정을 user doc에 원자적으로 반영한다. 동일 날짜가 이미 판정된 경우 + * 현재 저장된 상태를 반환하여 호출자가 멱등 경로에서 voteHistory 쓰기를 + * 건너뛸 수 있도록 한다. + * + * - perfect/success → 스트릭 +1, 하이스트릭 갱신, 티어 포인트 가산 + * - fail → 스트릭 0 (포인트 감점 없음) + * - skip → 스트릭 불변, 포인트 불변 + * - perfect 시 `tickets.dailyAllKill` +1 + * + * `computePoints`는 판정 후 스트릭(streakAfter)을 인자로 받아 해당 판정으로 + * 획득할 포인트를 반환한다. `fail|skip`일 땐 호출되지 않는다. + */ +export async function applyDailyJudgmentTx( + uid: string, + date: DateString, + input: { + judgment: DailyJudgment; + correctCount: number; + completedCount: number; + computePoints: (streakAfter: number) => number; + } +): Promise { + const ref = firestore.collection(COLLECTION).doc(uid); + return firestore.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const user = (snap.data() ?? {}) as Partial; + + const lastJudged = user.lastJudgedDate; + if (lastJudged && lastJudged >= date) { + const streakAfter = user.currentStreak ?? 0; + return { + judgment: input.judgment, + correctCount: input.correctCount, + completedCount: input.completedCount, + streakAfter, + skippedByGuard: true, + }; + } + + const currentStreak = user.currentStreak ?? 0; + const highestStreak = user.highestStreak ?? 0; + const tierPoints = user.tierPoints ?? 0; + const dailyAllKill = user.tickets?.dailyAllKill ?? 0; + const weeklyMaster = user.tickets?.weeklyMaster ?? 0; + + let nextStreak = currentStreak; + let nextPoints = tierPoints; + let nextDailyAllKill = dailyAllKill; + + if (input.judgment === "perfect" || input.judgment === "success") { + nextStreak = currentStreak + 1; + nextPoints = tierPoints + input.computePoints(nextStreak); + if (input.judgment === "perfect") nextDailyAllKill = dailyAllKill + 1; + } else if (input.judgment === "fail") { + nextStreak = 0; + } + // skip: 변화 없음. + + const patch: Record = { + currentStreak: nextStreak, + highestStreak: Math.max(highestStreak, nextStreak), + tierPoints: nextPoints, + tickets: { + dailyAllKill: nextDailyAllKill, + weeklyMaster, + }, + lastJudgedDate: date, + }; + tx.set(ref, patch, { merge: true }); + + return { + judgment: input.judgment, + correctCount: input.correctCount, + completedCount: input.completedCount, + streakAfter: nextStreak, + skippedByGuard: false, + }; + }); +} + +/** + * 주간 마스터 티켓을 지급한다. 같은 화요일 키로 이미 지급됐다면 no-op. + * + * @returns 실제로 지급됐으면 `true`, 멱등 가드에 막혔으면 `false`. + */ +export async function applyWeeklyMasterTx( + uid: string, + tuesday: DateString +): Promise { + const ref = firestore.collection(COLLECTION).doc(uid); + return firestore.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const user = (snap.data() ?? {}) as Partial; + + if (user.lastWeeklyMasterTuesday === tuesday) return false; + + const dailyAllKill = user.tickets?.dailyAllKill ?? 0; + const weeklyMaster = user.tickets?.weeklyMaster ?? 0; + + tx.set( + ref, + { + tickets: { + dailyAllKill, + weeklyMaster: weeklyMaster + 1, + }, + lastWeeklyMasterTuesday: tuesday, + }, + { merge: true } + ); + return true; + }); +} diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index a742af4..96f5954 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -3,11 +3,16 @@ import { logger } from "firebase-functions"; import { rtdb } from "../firebase.js"; import { setDay } from "../repositories/voteHistoryRepository.js"; import { invalidateStats } from "../services/statsService.js"; +import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js"; import { getGame } from "../repositories/gameRepository.js"; import { deleteUserVoteGame } from "../repositories/voteRepository.js"; import { processGameEndWithGame } from "../services/gameResultService.js"; import type { VoteHistoryDoc } from "../types/panit.js"; -import { daysAgoKst, type DateString } from "../types/dateString.js"; +import { + dayOfWeekKst, + daysAgoKst, + type DateString, +} from "../types/dateString.js"; interface RawVote { team: string; @@ -79,6 +84,7 @@ export const dailyArchive = onSchedule( const byUid = snap.val() as Record>; let archived = 0; + const judgedUids: string[] = []; for (const uid of Object.keys(byUid)) { let dayVotes = byUid[uid]?.[date]; if (!dayVotes) continue; @@ -104,18 +110,31 @@ export const dailyArchive = onSchedule( continue; } - // 리컨실 결과 모든 경기가 cancelled로 제거된 경우: voteHistory에 빈 문서를 남기지 않고 인덱스만 정리. - if (data.length === 0) { - await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); - continue; - } - + // 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨 + // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. await setDay(uid, date, { data }); await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); + try { + await judgeDay(uid, date, { data }); + } catch (err) { + logger.error(`judgeDay failed uid=${uid} date=${date}`, err); + } await invalidateStats(uid).catch(() => undefined); + judgedUids.push(uid); archived += 1; } + // 일요일(dow=0) 아카이브/판정이 끝난 시점에 주간 마스터 티켓 지급 판단. + if (dayOfWeekKst(date) === 0) { + for (const uid of judgedUids) { + try { + await judgeWeekIfNeeded(uid, date); + } catch (err) { + logger.error(`judgeWeek failed uid=${uid} date=${date}`, err); + } + } + } + logger.info(`dailyArchive done: ${archived} users archived for ${date}`); } -); \ No newline at end of file +); diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts new file mode 100644 index 0000000..4371a91 --- /dev/null +++ b/src/services/judgmentService.ts @@ -0,0 +1,93 @@ +import { logger } from "firebase-functions"; +import { listByDate } from "../repositories/gameRepository.js"; +import { getRange, setDay } from "../repositories/voteHistoryRepository.js"; +import { + applyDailyJudgmentTx, + applyWeeklyMasterTx, +} from "../repositories/userRepository.js"; +import { + judgeByCounts, + streakBonus, + thresholdsFor, +} from "../constants/judgment.js"; +import type { DailyJudgment, VoteHistoryDoc } from "../types/panit.js"; +import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString.js"; + +/** + * 지정 날짜의 투표 이력을 판정하고 결과를 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); + } + + const tx = await applyDailyJudgmentTx(uid, date, { + judgment, + correctCount, + completedCount, + 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}`); + } +} diff --git a/src/services/statsService.ts b/src/services/statsService.ts index b2f2acf..615a58e 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -1,13 +1,16 @@ import {rtdb} from "../firebase.js"; import {HttpError} from "../middleware/errors.js"; import {computeLevel} from "../constants/levels.js"; +import {tierOf} from "../constants/tiers.js"; import {getAll, getDay} from "../repositories/voteHistoryRepository.js"; +import {getUser} from "../repositories/userRepository.js"; import type {StatsResponse, VoteHistoryDoc} from "../types/panit.js"; import { parseDateString, toDateString, startOfDayKst, todayKst, + tuesdayOf as tuesdayOfUtil, type DateString, } from "../types/dateString.js"; @@ -51,12 +54,7 @@ function parseYmd(date: DateString): { y: number; m: number; d: number } { } /** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */ -function tuesdayOf(date: DateString): DateString { - const {y, m, d} = parseYmd(date); - const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2, ... - const offset = (dow - 2 + 7) % 7; - return toDateString(new Date(y, m - 1, d - offset)); -} +const tuesdayOf = tuesdayOfUtil; /** * 날짜가 주어진 기간에 속하는지 판정한다. @@ -171,7 +169,7 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc * @param period - 레벨·예측 수 집계에 사용할 기간 */ async function computeStats(uid: string, period: Period): Promise { - const all = await getAll(uid); + const [all, user] = await Promise.all([getAll(uid), getUser(uid)]); const overall = aggregate(all); const today = todayKst(); @@ -194,12 +192,17 @@ async function computeStats(uid: string, period: Period): Promise const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period)); const periodAgg = aggregate(periodEntries); - const streakDays = computeStreak(all); + const storedStreak = user?.currentStreak; + const streakDays = storedStreak ?? computeStreak(all); + const highestStreak = user?.highestStreak ?? streakDays; + const tierPoints = user?.tierPoints ?? 0; + const tickets = user?.tickets ?? {dailyAllKill: 0, weeklyMaster: 0}; const weeklyResults = weeklyResultsOf(all); const {level, progress} = computeLevel(periodAgg.total); return { streakDays, + highestStreak, weeklyResults, winRates: { overall: rate(overall.correct, overall.total), @@ -212,6 +215,9 @@ async function computeStats(uid: string, period: Period): Promise weeklyPredictions: weekly.total, currentLevel: level, progress, + tier: tierOf(tierPoints), + tierPoints, + tickets, updatedAt: Date.now(), }; } diff --git a/src/types/dateString.ts b/src/types/dateString.ts index 799ad8d..2d6b3da 100644 --- a/src/types/dateString.ts +++ b/src/types/dateString.ts @@ -47,3 +47,23 @@ export function daysAgoKst(n: number): DateString { export function startOfDayKst(date: DateString): Date { return new Date(Date.parse(`${date}T00:00:00+09:00`)); } + +/** 해당 KST 날짜가 속한 화~월 주의 화요일을 반환한다. */ +export function tuesdayOf(date: DateString): DateString { + const [y, m, d] = date.split("-").map(Number); + const dow = new Date(y, m - 1, d).getDay() || 7; // 일=7, 월=1, 화=2 + const offset = (dow - 2 + 7) % 7; + return toDateString(new Date(y, m - 1, d - offset)); +} + +/** `date`의 요일 (일=0, 월=1, ... 토=6). KST 기준. */ +export function dayOfWeekKst(date: DateString): number { + const [y, m, d] = date.split("-").map(Number); + return new Date(y, m - 1, d).getDay(); +} + +/** `date`에 `delta` 일을 더한 KST 날짜를 반환. */ +export function addDaysKst(date: DateString, delta: number): DateString { + const base = startOfDayKst(date).getTime(); + return toDateString(new Date(base + delta * 24 * 60 * 60 * 1000)); +} diff --git a/src/types/panit.ts b/src/types/panit.ts index dd97db5..390c012 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -1,6 +1,11 @@ import type { Timestamp } from "firebase-admin/firestore"; +import type { DateString } from "./dateString.js"; export type Provider = "google" | "apple"; + +export type DailyJudgment = "perfect" | "success" | "fail" | "skip"; + +export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond"; export type GameStatus = "scheduled" | "live" | "completed" | "cancelled"; export enum TeamCode { @@ -22,6 +27,11 @@ export enum KnowledgeLevel { Expert = "expert", } +export interface TicketMap { + dailyAllKill: number; + weeklyMaster: number; +} + export interface User { displayName: string; email: string; @@ -30,6 +40,12 @@ export interface User { favoriteTeamCode?: TeamCode; knowledgeLevel: KnowledgeLevel; createdAt: Timestamp; + currentStreak?: number; + highestStreak?: number; + tierPoints?: number; + tickets?: TicketMap; + lastJudgedDate?: DateString; + lastWeeklyMasterTuesday?: DateString; } export interface Game { @@ -48,10 +64,15 @@ export interface VoteEntry { export interface VoteHistoryDoc { data: Array<{ gameId: string; team: string; result: boolean }>; + judgment?: DailyJudgment; + correctCount?: number; + completedCount?: number; + streakAfter?: number; } export interface StatsResponse { streakDays: number; + highestStreak: number; weeklyResults: Array; winRates: { overall: number; @@ -64,5 +85,8 @@ export interface StatsResponse { weeklyPredictions: number; currentLevel: number; progress: number; + tier: TierName; + tierPoints: number; + tickets: TicketMap; updatedAt: number; } diff --git a/tests/services/judgmentService.test.ts b/tests/services/judgmentService.test.ts new file mode 100644 index 0000000..b3b47b2 --- /dev/null +++ b/tests/services/judgmentService.test.ts @@ -0,0 +1,362 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase.js"; +import { + judgeDay, + judgeWeekIfNeeded, +} from "../../src/services/judgmentService.js"; +import { setDay } from "../../src/repositories/voteHistoryRepository.js"; +import type { DateString } from "../../src/types/dateString.js"; +import type { + DailyJudgment, + Game, + GameStatus, + User, + VoteHistoryDoc, +} from "../../src/types/panit.js"; + +const uid = "judge-uid"; + +/** 2026-04-14는 화요일 (KST) */ +const TUE: DateString = "2026-04-14" as DateString; +const WED: DateString = "2026-04-15" as DateString; +const THU: DateString = "2026-04-16" as DateString; +const FRI: DateString = "2026-04-17" as DateString; +const SAT: DateString = "2026-04-18" as DateString; +const SUN: DateString = "2026-04-19" as DateString; + +function makeGame( + date: DateString, + status: GameStatus, + homeTeamCode: string, + winner: string | null = null +): Game { + const [y, m, d] = date.split("-").map(Number); + const doc: Game = { + time: Timestamp.fromDate(new Date(Date.UTC(y, m - 1, d, 9, 0))), + stadium: "잠실", + status, + homeTeamCode, + awayTeamCode: "HT", + }; + if (winner) doc.winningTeamCode = winner; + return doc; +} + +async function seedGames( + date: DateString, + specs: Array<{ status: GameStatus; winner?: string }> +): Promise { + for (let i = 0; i < specs.length; i++) { + const spec = specs[i]; + const gameId = `${date.replace(/-/g, "")}G${i}`; + const game = makeGame(date, spec.status, "LG", spec.winner ?? null); + await firestore.collection("games").doc(gameId).set(game); + } +} + +function voteDocOf(correct: number, total: number): VoteHistoryDoc { + const data = []; + for (let i = 0; i < total; i++) { + data.push({ + gameId: `g${i}`, + team: "LG", + result: i < correct, + }); + } + return { data }; +} + +async function seedUser(patch: Partial = {}): Promise { + await firestore + .collection("users") + .doc(uid) + .set( + { + displayName: "judge", + email: "j@e.com", + provider: "google", + knowledgeLevel: "beginner", + createdAt: Timestamp.now(), + ...patch, + }, + { merge: true } + ); +} + +async function readUser(): Promise> { + const snap = await firestore.collection("users").doc(uid).get(); + return (snap.data() ?? {}) as Partial; +} + +async function readVoteHistory(date: DateString): Promise { + const snap = await firestore + .collection("users") + .doc(uid) + .collection("voteHistory") + .doc(date) + .get(); + return (snap.data() ?? { data: [] }) as VoteHistoryDoc; +} + +async function seedJudgedHistory( + date: DateString, + judgment: DailyJudgment +): Promise { + await setDay(uid, date, { data: [], judgment }); +} + +describe("judgmentService (Firestore emulator)", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users")); + await firestore.recursiveDelete(firestore.collection("games")); + await seedUser(); + }); + + describe("judgeDay — 정상 5경기", () => { + it("5경기 모두 종료·5적중이면 perfect 판정과 dailyAllKill 티켓 지급", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + const vote = voteDocOf(5, 5); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + const hist = await readVoteHistory(TUE); + + expect(hist.judgment).toBe("perfect"); + expect(hist.correctCount).toBe(5); + expect(hist.completedCount).toBe(5); + expect(hist.streakAfter).toBe(1); + expect(user.currentStreak).toBe(1); + expect(user.highestStreak).toBe(1); + expect(user.tickets?.dailyAllKill).toBe(1); + expect(user.tickets?.weeklyMaster).toBe(0); + expect(user.tierPoints).toBe(50); // 5 * 10, streak 1이라 보너스 0 + expect(user.lastJudgedDate).toBe(TUE); + }); + + it("5경기·3적중은 success, 스트릭 +1, 티켓 미지급", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + const vote = voteDocOf(3, 5); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + const hist = await readVoteHistory(TUE); + + expect(hist.judgment).toBe("success"); + expect(user.currentStreak).toBe(1); + expect(user.tickets?.dailyAllKill).toBe(0); + expect(user.tierPoints).toBe(30); + }); + + it("5경기·2적중은 fail, 스트릭 0 유지, 포인트 불변", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + await seedUser({ currentStreak: 3, highestStreak: 3, tierPoints: 100 }); + const vote = voteDocOf(2, 5); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + expect(user.currentStreak).toBe(0); + expect(user.highestStreak).toBe(3); // 기존 최고치 유지 + expect(user.tierPoints).toBe(100); // 감점 없음 + }); + }); + + describe("judgeDay — 일부 취소 (thresholds 일반화)", () => { + it("3 completed + 2 cancelled, 2적중이면 success (success=2, perfect=3)", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "cancelled" }, + { status: "cancelled" }, + ]); + const vote = voteDocOf(2, 3); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const hist = await readVoteHistory(TUE); + expect(hist.judgment).toBe("success"); + expect(hist.completedCount).toBe(3); + }); + + it("3 completed, 3적중이면 perfect → dailyAllKill 지급", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + const vote = voteDocOf(3, 3); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + const hist = await readVoteHistory(TUE); + expect(hist.judgment).toBe("perfect"); + expect(user.tickets?.dailyAllKill).toBe(1); + }); + + it("4 completed, 2적중이면 success (success=2, perfect=4)", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + const vote = voteDocOf(2, 4); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const hist = await readVoteHistory(TUE); + expect(hist.judgment).toBe("success"); + }); + }); + + describe("judgeDay — skip (completed ≤ 2)", () => { + it("2 completed만 있으면 skip, 스트릭 유지, 포인트 불변", async () => { + await seedUser({ currentStreak: 4, tierPoints: 80, highestStreak: 4 }); + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "cancelled" }, + ]); + const vote = voteDocOf(1, 2); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + const hist = await readVoteHistory(TUE); + + expect(hist.judgment).toBe("skip"); + expect(hist.streakAfter).toBe(4); + expect(user.currentStreak).toBe(4); + expect(user.tierPoints).toBe(80); + expect(user.tickets?.dailyAllKill).toBe(0); + }); + }); + + describe("judgeDay — 멱등성", () => { + it("같은 날짜에 두 번 호출해도 포인트·스트릭·티켓이 두 번 누적되지 않는다", async () => { + await seedGames(TUE, [ + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + { status: "completed", winner: "LG" }, + ]); + const vote = voteDocOf(5, 5); + await setDay(uid, TUE, vote); + + await judgeDay(uid, TUE, vote); + await judgeDay(uid, TUE, vote); + + const user = await readUser(); + expect(user.currentStreak).toBe(1); + expect(user.tickets?.dailyAllKill).toBe(1); + expect(user.tierPoints).toBe(50); + }); + }); + + describe("judgeWeekIfNeeded", () => { + async function seedWeekJudgments( + judgments: Record + ): Promise { + for (const [date, j] of Object.entries(judgments)) { + await seedJudgedHistory(date as DateString, j); + } + } + + it("화~일 6일 전부 success|perfect|skip 이면 weeklyMaster 지급", async () => { + await seedWeekJudgments({ + [TUE]: "success", + [WED]: "perfect", + [THU]: "skip", + [FRI]: "success", + [SAT]: "success", + [SUN]: "success", + }); + + await judgeWeekIfNeeded(uid, SUN); + + const user = await readUser(); + expect(user.tickets?.weeklyMaster).toBe(1); + expect(user.lastWeeklyMasterTuesday).toBe(TUE); + }); + + it("한 날이라도 fail이면 지급 안 됨", async () => { + await seedWeekJudgments({ + [TUE]: "success", + [WED]: "fail", + [THU]: "success", + [FRI]: "success", + [SAT]: "success", + [SUN]: "success", + }); + + await judgeWeekIfNeeded(uid, SUN); + + const user = await readUser(); + expect(user.tickets?.weeklyMaster ?? 0).toBe(0); + }); + + it("판정 이력이 누락된 날이 있으면 지급 안 됨", async () => { + await seedWeekJudgments({ + [TUE]: "success", + [WED]: "success", + // THU 누락 + [FRI]: "success", + [SAT]: "success", + [SUN]: "success", + }); + + await judgeWeekIfNeeded(uid, SUN); + + const user = await readUser(); + expect(user.tickets?.weeklyMaster ?? 0).toBe(0); + }); + + it("같은 주 재실행 시 중복 지급되지 않는다 (멱등)", async () => { + await seedWeekJudgments({ + [TUE]: "success", + [WED]: "success", + [THU]: "success", + [FRI]: "success", + [SAT]: "success", + [SUN]: "success", + }); + + await judgeWeekIfNeeded(uid, SUN); + await judgeWeekIfNeeded(uid, SUN); + + const user = await readUser(); + expect(user.tickets?.weeklyMaster).toBe(1); + }); + }); +});