merge: 일시정지와 서버 중지 상태 계약 분리
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
export interface GatewayProfileGateOptions {
|
export interface GatewayProfileGateOptions {
|
||||||
@@ -15,8 +16,6 @@ export interface GatewayProfileGate {
|
|||||||
|
|
||||||
const DEFAULT_CACHE_MS = 2000;
|
const DEFAULT_CACHE_MS = 2000;
|
||||||
|
|
||||||
const isRunningStatus = (status: string | null | undefined): boolean => status === 'RUNNING';
|
|
||||||
|
|
||||||
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
|
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
|
||||||
const connector = createGatewayPostgresConnector({
|
const connector = createGatewayPostgresConnector({
|
||||||
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
|
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
|
||||||
@@ -34,7 +33,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !isRunningStatus(profile.status);
|
return !gatewayProfileCapabilities(profile.status as GatewayProfileStatus).turnsRunning;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
import type { GatewayPrisma } from '@sammo-ts/infra';
|
import type { GatewayPrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
@@ -2068,6 +2069,22 @@ export const adminRouter = router({
|
|||||||
message: 'Resume permission is required.',
|
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') {
|
} else if (input.action === 'OPEN_SURVEY') {
|
||||||
if (!canOpenSurvey) {
|
if (!canOpenSurvey) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
|
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
|
||||||
import type {
|
import type {
|
||||||
GatewayProfileRecord,
|
GatewayProfileRecord,
|
||||||
@@ -26,6 +27,9 @@ export type LobbyProfileStatus = {
|
|||||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||||
scenario: string;
|
scenario: string;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
|
lifecycle: GatewayProfileCapabilities & {
|
||||||
|
dataInitialized: boolean;
|
||||||
|
};
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
@@ -94,6 +98,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
currentScenario: row.currentScenario,
|
currentScenario: row.currentScenario,
|
||||||
scenario: row.scenario,
|
scenario: row.scenario,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
lifecycle: {
|
||||||
|
...gatewayProfileCapabilities(row.status),
|
||||||
|
dataInitialized: row.currentScenario !== null,
|
||||||
|
},
|
||||||
apiPort: row.apiPort,
|
apiPort: row.apiPort,
|
||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|||||||
import { stripVTControlCharacters } from 'node:util';
|
import { stripVTControlCharacters } from 'node:util';
|
||||||
|
|
||||||
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
|
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
|
||||||
|
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
@@ -89,7 +90,7 @@ export const planProfileReconcile = (
|
|||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
runtime: ProfileRuntimeState
|
runtime: ProfileRuntimeState
|
||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (gatewayProfileCapabilities(status).runtimeExpected) {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(
|
shouldStart: !(
|
||||||
runtime.frontendRunning &&
|
runtime.frontendRunning &&
|
||||||
@@ -1104,7 +1105,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return { ok: false, detail: 'build already in progress' };
|
return { ok: false, detail: 'build already in progress' };
|
||||||
}
|
}
|
||||||
this.buildInFlight = true;
|
this.buildInFlight = true;
|
||||||
const shouldRun = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profile.status);
|
const shouldRun = gatewayProfileCapabilities(profile.status).runtimeExpected;
|
||||||
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
|
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
|
||||||
if (!this.repository.updateProfileForOperation) {
|
if (!this.repository.updateProfileForOperation) {
|
||||||
throw new Error('Profile deploy requires lease-fenced profile updates.');
|
throw new Error('Profile deploy requires lease-fenced profile updates.');
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
|
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||||
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
export const GATEWAY_PROFILE_STATUSES = [
|
export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
|
||||||
'RESERVED',
|
|
||||||
'PREOPEN',
|
|
||||||
'RUNNING',
|
|
||||||
'PAUSED',
|
|
||||||
'COMPLETED',
|
|
||||||
'STOPPED',
|
|
||||||
'DISABLED',
|
|
||||||
] as const;
|
|
||||||
export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
|
|
||||||
|
|
||||||
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||||
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const buildCaller = async (
|
|||||||
runtimeActionCreateError?: unknown;
|
runtimeActionCreateError?: unknown;
|
||||||
initialNotice?: string;
|
initialNotice?: string;
|
||||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||||
profileScenario?: string;
|
profileScenario?: string | null;
|
||||||
profileMeta?: GatewayProfileRecord['meta'];
|
profileMeta?: GatewayProfileRecord['meta'];
|
||||||
initialOperation?: GatewayOperationRecord;
|
initialOperation?: GatewayOperationRecord;
|
||||||
profileLogVisibilityAfterPolls?: number;
|
profileLogVisibilityAfterPolls?: number;
|
||||||
@@ -86,7 +86,7 @@ const buildCaller = async (
|
|||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
instanceKey: '2',
|
instanceKey: '2',
|
||||||
currentScenario: options.profileScenario ?? '2',
|
currentScenario: Object.hasOwn(options, 'profileScenario') ? (options.profileScenario ?? null) : '2',
|
||||||
scenario: options.profileScenario ?? '2',
|
scenario: options.profileScenario ?? '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||||
@@ -1061,6 +1061,54 @@ describe('admin runtime clock action API', () => {
|
|||||||
expect(harness.getReconcileCount()).toBe(0);
|
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 () => {
|
it('creates a first-class clock action owned by the authenticated administrator', async () => {
|
||||||
const harness = await buildCaller(unusedCreateOperation);
|
const harness = await buildCaller(unusedCreateOperation);
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ const buildCaller = (
|
|||||||
currentScenario: profile.currentScenario,
|
currentScenario: profile.currentScenario,
|
||||||
scenario: profile.scenario,
|
scenario: profile.scenario,
|
||||||
status: profile.status,
|
status: profile.status,
|
||||||
|
lifecycle: {
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: true,
|
||||||
|
operatorResumable: false,
|
||||||
|
dataInitialized: profile.currentScenario !== null,
|
||||||
|
},
|
||||||
apiPort: profile.apiPort,
|
apiPort: profile.apiPort,
|
||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const installFixture = async (
|
|||||||
initialActions?: RuntimeAction[];
|
initialActions?: RuntimeAction[];
|
||||||
afterRequestActions?: RuntimeAction[];
|
afterRequestActions?: RuntimeAction[];
|
||||||
pendingProfileReads?: number;
|
pendingProfileReads?: number;
|
||||||
|
profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED';
|
||||||
|
currentScenario?: string | null;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
let requested = false;
|
let requested = false;
|
||||||
@@ -151,7 +153,7 @@ const installFixture = async (
|
|||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
instanceKey: 'default',
|
instanceKey: 'default',
|
||||||
currentScenario: '1010',
|
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -163,10 +165,10 @@ const installFixture = async (
|
|||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
instanceKey: 'default',
|
instanceKey: 'default',
|
||||||
currentScenario: '1010',
|
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||||
scenario: '1010',
|
scenario: options.currentScenario ?? 'default',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
status: 'RUNNING',
|
status: options.profileStatus ?? 'RUNNING',
|
||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
meta: {},
|
meta: {},
|
||||||
activeOperation: installActive
|
activeOperation: installActive
|
||||||
@@ -286,6 +288,34 @@ test('reports clock-shift acceptance separately from actual application', async
|
|||||||
await expect(page.getByText('설문 생성은 해당 게임의 설문 관리 화면에서 진행해 주세요.')).toBeVisible();
|
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 }) => {
|
test('blocks another clock shift while any recent action is pending', async ({ page }) => {
|
||||||
await installFixture(page, {
|
await installFixture(page, {
|
||||||
initialActions: [
|
initialActions: [
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type LobbyFixtureOptions = {
|
|||||||
opentime?: string;
|
opentime?: string;
|
||||||
turntime?: string;
|
turntime?: string;
|
||||||
lobbyBundleFailures?: number;
|
lobbyBundleFailures?: number;
|
||||||
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED';
|
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||||
@@ -112,8 +112,17 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:903',
|
profileName: 'hwe:903',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: '903',
|
||||||
|
currentScenario: '903',
|
||||||
scenario: '903',
|
scenario: '903',
|
||||||
status: profileStatus,
|
status: profileStatus,
|
||||||
|
lifecycle: {
|
||||||
|
runtimeExpected: profileStatus !== 'STOPPED',
|
||||||
|
userAccessible: profileStatus !== 'STOPPED',
|
||||||
|
turnsRunning: profileStatus === 'RUNNING',
|
||||||
|
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
|
||||||
|
dataInitialized: true,
|
||||||
|
},
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
@@ -239,7 +248,7 @@ test('loads and labels a PAUSED profile whose runtime remains available', async
|
|||||||
await page.goto('lobby');
|
await page.goto('lobby');
|
||||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
const pausedStatus = row.getByTestId('profile-paused-status');
|
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(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
|
||||||
await expect(row).toContainText('선택장수');
|
await expect(row).toContainText('선택장수');
|
||||||
await expect(row).not.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 });
|
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 }) => {
|
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
|
||||||
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,23 @@ type ProfileFixture = {
|
|||||||
color: string;
|
color: string;
|
||||||
status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||||
apiPort: number;
|
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[] = [
|
const profiles: ProfileFixture[] = [
|
||||||
{
|
{
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
@@ -23,6 +38,7 @@ const profiles: ProfileFixture[] = [
|
|||||||
color: '#ff8080',
|
color: '#ff8080',
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
|
lifecycle: lifecycleFor('RUNNING'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
profileName: 'hwe:2',
|
profileName: 'hwe:2',
|
||||||
@@ -31,6 +47,7 @@ const profiles: ProfileFixture[] = [
|
|||||||
color: '#80c0ff',
|
color: '#80c0ff',
|
||||||
status: 'PAUSED',
|
status: 'PAUSED',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
|
lifecycle: lifecycleFor('PAUSED'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
profileName: 'kwe:2',
|
profileName: 'kwe:2',
|
||||||
@@ -39,6 +56,7 @@ const profiles: ProfileFixture[] = [
|
|||||||
color: '#b0b0b0',
|
color: '#b0b0b0',
|
||||||
status: 'STOPPED',
|
status: 'STOPPED',
|
||||||
apiPort: 15005,
|
apiPort: 15005,
|
||||||
|
lifecycle: lifecycleFor('STOPPED'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,6 +76,7 @@ const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korN
|
|||||||
color: '#b0b0b0',
|
color: '#b0b0b0',
|
||||||
status: 'STOPPED',
|
status: 'STOPPED',
|
||||||
apiPort,
|
apiPort,
|
||||||
|
lifecycle: lifecycleFor('STOPPED'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {
|
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
|
import {
|
||||||
|
formatServerDateTime,
|
||||||
|
gatewayProfileCapabilities,
|
||||||
|
serverDateTimeInputToIso,
|
||||||
|
toServerDateTimeInputValue,
|
||||||
|
type GatewayProfileStatus,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
@@ -180,7 +186,7 @@ type AdminProfile = {
|
|||||||
currentScenario: string | null;
|
currentScenario: string | null;
|
||||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||||
scenario: string;
|
scenario: string;
|
||||||
status: string;
|
status: GatewayProfileStatus;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
@@ -423,6 +429,22 @@ const runtimeActionPending = (profile: AdminProfile): boolean => {
|
|||||||
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
|
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const profileLifecycleText = (profile: AdminProfile): string => {
|
||||||
|
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
|
||||||
|
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
|
||||||
|
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
|
||||||
|
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
|
||||||
|
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
|
||||||
|
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
|
||||||
|
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
||||||
|
return '준비 중 · 게임 접근 불가';
|
||||||
|
};
|
||||||
|
|
||||||
|
const canResumeProfile = (profile: AdminProfile): boolean =>
|
||||||
|
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
|
||||||
|
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
|
||||||
|
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
|
||||||
|
|
||||||
const validDuration = (profileName: string): boolean => {
|
const validDuration = (profileName: string): boolean => {
|
||||||
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
||||||
return Number.isInteger(value) && value >= 1 && value <= 1440;
|
return Number.isInteger(value) && value >= 1 && value <= 1440;
|
||||||
@@ -2069,6 +2091,12 @@ onMounted(() => {
|
|||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-1 text-xs text-amber-200"
|
||||||
|
data-testid="profile-lifecycle-description"
|
||||||
|
>
|
||||||
|
{{ profileLifecycleText(profile) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||||
@@ -2359,19 +2387,30 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2 pt-2">
|
<div class="grid grid-cols-2 gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
:disabled="
|
||||||
|
profileActionSubmitting[profile.profileName] ||
|
||||||
|
!canResumeProfile(profile)
|
||||||
|
"
|
||||||
@click="requestProfileAction(profile.profileName, 'RESUME')"
|
@click="requestProfileAction(profile.profileName, 'RESUME')"
|
||||||
>
|
>
|
||||||
재개
|
{{ profile.status === 'PAUSED' ? '턴 재개' : '서버 재개' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
:disabled="
|
||||||
|
profileActionSubmitting[profile.profileName] ||
|
||||||
|
!canPauseProfile(profile)
|
||||||
|
"
|
||||||
@click="requestProfileAction(profile.profileName, 'PAUSE')"
|
@click="requestProfileAction(profile.profileName, 'PAUSE')"
|
||||||
>
|
>
|
||||||
일시정지
|
일시정지
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
:disabled="
|
||||||
|
profileActionSubmitting[profile.profileName] || !canStopProfile(profile)
|
||||||
|
"
|
||||||
@click="requestProfileAction(profile.profileName, 'STOP')"
|
@click="requestProfileAction(profile.profileName, 'STOP')"
|
||||||
>
|
>
|
||||||
중지
|
중지
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||||
|
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
|
|
||||||
import MapPreview from '../components/MapPreview.vue';
|
import MapPreview from '../components/MapPreview.vue';
|
||||||
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
||||||
@@ -53,9 +54,11 @@ const loadPublicStatus = async (): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
const profiles = await trpc.lobby.profiles.query();
|
const profiles = await trpc.lobby.profiles.query();
|
||||||
profile.value =
|
profile.value =
|
||||||
PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find(
|
PROFILE_PUBLIC_STATUS_ORDER.map((status) =>
|
||||||
(entry) => entry !== undefined
|
profiles.find(
|
||||||
) ?? null;
|
(entry) => entry.status === status && gatewayProfileCapabilities(entry.status).userAccessible
|
||||||
|
)
|
||||||
|
).find((entry) => entry !== undefined) ?? null;
|
||||||
if (!profile.value) {
|
if (!profile.value) {
|
||||||
statusError.value = '현재 공개 중인 서버가 없습니다.';
|
statusError.value = '현재 공개 중인 서버가 없습니다.';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ type ProfileLoadState = {
|
|||||||
|
|
||||||
const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
|
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_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 router = useRouter();
|
||||||
const me = ref<MeOutput>(null);
|
const me = ref<MeOutput>(null);
|
||||||
const notice = ref('');
|
const notice = ref('');
|
||||||
@@ -67,9 +65,7 @@ const needsKakaoVerification = computed(
|
|||||||
);
|
);
|
||||||
const userIconBaseUrl = configuredUserIconPublicUrl();
|
const userIconBaseUrl = configuredUserIconPublicUrl();
|
||||||
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
||||||
const publicMapProfiles = computed(() =>
|
const publicMapProfiles = computed(() => profiles.value.filter((profile) => profile.lifecycle.userAccessible));
|
||||||
profiles.value.filter((profile) => PROFILE_RUNTIME_STATUSES.has(profile.status))
|
|
||||||
);
|
|
||||||
const selectedMapProfile = computed(
|
const selectedMapProfile = computed(
|
||||||
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
|
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
|
||||||
);
|
);
|
||||||
@@ -110,11 +106,12 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
|||||||
|
|
||||||
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => PROFILE_RUNTIME_STATUSES.has(profile.status);
|
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
||||||
const unavailableProfileText = (profile: LobbyProfile): string => {
|
const unavailableProfileText = (profile: LobbyProfile): string => {
|
||||||
if (profile.status === 'RESERVED') return '- 준 비 중 -';
|
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
||||||
|
if (profile.status === 'RESERVED') return '- 준 비 중 · 접근 불가 -';
|
||||||
if (profile.status === 'DISABLED') return '- 비 활 성 -';
|
if (profile.status === 'DISABLED') return '- 비 활 성 -';
|
||||||
return '- 폐 쇄 중 -';
|
return '- 서버 중지 · 접근 불가 -';
|
||||||
};
|
};
|
||||||
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
|
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
|
||||||
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
|
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
|
||||||
@@ -455,7 +452,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
class="mt-1 whitespace-nowrap text-xs text-amber-300"
|
class="mt-1 whitespace-nowrap text-xs text-amber-300"
|
||||||
data-testid="profile-paused-status"
|
data-testid="profile-paused-status"
|
||||||
>
|
>
|
||||||
턴 진행 일시정지
|
턴 일시정지 · 조회/예약턴 가능
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="profile.localAccountPolicy?.specialAccess"
|
v-if="profile.localAccountPolicy?.specialAccess"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export const GATEWAY_PROFILE_STATUSES = [
|
||||||
|
'RESERVED',
|
||||||
|
'PREOPEN',
|
||||||
|
'RUNNING',
|
||||||
|
'PAUSED',
|
||||||
|
'COMPLETED',
|
||||||
|
'STOPPED',
|
||||||
|
'DISABLED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
|
||||||
|
|
||||||
|
export type GatewayProfileCapabilities = {
|
||||||
|
/** Frontend/API/daemon processes are expected to remain online. */
|
||||||
|
runtimeExpected: boolean;
|
||||||
|
/** A player may enter the game and read or edit data such as reserved turns. */
|
||||||
|
userAccessible: boolean;
|
||||||
|
/** The turn daemon may advance logical game time. */
|
||||||
|
turnsRunning: boolean;
|
||||||
|
/** An operator may move this state directly back to RUNNING. */
|
||||||
|
operatorResumable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPABILITIES: Record<GatewayProfileStatus, GatewayProfileCapabilities> = {
|
||||||
|
RESERVED: {
|
||||||
|
runtimeExpected: false,
|
||||||
|
userAccessible: false,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: false,
|
||||||
|
},
|
||||||
|
PREOPEN: {
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: false,
|
||||||
|
},
|
||||||
|
RUNNING: {
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: true,
|
||||||
|
operatorResumable: false,
|
||||||
|
},
|
||||||
|
PAUSED: {
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: true,
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: false,
|
||||||
|
},
|
||||||
|
STOPPED: {
|
||||||
|
runtimeExpected: false,
|
||||||
|
userAccessible: false,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: true,
|
||||||
|
},
|
||||||
|
DISABLED: {
|
||||||
|
runtimeExpected: false,
|
||||||
|
userAccessible: false,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gatewayProfileCapabilities = (status: GatewayProfileStatus): GatewayProfileCapabilities =>
|
||||||
|
CAPABILITIES[status];
|
||||||
@@ -22,3 +22,4 @@ export * from './ranking/types.js';
|
|||||||
export * from './ranking/legacyColor.js';
|
export * from './ranking/legacyColor.js';
|
||||||
export * from './auth/accountIconProjection.js';
|
export * from './auth/accountIconProjection.js';
|
||||||
export * from './logging/formatLegacyLogHtml.js';
|
export * from './logging/formatLegacyLogHtml.js';
|
||||||
|
export * from './gateway/profileStatus.js';
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GATEWAY_PROFILE_STATUSES, gatewayProfileCapabilities } from '../src/gateway/profileStatus.js';
|
||||||
|
|
||||||
|
describe('gateway profile status capabilities', () => {
|
||||||
|
it('keeps PAUSED accessible while stopping only turn execution', () => {
|
||||||
|
expect(gatewayProfileCapabilities('PAUSED')).toEqual({
|
||||||
|
runtimeExpected: true,
|
||||||
|
userAccessible: true,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps STOPPED inaccessible while allowing an operator restart', () => {
|
||||||
|
expect(gatewayProfileCapabilities('STOPPED')).toEqual({
|
||||||
|
runtimeExpected: false,
|
||||||
|
userAccessible: false,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines capabilities for every persisted status', () => {
|
||||||
|
expect(GATEWAY_PROFILE_STATUSES.map((status) => gatewayProfileCapabilities(status))).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user