- selectedTeamCode에 무승부 예약 코드 'DRAW' 허용 (sideOf가 draw 진영 반환)
- RTDB /votes/{gameId}/counts에 drawCount 추가, 투표 요약 응답에 포함
- 채점 규칙 변경: 무승부 경기는 'DRAW' 투표만 적중, 팀 투표는 오답 (기존: 전원 적중 처리)
- 무승부 경기(completed + winningTeamCode 없음)도 onGameCompleted 트리거에서 즉시 채점 (기존: dailyArchive 리컨실까지 지연)
- 어드민 수동 종료(POST /admin/game/end)에 winningTeamCode: 'DRAW' 지원
- VOTE_FLOW.md 갱신, voteRepository 무승부 투표/카운트 재분배 테스트 추가
76 lines
3.1 KiB
TypeScript
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 };
|
|
}
|