mmday-firebase/src/services/gameResultService.ts
윤정민 9daba193e4 Add draw prediction support to vote pipeline
- selectedTeamCode에 무승부 예약 코드 'DRAW' 허용 (sideOf가 draw 진영 반환)
- RTDB /votes/{gameId}/counts에 drawCount 추가, 투표 요약 응답에 포함
- 채점 규칙 변경: 무승부 경기는 'DRAW' 투표만 적중, 팀 투표는 오답 (기존: 전원 적중 처리)
- 무승부 경기(completed + winningTeamCode 없음)도 onGameCompleted 트리거에서 즉시 채점 (기존: dailyArchive 리컨실까지 지연)
- 어드민 수동 종료(POST /admin/game/end)에 winningTeamCode: 'DRAW' 지원
- VOTE_FLOW.md 갱신, voteRepository 무승부 투표/카운트 재분배 테스트 추가
2026-07-10 16:51:56 +09:00

76 lines
3.1 KiB
TypeScript

import { FieldValue } from "firebase-admin/firestore";
import { logger } from "firebase-functions";
import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors";
import { getGame } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString";
import { DRAW_TEAM_CODE, type Game } from "../types/panit";
export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
const game = await getGame(gameId);
if (!game) throw new HttpError(404, `game not found: ${gameId}`);
return processGameEndWithGame(gameId, game);
}
export async function processGameEndWithGame(
gameId: string,
game: Game,
opts?: { skipInvalidate?: boolean }
): Promise<{ processed: number }> {
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`);
const date = fromTimestamp(game.time);
const votes = await getAllUserVotes(gameId);
const uids = Object.keys(votes);
// 무승부(`winningTeamCode` 없음): 무승부('DRAW') 투표만 적중, 팀 투표는 오답.
// 승부가 난 경기: 승리 팀 투표만 적중 — 무승부 투표는 자연히 오답이 된다.
const isDraw = !game.winningTeamCode;
const rtdbUpdates: Record<string, unknown> = {};
for (const uid of uids) {
const voted = votes[uid].team;
const result = isDraw
? voted === DRAW_TEAM_CODE
: voted === game.winningTeamCode;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
}
if (Object.keys(rtdbUpdates).length > 0) {
await rtdb.ref().update(rtdbUpdates);
}
await deleteGameVotes(gameId);
// 아카이브 리컨실 경로에선 호출자(dailyArchive)가 유저 단위로 1회 무효화하므로
// 경기마다 투표자 전원을 다시 무효화하는 fan-out을 건너뛴다(중복 제거).
if (!opts?.skipInvalidate) {
await Promise.all(uids.map((uid) => invalidateStats(uid).catch(() => undefined)));
}
logger.info(`processGameEnd: ${gameId} processed ${uids.length} votes`);
return { processed: uids.length };
}
export async function markGameEnded(
gameId: string,
winningTeamCode: string
): Promise<{ ok: true; gameId: string }> {
if (!gameId || !winningTeamCode) {
throw new HttpError(400, "gameId and winningTeamCode required");
}
const ref = firestore.collection("games").doc(gameId);
const snap = await ref.get();
if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`);
// 'DRAW'는 무승부 수동 처리 — winningTeamCode 없이 completed로 기록한다
// (판정 규칙상 "winningTeamCode 없음 = 무승부"이므로 남아있는 값도 지운다).
const isDraw = winningTeamCode === DRAW_TEAM_CODE;
await ref.update({
status: "completed",
winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
endedAt: FieldValue.serverTimestamp(),
});
return { ok: true, gameId };
}