From fbf8cd876f58f691c25c5553ee7e1301b52f4674 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 17:56:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=83=81=ED=83=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/tournament/index.ts | 4 +- app/game-api/src/tournament/types.ts | 1 + app/game-api/src/tournament/worker.ts | 1 + .../src/views/TournamentView.vue | 100 +++++++++++++++++- 4 files changed, 104 insertions(+), 2 deletions(-) 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); + } +};