diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a7b9066..a129656 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -13,6 +13,7 @@ import BattleCenterView from '../views/BattleCenterView.vue'; import BattleSimulatorView from '../views/BattleSimulatorView.vue'; import NpcControlView from '../views/NpcControlView.vue'; import NotFoundView from '../views/NotFoundView.vue'; +import TournamentView from '../views/TournamentView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -120,6 +121,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/tournament', + name: 'tournament', + component: TournamentView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/login', name: 'login', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 45e922e..feb9ee7 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -98,6 +98,7 @@ watch( 사령부 감찰부 전투 시뮬레이터 + 토너먼트 NPC 정책 유산 강화 +import { computed, onMounted, ref } from 'vue'; +import { useMediaQuery } from '@vueuse/core'; +import PanelCard from '../components/ui/PanelCard.vue'; +import SkeletonLines from '../components/ui/SkeletonLines.vue'; +import { trpc } from '../utils/trpc'; +import { formatLog } from '../utils/formatLog'; + +type TournamentSnapshot = Awaited>; +type TournamentBettingSummary = Awaited>; +type GeneralMe = Awaited>; + +const isMobile = useMediaQuery('(max-width: 1024px)'); + +const loading = ref(false); +const error = ref(null); +const snapshot = ref(null); +const bettingSummary = ref(null); +const myGeneral = ref(null); +const lastActionMessage = ref(null); + +const mobileTabs = [ + { key: 'status', label: '상태' }, + { key: 'bracket', label: '대진' }, + { key: 'betting', label: '베팅' }, +] as const; + +type MobileTabKey = (typeof mobileTabs)[number]['key']; +const mobileTab = ref('status'); + +const resolveErrorMessage = (value: unknown): string => { + if (value instanceof Error) { + return value.message; + } + if (typeof value === 'string') { + return value; + } + return 'unknown_error'; +}; + +const loadTournament = async () => { + if (loading.value) { + return; + } + loading.value = true; + error.value = null; + lastActionMessage.value = null; + + try { + const [snapshotResult, bettingResult, generalResult] = await Promise.all([ + trpc.tournament.getSnapshot.query(), + trpc.tournament.getBettingSummary.query(), + trpc.general.me.query(), + ]); + snapshot.value = snapshotResult; + bettingSummary.value = bettingResult; + myGeneral.value = generalResult; + } catch (err) { + error.value = resolveErrorMessage(err); + } finally { + loading.value = false; + } +}; + +onMounted(() => { + void loadTournament(); +}); + +const stageLabelMap: Record = { + 0: '경기 없음', + 1: '참가 모집중', + 2: '예선 진행중', + 3: '본선 추첨중', + 4: '본선 진행중', + 5: '16강 배정중', + 6: '베팅 진행중', + 7: '16강 진행중', + 8: '8강 진행중', + 9: '4강 진행중', + 10: '결승 진행중', +}; + +const typeLabelMap: Record = { + 0: '전력전', + 1: '통솔전', + 2: '일기토', + 3: '설전', +}; + +const stageLabel = computed(() => { + const state = snapshot.value?.state; + if (!state) { + return '토너먼트 정보를 불러오는 중'; + } + if (state.stage === 2 || state.stage === 4) { + return `예선 진행중(페이즈 ${state.phase + 1})`; + } + return stageLabelMap[state.stage] ?? '토너먼트 상태 확인 중'; +}); + +const bettingCloseLabel = computed(() => { + const closeAt = snapshot.value?.state?.bettingCloseAt; + if (!closeAt) { + return null; + } + const date = new Date(closeAt); + if (!Number.isFinite(date.getTime())) { + return null; + } + return `${date.toLocaleString('ko-KR')} 마감`; +}); + +const bracketEntries = computed(() => { + const matches = snapshot.value?.matches ?? []; + const participants = snapshot.value?.participants ?? []; + const nameMap = new Map(participants.map((entry) => [entry.id, entry.name])); + + return matches.map((match) => ({ + ...match, + attackerName: nameMap.get(match.attackerId) ?? `#${match.attackerId}`, + defenderName: nameMap.get(match.defenderId) ?? `#${match.defenderId}`, + winnerName: match.winnerId ? nameMap.get(match.winnerId) ?? `#${match.winnerId}` : null, + })); +}); + +const stageMatches = (stage: number) => bracketEntries.value.filter((match) => match.stage === stage); + +const currentMatch = computed(() => { + const state = snapshot.value?.state; + if (!state || state.stage < 7 || state.stage > 10) { + return null; + } + const matches = stageMatches(state.stage).sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex); + if (matches.length === 0) { + return null; + } + const fallbackIndex = Math.min(state.phase, matches.length - 1); + return matches[fallbackIndex] ?? matches.find((match) => !match.winnerId) ?? matches[0]; +}); + +const extractEnergy = (logs?: string[]) => { + if (!logs || logs.length === 0) { + return null; + } + const line = [...logs].reverse().find((entry) => entry.includes('合')) ?? null; + if (!line) { + return null; + } + const values: number[] = []; + const regex = /(\d+)<\/>/g; + let match: RegExpExecArray | null = null; + while ((match = regex.exec(line)) !== null) { + values.push(Number(match[1])); + } + if (values.length < 2) { + return null; + } + return { attacker: values[0], defender: values[1] }; +}; + +const currentEnergy = computed(() => extractEnergy(currentMatch.value?.log)); +const formattedLogs = computed(() => + (currentMatch.value?.log ?? []).map((entry) => formatLog(entry)) +); + +const finalists = computed(() => { + const matches = stageMatches(7); + const ids = new Set(); + for (const match of matches) { + ids.add(match.attackerId); + ids.add(match.defenderId); + } + return Array.from(ids); +}); + +const finalistOptions = computed(() => { + const participants = snapshot.value?.participants ?? []; + const nameMap = new Map(participants.map((entry) => [entry.id, entry.name])); + return finalists.value.map((id) => ({ id, name: nameMap.get(id) ?? `#${id}` })); +}); + +const myGeneralId = computed(() => myGeneral.value?.general?.id ?? 0); +const isParticipant = computed(() => { + if (!myGeneralId.value) { + return false; + } + return (snapshot.value?.participants ?? []).some((entry) => entry.id === myGeneralId.value); +}); + +const showJoinButton = computed(() => snapshot.value?.state?.stage === 1); + +const betTargetId = ref(0); +const betAmount = ref(0); + +const isBettingOpen = computed(() => { + const state = snapshot.value?.state; + if (!state || state.stage !== 6) { + return false; + } + if (!state.bettingCloseAt) { + return true; + } + const closeAt = new Date(state.bettingCloseAt).getTime(); + return Number.isFinite(closeAt) ? Date.now() < closeAt : true; +}); + +const placeBet = async () => { + if (!betTargetId.value || betAmount.value <= 0) { + lastActionMessage.value = '베팅 대상과 금액을 입력하세요.'; + return; + } + try { + await trpc.tournament.placeBet.mutate({ targetId: betTargetId.value, amount: betAmount.value }); + lastActionMessage.value = '베팅이 등록되었습니다.'; + await loadTournament(); + } catch (err) { + lastActionMessage.value = resolveErrorMessage(err); + } +}; + +const toggleJoin = async () => { + try { + await trpc.general.setMySetting.mutate({ tnmt: 1 }); + lastActionMessage.value = '참가 신청이 반영되었습니다.'; + await loadTournament(); + } catch (err) { + lastActionMessage.value = resolveErrorMessage(err); + } +}; + +const bettingTotals = computed(() => bettingSummary.value?.totals ?? {}); +const bettingMine = computed(() => bettingSummary.value?.myTotals ?? {}); +const totalBetAmount = computed(() => bettingSummary.value?.totalAmount ?? 0); +const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0); + + + + + + + 토너먼트 + {{ stageLabel }} + + + 새로고침 + + + + {{ error }} + {{ lastActionMessage }} + + + + + {{ tab.label }} + + + + + + + + 종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }} + 개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월 + 페이즈: {{ snapshot?.state?.phase ?? '-' }} + 자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }} + + + + {{ isParticipant ? '참가 완료' : '참가 신청' }} + + 참가 신청 기간이 아닙니다. + + + + + + + + + {{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }} + + + 남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }} + + + + + + 현재 진행 중인 전투가 없습니다. + + + + + + + + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + + + 8강 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + 4강 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + 결승 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + + + + + + + + 전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }} + 내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }} + + + + {{ entry.name }} + 전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }} + 내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }} + + + + + + + 베팅 기간이 아닙니다. + + + 대상 + + 선택 + + {{ entry.name }} + + + + + 금액 + + + 베팅 등록 + + + + + + + + + + + 종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }} + 개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월 + 페이즈: {{ snapshot?.state?.phase ?? '-' }} + 자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }} + + + + {{ isParticipant ? '참가 완료' : '참가 신청' }} + + 참가 신청 기간이 아닙니다. + + + + + + + + + {{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }} + + + 남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }} + + + + + + 현재 진행 중인 전투가 없습니다. + + + + + + + + {{ entry.name }} + 통{{ entry.leadership }} / 무{{ entry.strength }} / 지{{ entry.intel }} + + + + + + + + + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + + + 8강 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + 4강 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + 결승 + 진행 대기 + + + {{ entry.attackerName }} + vs + {{ entry.defenderName }} + + + + + + + + + + 전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }} + 내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }} + + + + {{ entry.name }} + 전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }} + 내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }} + + + + + + + 베팅 기간이 아닙니다. + + + 대상 + + 선택 + + {{ entry.name }} + + + + + 금액 + + + 베팅 등록 + + + + + + + + \ No newline at end of file
{{ stageLabel }}