Implement game result judgment and automated reward system.

- `dailyArchive` 과정에 `judgeDay`를 통합하여 경기 결과에 따른 일간 판정, 스트릭 업데이트, 티어 포인트 가산을 자동화했습니다.
- 주간 모든 경기를 성공적으로 예측했을 때 '주간 마스터 티켓'을 지급하는 `judgeWeekIfNeeded` 로직을 구현했습니다.
- Firestore 트랜잭션을 사용하여 판정 결과 반영의 원자성을 확보하고, 중복 처리를 방지하는 멱등성 가드를 적용했습니다.
- `statsService`가 실시간 계산 대신 사용자 문서에 저장된 스트릭과 티어 정보를 활용하도록 고도화하고 관련 유틸리티와 테스트 코드를 추가했습니다.
This commit is contained in:
윤정민 2026-04-22 11:08:35 +09:00
parent 06c6de4660
commit 392ecfbd0b
11 changed files with 740 additions and 16 deletions

0
database-debug.log Normal file
View File

0
firestore-debug.log Normal file
View File

46
src/constants/judgment.ts Normal file
View File

@ -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;
}

27
src/constants/tiers.ts Normal file
View File

@ -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;
}

View File

@ -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<void> {
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<DailyJudgmentResult> {
const ref = firestore.collection(COLLECTION).doc(uid);
return firestore.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const user = (snap.data() ?? {}) as Partial<User>;
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<string, unknown> = {
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<boolean> {
const ref = firestore.collection(COLLECTION).doc(uid);
return firestore.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const user = (snap.data() ?? {}) as Partial<User>;
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;
});
}

View File

@ -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<string, Record<string, DayVotes>>;
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}`);
}
);

View File

@ -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<void> {
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<void> {
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}`);
}
}

View File

@ -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<StatsResponse> {
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<StatsResponse>
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<StatsResponse>
weeklyPredictions: weekly.total,
currentLevel: level,
progress,
tier: tierOf(tierPoints),
tierPoints,
tickets,
updatedAt: Date.now(),
};
}

View File

@ -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));
}

View File

@ -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<boolean | null>;
winRates: {
overall: number;
@ -64,5 +85,8 @@ export interface StatsResponse {
weeklyPredictions: number;
currentLevel: number;
progress: number;
tier: TierName;
tierPoints: number;
tickets: TicketMap;
updatedAt: number;
}

View File

@ -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<void> {
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<User> = {}): Promise<void> {
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<Partial<User>> {
const snap = await firestore.collection("users").doc(uid).get();
return (snap.data() ?? {}) as Partial<User>;
}
async function readVoteHistory(date: DateString): Promise<VoteHistoryDoc> {
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<void> {
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<DateString, DailyJudgment>
): Promise<void> {
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);
});
});
});