Implement game result judgment and automated reward system.
- `dailyArchive` 과정에 `judgeDay`를 통합하여 경기 결과에 따른 일간 판정, 스트릭 업데이트, 티어 포인트 가산을 자동화했습니다. - 주간 모든 경기를 성공적으로 예측했을 때 '주간 마스터 티켓'을 지급하는 `judgeWeekIfNeeded` 로직을 구현했습니다. - Firestore 트랜잭션을 사용하여 판정 결과 반영의 원자성을 확보하고, 중복 처리를 방지하는 멱등성 가드를 적용했습니다. - `statsService`가 실시간 계산 대신 사용자 문서에 저장된 스트릭과 티어 정보를 활용하도록 고도화하고 관련 유틸리티와 테스트 코드를 추가했습니다.
This commit is contained in:
parent
06c6de4660
commit
392ecfbd0b
0
database-debug.log
Normal file
0
database-debug.log
Normal file
0
firestore-debug.log
Normal file
0
firestore-debug.log
Normal file
46
src/constants/judgment.ts
Normal file
46
src/constants/judgment.ts
Normal 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
27
src/constants/tiers.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
);
|
||||
93
src/services/judgmentService.ts
Normal file
93
src/services/judgmentService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
362
tests/services/judgmentService.test.ts
Normal file
362
tests/services/judgmentService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user