diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 3e2d08f..54976a6 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -37,6 +37,7 @@ const zTournamentState = z.object({ winnerId: z.number().int().optional(), bettingSettled: z.boolean().optional(), rewardSettled: z.boolean().optional(), + participantsLockedAt: z.string().optional(), lastError: z.string().optional(), lastErrorAt: z.string().optional(), }); @@ -95,6 +96,7 @@ export const tournamentRouter = router({ const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); return store.getState(); }), + getAdminStatus: adminProcedure.query(async () => ({ ok: true })), getSnapshot: procedure.query(async ({ ctx }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const [state, participants, matches, bets] = await Promise.all([ @@ -230,7 +232,7 @@ export const tournamentRouter = router({ } const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const state = await store.getState(); - if (!state || state.stage !== 1) { + if (!state || state.stage !== 1 || state.participantsLockedAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); } diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index af3fc3e..a071ad9 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -14,6 +14,7 @@ export interface TournamentState { winnerId?: number; bettingSettled?: boolean; rewardSettled?: boolean; + participantsLockedAt?: string; lastError?: string; lastErrorAt?: string; } diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 93bbf27..7226311 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -330,6 +330,7 @@ const applyPreBattleStage = async ( ...state, stage: 2, phase: 0, + participantsLockedAt: new Date().toISOString(), nextAt: resolveNextAt(state), }; await store.setState(nextState); diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index ef58b25..2edddb8 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -17,6 +17,7 @@ const snapshot = ref(null); const bettingSummary = ref(null); const myGeneral = ref(null); const lastActionMessage = ref(null); +const adminEnabled = ref(false); const mobileTabs = [ { key: 'status', label: '상태' }, @@ -46,14 +47,16 @@ const loadTournament = async () => { lastActionMessage.value = null; try { - const [snapshotResult, bettingResult, generalResult] = await Promise.all([ + const [snapshotResult, bettingResult, generalResult, adminResult] = await Promise.all([ trpc.tournament.getSnapshot.query(), trpc.tournament.getBettingSummary.query(), trpc.general.me.query(), + trpc.tournament.getAdminStatus.query().catch(() => null), ]); snapshot.value = snapshotResult; bettingSummary.value = bettingResult; myGeneral.value = generalResult; + adminEnabled.value = !!adminResult?.ok; } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -223,6 +226,7 @@ const isParticipant = computed(() => { }); const showJoinButton = computed(() => snapshot.value?.state?.stage === 1); +const joinLockedAt = computed(() => snapshot.value?.state?.participantsLockedAt ?? null); const betTargetId = ref(0); const betAmount = ref(0); @@ -267,6 +271,64 @@ const bettingTotals = computed>(() => bettingSummary.valu const bettingMine = computed>(() => bettingSummary.value?.myTotals ?? {}); const totalBetAmount = computed(() => bettingSummary.value?.totalAmount ?? 0); const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0); + +const progressSummary = computed(() => { + return { + participants: snapshot.value?.participants?.length ?? 0, + matches: snapshot.value?.matches?.length ?? 0, + bets: snapshot.value?.bets?.length ?? 0, + nextAt: snapshot.value?.state?.nextAt ?? null, + lockedAt: snapshot.value?.state?.participantsLockedAt ?? null, + }; +}); + +const adminMessage = ref(null); + +const adminStopTournament = async () => { + if (!snapshot.value?.state) { + adminMessage.value = '토너먼트 상태를 찾을 수 없습니다.'; + return; + } + try { + await trpc.tournament.patchState.mutate({ auto: false }); + adminMessage.value = '자동 진행이 중지되었습니다.'; + await loadTournament(); + } catch (err) { + adminMessage.value = resolveErrorMessage(err); + } +}; + +const adminStartTournament = async () => { + const current = snapshot.value?.state; + if (!current) { + adminMessage.value = '토너먼트 상태를 찾을 수 없습니다.'; + return; + } + try { + await Promise.all([ + trpc.tournament.setParticipants.mutate([]), + trpc.tournament.setMatches.mutate([]), + trpc.tournament.setBettingEntries.mutate([]), + ]); + await trpc.tournament.setState.mutate({ + ...current, + stage: 1, + phase: 0, + auto: true, + nextAt: new Date().toISOString(), + bettingId: undefined, + bettingCloseAt: undefined, + winnerId: undefined, + bettingSettled: false, + rewardSettled: false, + participantsLockedAt: undefined, + }); + adminMessage.value = '임의 개최가 시작되었습니다.'; + await loadTournament(); + } catch (err) { + adminMessage.value = resolveErrorMessage(err); + } +};