fix(gateway): 일시정지 프로필을 로비에 표시
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
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;
|
||||
|
||||
@@ -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<LobbyProfile['status']>(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']);
|
||||
|
||||
const router = useRouter();
|
||||
const me = ref<MeOutput>(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<void> => {
|
||||
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 }}
|
||||
</div>
|
||||
<div
|
||||
v-if="profile.status === 'PAUSED'"
|
||||
class="mt-1 whitespace-nowrap text-xs text-amber-300"
|
||||
data-testid="profile-paused-status"
|
||||
>
|
||||
턴 진행 일시정지
|
||||
</div>
|
||||
<div
|
||||
v-if="profile.localAccountPolicy?.specialAccess"
|
||||
class="mt-2 text-xs text-emerald-300"
|
||||
@@ -494,8 +508,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="profile.status === 'STOPPED'">
|
||||
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div>
|
||||
<template v-else-if="!isProfileRuntimeAvailable(profile)">
|
||||
<div class="text-center text-zinc-600 py-2">
|
||||
{{ unavailableProfileText(profile) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
|
||||
<div
|
||||
@@ -598,7 +614,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="profile.status === 'STOPPED'">
|
||||
<template v-else-if="!isProfileRuntimeAvailable(profile)">
|
||||
<span class="text-zinc-700">-</span>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
@@ -124,21 +124,36 @@ process 복구를 시도합니다. 관리자 화면의 오류와 PM2 process 상
|
||||
뒤 원인을 해결하고 실패한 작업을 재시도해 주세요. 재시도는 처음 고정된 commit을
|
||||
사용합니다.
|
||||
|
||||
Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn
|
||||
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
|
||||
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
|
||||
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
|
||||
운영에서는 실제 flush 시간과 DB 경합을 함께 관찰합니다.
|
||||
|
||||
로비 한 행만 위 안내에 머물 때는 먼저 새 배포를 요청하지 말고 다음 순서로
|
||||
구분합니다.
|
||||
|
||||
1. 로비의 `지금 다시 확인` 또는 browser 새로고침으로 같은 profile을 다시
|
||||
조회합니다.
|
||||
2. 공개 `/<profile>/api/trpc/lobby.info`가 이미 200이면 runtime은 복구된 것이므로
|
||||
2. `lobby.profiles`의 상태와 runtime role을 함께 봅니다. `RUNNING`, `PREOPEN`,
|
||||
`PAUSED`, `COMPLETED`는 API process가 유지되는 상태이므로 로비 상세와 공개 지도를
|
||||
조회합니다. `PAUSED`에는 `턴 진행 일시정지`를 함께 표시하며, 상세 조회 실패나
|
||||
폐쇄 상태로 바꾸어 표시하지 않습니다.
|
||||
3. 상태가 `RUNNING`/`PREOPEN`/`COMPLETED`이고 공개
|
||||
`/<profile>/api/trpc/lobby.info`가 이미 200이면 runtime은 복구된 것이므로
|
||||
`DB 유지 배포`, `중지`, `재개`를 누르지 않습니다. 기존 로비의 전환 중 1회
|
||||
실패였을 가능성이 큽니다.
|
||||
3. 응답이 계속 502/connection refused이면 관리자 작업 이력에서 활성 DEPLOY/RESET과
|
||||
4. 상태가 `PAUSED`이면 turn daemon이 오류 또는 관리자 요청 때문에 턴 진행 gate를
|
||||
닫은 것입니다. 활성 DEPLOY/RESET이 없고 `lastError`의 원인이 일시적이거나 이미
|
||||
해소됐음을 확인한 뒤 `재개`를 한 번 사용합니다. runtime role이 모두 RUNNING이어도
|
||||
profile status가 자동으로 `RUNNING`으로 돌아가지는 않습니다.
|
||||
5. 응답이 계속 502/connection refused이면 관리자 작업 이력에서 활성 DEPLOY/RESET과
|
||||
terminal 오류, 해당 profile의 API/daemon/worker runtime 상태를 확인합니다. 활성
|
||||
작업이 있으면 중복 작업을 만들지 말고 readiness 또는 rollback 종료를 기다립니다.
|
||||
4. profile이 실제 `STOPPED`/`PAUSED`이고 활성 작업이 없을 때만 `재개`를 사용합니다.
|
||||
metadata는 RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를
|
||||
먼저 확인하고, 원인이 없는 상태에서만 마지막 복구 수단으로 `중지` 후 `재개`를
|
||||
사용합니다. DB 보존 배포는 health restart 버튼이 아닙니다.
|
||||
6. profile이 `STOPPED`이고 활성 작업이 없을 때만 `재개`를 사용합니다. metadata는
|
||||
RUNNING인데 process가 계속 없으면 자동 reconcile과 operation 오류를 먼저 확인하고,
|
||||
원인이 없는 상태에서만 마지막 복구 수단으로 `중지` 후 `재개`를 사용합니다. DB
|
||||
보존 배포는 health restart 버튼이 아닙니다.
|
||||
|
||||
## Gateway 전체 배포
|
||||
|
||||
|
||||
Reference in New Issue
Block a user