From 9eabf3003a89eab2dac5f86547157106a2638ac5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 16:37:01 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(engine):=20=EB=8C=80=EA=B7=9C=EB=AA=A8?= =?UTF-8?q?=20=ED=84=B4=20=EC=A0=80=EC=9E=A5=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/databaseHooks.ts | 36 +++++++++++------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index e982be26..2a2c2505 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -897,6 +897,10 @@ export const createDatabaseTurnHooks = async ( const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); const prisma = connector.prisma; + // Prisma's 5-second default matches the normal turn execution budget too + // closely. A populated season can finish the turn but expire while flushing + // it, which rolls the transaction back and marks the profile PAUSED. + const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 }; let committedReadModelChanges: RealtimeReadModelChanges | null = null; const readModelBaseline = createRealtimeReadModelBaseline(world); @@ -1391,10 +1395,7 @@ export const createDatabaseTurnHooks = async ( if (transaction) { await persist(transaction); } else { - await prisma.$transaction( - persist, - options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined - ); + await prisma.$transaction(persist, transactionOptions); } const readModelChanges = mergePersistedVisibleLogChanges( @@ -1425,21 +1426,18 @@ export const createDatabaseTurnHooks = async ( committedReadModelChanges = committed.readModelChanges; }, executeCommand: async (requestId, execute) => { - const committed = await prisma.$transaction( - async (transaction) => { - const directLogFloor = - ( - await transaction.logEntry.findFirst({ - orderBy: { id: 'desc' }, - select: { id: true }, - }) - )?.id ?? 0; - const result = await execute({ db: transaction }); - const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor); - return { result, persisted }; - }, - options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined - ); + const committed = await prisma.$transaction(async (transaction) => { + const directLogFloor = + ( + await transaction.logEntry.findFirst({ + orderBy: { id: 'desc' }, + select: { id: true }, + }) + )?.id ?? 0; + const result = await execute({ db: transaction }); + const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor); + return { result, persisted }; + }, transactionOptions); committed.persisted.acknowledge(); committedReadModelChanges = committed.persisted.readModelChanges; return committed.result; From 3a6ef36d01315433f60e06576c4f199ac60a1064 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 16:37:01 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20=EC=9D=BC=EC=8B=9C=EC=A0=95?= =?UTF-8?q?=EC=A7=80=20=ED=94=84=EB=A1=9C=ED=95=84=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EB=B9=84=EC=97=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/lobby-game-auth.spec.ts | 21 ++++++++++++++- .../e2e/public-map-tabs.spec.ts | 15 +++++++++-- app/gateway-frontend/src/views/HomeView.vue | 7 ++--- app/gateway-frontend/src/views/LobbyView.vue | 26 ++++++++++++++---- docs/release-operations.md | 27 ++++++++++++++----- 5 files changed, 79 insertions(+), 17 deletions(-) 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 }} +
+ 턴 진행 일시정지 +
{
- -