diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index 33aefae8..f13ad3df 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -53,6 +53,7 @@ type LobbyFixtureOptions = { opentime?: string; turntime?: string; lobbyBundleFailures?: number; + profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED'; }; const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { @@ -78,6 +79,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => opentime = '2026-07-30 00:00:00', turntime = '2026-07-30 00:05:00', lobbyBundleFailures = 0, + profileStatus = 'RUNNING', } = options; let remainingLobbyBundleFailures = lobbyBundleFailures; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; @@ -111,7 +113,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => profileName: 'hwe:903', profile: 'hwe', scenario: '903', - status: 'RUNNING', + status: profileStatus, apiPort: 15015, runtime: { apiRunning: true, @@ -230,6 +232,23 @@ test('exchanges the gateway token before loading authenticated lobby general dat }); }); +test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => { + const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' }); + await page.setViewportSize({ width: 1365, height: 900 }); + + await page.goto('lobby'); + const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); + const pausedStatus = row.getByTestId('profile-paused-status'); + await expect(pausedStatus).toHaveText('턴 진행 일시정지'); + await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)'); + await expect(row).toContainText('선택장수'); + await expect(row).not.toContainText('정보를 불러오는 중'); + await expect(row.getByRole('button', { name: '입장' })).toBeVisible(); + expect(gameOperations.some(({ operation }) => operation === 'lobby.info')).toBe(true); + await expect(page.getByRole('tab', { name: 'hwe섭' })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true }); +}); + test('automatically recovers profile details after a transient update outage', async ({ page }) => { const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 }); diff --git a/app/gateway-frontend/e2e/public-map-tabs.spec.ts b/app/gateway-frontend/e2e/public-map-tabs.spec.ts index 7d8ac615..4dd368e7 100644 --- a/app/gateway-frontend/e2e/public-map-tabs.spec.ts +++ b/app/gateway-frontend/e2e/public-map-tabs.spec.ts @@ -11,7 +11,7 @@ type ProfileFixture = { profile: string; korName: string; color: string; - status: 'RUNNING' | 'PREOPEN' | 'STOPPED'; + status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; apiPort: number; }; @@ -29,7 +29,7 @@ const profiles: ProfileFixture[] = [ profile: 'hwe', korName: '훼', color: '#80c0ff', - status: 'PREOPEN', + status: 'PAUSED', apiPort: 15015, }, { @@ -371,6 +371,17 @@ test('treats an all-closed profile list as a normal empty login status', async ( await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true }); }); +test('shows a PAUSED profile with a live runtime on the public login status', async ({ page }) => { + await installGatewayFixture(page, [profiles[1]!], false); + await installGameFixture(page, profiles[1]!, 22); + + await page.goto('/gateway/'); + const status = page.locator('#map-subframe'); + await expect(status).toContainText('훼 현황'); + await expect(status).toContainText('유저 22명'); + await expect(status).not.toContainText('현재 공개 중인 서버가 없습니다.'); +}); + test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => { await installGatewayFixture(page, orderedProfiles, true); diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 039e01d4..af44b2f5 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -19,6 +19,7 @@ type LobbyProfile = GatewayOutput['lobby']['profiles'][number]; type LobbyInfo = GameOutput['lobby']['info']; type PublicMap = GameOutput['public']['getCachedMap']; type PublicMapLayout = GameOutput['public']['getMapLayout']; +const PROFILE_PUBLIC_STATUS_ORDER: LobbyProfile['status'][] = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']; const router = useRouter(); const username = ref(''); @@ -52,9 +53,9 @@ const loadPublicStatus = async (): Promise => { try { const profiles = await trpc.lobby.profiles.query(); profile.value = - profiles.find((entry) => entry.status === 'RUNNING') ?? - profiles.find((entry) => entry.status === 'PREOPEN') ?? - null; + PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find( + (entry) => entry !== undefined + ) ?? null; if (!profile.value) { statusError.value = '현재 공개 중인 서버가 없습니다.'; return; diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 0bc2484c..60b43beb 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -33,6 +33,7 @@ type ProfileLoadState = { const PROFILE_REQUEST_TIMEOUT_MS = 10_000; const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const; +const PROFILE_RUNTIME_STATUSES = new Set(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']); const router = useRouter(); const me = ref(null); @@ -67,7 +68,7 @@ const needsKakaoVerification = computed( const userIconBaseUrl = configuredUserIconPublicUrl(); const sharedIconBaseUrl = configuredSharedIconPublicUrl(); const publicMapProfiles = computed(() => - profiles.value.filter((profile) => profile.status === 'RUNNING' || profile.status === 'PREOPEN') + profiles.value.filter((profile) => PROFILE_RUNTIME_STATUSES.has(profile.status)) ); const selectedMapProfile = computed( () => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null @@ -109,6 +110,12 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void => const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); +const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => PROFILE_RUNTIME_STATUSES.has(profile.status); +const unavailableProfileText = (profile: LobbyProfile): string => { + if (profile.status === 'RESERVED') return '- 준 비 중 -'; + if (profile.status === 'DISABLED') return '- 비 활 성 -'; + return '- 폐 쇄 중 -'; +}; const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName]; const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => { profileLoadStates.value = { @@ -151,7 +158,7 @@ const handleGeneralPictureError = (event: Event): void => { }; const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise => { - if (!lobbyMounted || (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN')) { + if (!lobbyMounted || !isProfileRuntimeAvailable(profile)) { return; } clearProfileRetry(profile.profileName); @@ -443,6 +450,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { > {{ serverSeasonStatus(profileDetails[profile.profileName]!).label }} +
+ 턴 진행 일시정지 +
{
- -