diff --git a/app/game-engine/src/turn/gatewayProfileGate.ts b/app/game-engine/src/turn/gatewayProfileGate.ts index 2d2dcc06..ea6cb7f6 100644 --- a/app/game-engine/src/turn/gatewayProfileGate.ts +++ b/app/game-engine/src/turn/gatewayProfileGate.ts @@ -1,3 +1,4 @@ +import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common'; import { createGatewayPostgresConnector } from '@sammo-ts/infra'; export interface GatewayProfileGateOptions { @@ -15,8 +16,6 @@ export interface GatewayProfileGate { const DEFAULT_CACHE_MS = 2000; -const isRunningStatus = (status: string | null | undefined): boolean => status === 'RUNNING'; - export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise => { const connector = createGatewayPostgresConnector({ url: options.gatewayDatabaseUrl ?? options.databaseUrl, @@ -34,7 +33,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption if (!profile) { return false; } - return !isRunningStatus(profile.status); + return !gatewayProfileCapabilities(profile.status as GatewayProfileStatus).turnsRunning; } catch { return false; } diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 86c84a6f..66ec86f1 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { gatewayProfileCapabilities } from '@sammo-ts/common'; import type { GatewayPrisma } from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; @@ -2068,6 +2069,22 @@ export const adminRouter = router({ message: 'Resume permission is required.', }); } + if (profile.currentScenario === null) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'An uninitialized profile must be reset before it can be resumed.', + }); + } + } else if (input.action === 'PAUSE' && profile.status !== 'RUNNING') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Pause is allowed only for RUNNING profiles.', + }); + } else if (input.action === 'STOP' && !gatewayProfileCapabilities(profile.status).runtimeExpected) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Stop is allowed only while the profile runtime is available.', + }); } else if (input.action === 'OPEN_SURVEY') { if (!canOpenSurvey) { throw new TRPCError({ diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index 5db1c7e5..31cd3cb3 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -1,3 +1,4 @@ +import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common'; import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js'; import type { GatewayProfileRecord, @@ -26,6 +27,9 @@ export type LobbyProfileStatus = { /** @deprecated Rollback-compatible mirror of currentScenario. */ scenario: string; status: GatewayProfileStatus; + lifecycle: GatewayProfileCapabilities & { + dataInitialized: boolean; + }; apiPort: number; runtime: { apiRunning: boolean; @@ -94,6 +98,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi currentScenario: row.currentScenario, scenario: row.scenario, status: row.status, + lifecycle: { + ...gatewayProfileCapabilities(row.status), + dataInitialized: row.currentScenario !== null, + }, apiPort: row.apiPort, runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index fc468ce1..93714c0c 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -5,6 +5,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { stripVTControlCharacters } from 'node:util'; import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; +import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, @@ -89,7 +90,7 @@ export const planProfileReconcile = ( status: GatewayProfileStatus, runtime: ProfileRuntimeState ): { shouldStart: boolean; shouldStop: boolean } => { - if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { + if (gatewayProfileCapabilities(status).runtimeExpected) { return { shouldStart: !( runtime.frontendRunning && @@ -1104,7 +1105,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return { ok: false, detail: 'build already in progress' }; } this.buildInFlight = true; - const shouldRun = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profile.status); + const shouldRun = gatewayProfileCapabilities(profile.status).runtimeExpected; const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise => { if (!this.repository.updateProfileForOperation) { throw new Error('Profile deploy requires lease-fenced profile updates.'); diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index e5f6179f..68e3c604 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -1,15 +1,7 @@ +import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common'; import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; -export const GATEWAY_PROFILE_STATUSES = [ - 'RESERVED', - 'PREOPEN', - 'RUNNING', - 'PAUSED', - 'COMPLETED', - 'STOPPED', - 'DISABLED', -] as const; -export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number]; +export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus }; export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const; export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number]; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 4c9f5877..d31e599d 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -29,7 +29,7 @@ const buildCaller = async ( runtimeActionCreateError?: unknown; initialNotice?: string; initialProfileStatus?: GatewayProfileRecord['status']; - profileScenario?: string; + profileScenario?: string | null; profileMeta?: GatewayProfileRecord['meta']; initialOperation?: GatewayOperationRecord; profileLogVisibilityAfterPolls?: number; @@ -86,7 +86,7 @@ const buildCaller = async ( profileName: 'che:2', profile: 'che', instanceKey: '2', - currentScenario: options.profileScenario ?? '2', + currentScenario: Object.hasOwn(options, 'profileScenario') ? (options.profileScenario ?? null) : '2', scenario: options.profileScenario ?? '2', apiPort: 15003, status: options.initialProfileStatus ?? ('STOPPED' as const), @@ -1061,6 +1061,54 @@ describe('admin runtime clock action API', () => { expect(harness.getReconcileCount()).toBe(0); }); + it('does not turn a stopped profile into an accessible paused runtime', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'STOPPED' }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'PAUSE', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Pause is allowed only for RUNNING profiles.', + }); + expect(harness.updatedStatuses).toEqual([]); + expect(harness.getReconcileCount()).toBe(0); + }); + + it('requires reset before an uninitialized stopped profile can be resumed', async () => { + const harness = await buildCaller(unusedCreateOperation, { + initialProfileStatus: 'STOPPED', + profileScenario: null, + }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'RESUME', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'An uninitialized profile must be reset before it can be resumed.', + }); + expect(harness.updatedStatuses).toEqual([]); + expect(harness.getReconcileCount()).toBe(0); + }); + + it('allows stopping an accessible paused profile', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'PAUSED' }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'STOP', + }) + ).resolves.toMatchObject({ ok: true }); + expect(harness.updatedStatuses).toEqual(['STOPPED']); + expect(harness.getReconcileCount()).toBe(1); + }); + it('creates a first-class clock action owned by the authenticated administrator', async () => { const harness = await buildCaller(unusedCreateOperation); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 7abadc55..f10c0dba 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -175,6 +175,13 @@ const buildCaller = ( currentScenario: profile.currentScenario, scenario: profile.scenario, status: profile.status, + lifecycle: { + runtimeExpected: true, + userAccessible: true, + turnsRunning: true, + operatorResumable: false, + dataInitialized: profile.currentScenario !== null, + }, apiPort: profile.apiPort, runtime: { apiRunning: true, diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index ca29718b..e93dcfab 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -37,6 +37,8 @@ const installFixture = async ( initialActions?: RuntimeAction[]; afterRequestActions?: RuntimeAction[]; pendingProfileReads?: number; + profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED'; + currentScenario?: string | null; } = {} ) => { let requested = false; @@ -151,7 +153,7 @@ const installFixture = async ( profileName: 'hwe:default', profile: 'hwe', instanceKey: 'default', - currentScenario: '1010', + currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario, meta: {}, }, ]); @@ -163,10 +165,10 @@ const installFixture = async ( profileName: 'hwe:default', profile: 'hwe', instanceKey: 'default', - currentScenario: '1010', - scenario: '1010', + currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario, + scenario: options.currentScenario ?? 'default', apiPort: 15015, - status: 'RUNNING', + status: options.profileStatus ?? 'RUNNING', buildStatus: 'SUCCEEDED', meta: {}, activeOperation: installActive @@ -286,6 +288,34 @@ test('reports clock-shift acceptance separately from actual application', async await expect(page.getByText('설문 생성은 해당 게임의 설문 관리 화면에서 진행해 주세요.')).toBeVisible(); }); +test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }) => { + await installFixture(page, { profileStatus: 'PAUSED' }); + + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 조회와 예약턴 입력 가능'); + await expect(page.getByRole('button', { name: '턴 재개' })).toBeEnabled(); + await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled(); +}); + +test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => { + await installFixture(page, { profileStatus: 'STOPPED' }); + + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 접근 불가'); + await expect(page.getByRole('button', { name: '서버 재개' })).toBeEnabled(); + await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '중지', exact: true })).toBeDisabled(); +}); + +test('separates an uninitialized database from an initialized stopped server', async ({ page }) => { + await installFixture(page, { profileStatus: 'STOPPED', currentScenario: null }); + + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByTestId('profile-lifecycle-description')).toHaveText('DB 초기화 전 · 게임 접근 불가'); + await expect(page.getByRole('button', { name: '서버 재개' })).toBeDisabled(); +}); + test('blocks another clock shift while any recent action is pending', async ({ page }) => { await installFixture(page, { initialActions: [ diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index f13ad3df..f9c12608 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -53,7 +53,7 @@ type LobbyFixtureOptions = { opentime?: string; turntime?: string; lobbyBundleFailures?: number; - profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED'; + profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; }; const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { @@ -112,8 +112,17 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { profileName: 'hwe:903', profile: 'hwe', + instanceKey: '903', + currentScenario: '903', scenario: '903', status: profileStatus, + lifecycle: { + runtimeExpected: profileStatus !== 'STOPPED', + userAccessible: profileStatus !== 'STOPPED', + turnsRunning: profileStatus === 'RUNNING', + operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED', + dataInitialized: true, + }, apiPort: 15015, runtime: { apiRunning: true, @@ -239,7 +248,7 @@ test('loads and labels a PAUSED profile whose runtime remains available', async 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).toHaveText('턴 일시정지 · 조회/예약턴 가능'); await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)'); await expect(row).toContainText('선택장수'); await expect(row).not.toContainText('정보를 불러오는 중'); @@ -249,6 +258,19 @@ test('loads and labels a PAUSED profile whose runtime remains available', async await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true }); }); +test('does not contact a STOPPED game runtime and labels it inaccessible', async ({ page }, testInfo) => { + const gameOperations = await installFixture(page, { profileStatus: 'STOPPED' }); + + await page.goto('lobby'); + const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); + await expect(row).toContainText('서버 중지 · 접근 불가'); + await expect(row).not.toContainText('정보를 불러오는 중'); + await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0); + expect(gameOperations).toEqual([]); + await page.screenshot({ path: testInfo.outputPath('gateway-stopped-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 4dd368e7..16920a46 100644 --- a/app/gateway-frontend/e2e/public-map-tabs.spec.ts +++ b/app/gateway-frontend/e2e/public-map-tabs.spec.ts @@ -13,8 +13,23 @@ type ProfileFixture = { color: string; status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; apiPort: number; + lifecycle: { + runtimeExpected: boolean; + userAccessible: boolean; + turnsRunning: boolean; + operatorResumable: boolean; + dataInitialized: boolean; + }; }; +const lifecycleFor = (status: ProfileFixture['status']): ProfileFixture['lifecycle'] => ({ + runtimeExpected: status !== 'STOPPED', + userAccessible: status !== 'STOPPED', + turnsRunning: status === 'RUNNING', + operatorResumable: status === 'PAUSED' || status === 'STOPPED', + dataInitialized: true, +}); + const profiles: ProfileFixture[] = [ { profileName: 'che:2', @@ -23,6 +38,7 @@ const profiles: ProfileFixture[] = [ color: '#ff8080', status: 'RUNNING', apiPort: 15003, + lifecycle: lifecycleFor('RUNNING'), }, { profileName: 'hwe:2', @@ -31,6 +47,7 @@ const profiles: ProfileFixture[] = [ color: '#80c0ff', status: 'PAUSED', apiPort: 15015, + lifecycle: lifecycleFor('PAUSED'), }, { profileName: 'kwe:2', @@ -39,6 +56,7 @@ const profiles: ProfileFixture[] = [ color: '#b0b0b0', status: 'STOPPED', apiPort: 15005, + lifecycle: lifecycleFor('STOPPED'), }, ]; @@ -58,6 +76,7 @@ const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korN color: '#b0b0b0', status: 'STOPPED', apiPort, + lifecycle: lifecycleFor('STOPPED'), })); const fulfill = async (route: Route, results: unknown[]): Promise => { diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 634516af..3d86765f 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -1,5 +1,11 @@