merge: 최신 main을 신규 장수 특기 수정에 통합한다
This commit is contained in:
@@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
|||||||
{
|
{
|
||||||
permission: 'admin.scenarios.reset',
|
permission: 'admin.scenarios.reset',
|
||||||
label: '시나리오 초기화',
|
label: '시나리오 초기화',
|
||||||
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
|
description:
|
||||||
|
'지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.',
|
||||||
risk: 'CRITICAL',
|
risk: 'CRITICAL',
|
||||||
scope: 'PROFILE',
|
scope: 'PROFILE',
|
||||||
},
|
},
|
||||||
@@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
|||||||
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
|
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
|
||||||
const action = (rawInput as { action?: unknown }).action;
|
const action = (rawInput as { action?: unknown }).action;
|
||||||
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
|
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
|
||||||
|
if (action === 'CLOSE_COMPLETED') return 'admin.scenarios.reset';
|
||||||
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
||||||
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const zServerAction = z.enum([
|
|||||||
'RESUME',
|
'RESUME',
|
||||||
'PAUSE',
|
'PAUSE',
|
||||||
'STOP',
|
'STOP',
|
||||||
|
'CLOSE_COMPLETED',
|
||||||
'ACCELERATE',
|
'ACCELERATE',
|
||||||
'DELAY',
|
'DELAY',
|
||||||
'UPDATE_RUNTIME_SETTINGS',
|
'UPDATE_RUNTIME_SETTINGS',
|
||||||
@@ -2227,6 +2228,7 @@ export const adminRouter = router({
|
|||||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
|
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
|
||||||
const canOpenSurvey =
|
const canOpenSurvey =
|
||||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||||
|
const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName);
|
||||||
|
|
||||||
if (input.action === 'RESUME') {
|
if (input.action === 'RESUME') {
|
||||||
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
||||||
@@ -2257,6 +2259,28 @@ export const adminRouter = router({
|
|||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Stop is allowed only while the profile runtime is available.',
|
message: 'Stop is allowed only while the profile runtime is available.',
|
||||||
});
|
});
|
||||||
|
} else if (input.action === 'CLOSE_COMPLETED') {
|
||||||
|
if (!canResetScenario) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Scenario reset permission is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Completed cleanup is allowed only while the profile runtime is available.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [runtimeSettings] =
|
||||||
|
(await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
|
||||||
|
const isUnited = profile.status === 'COMPLETED' || Number(runtimeSettings?.isUnited ?? 0) !== 0;
|
||||||
|
if (!isUnited) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Only a unified game can be closed by a scenario opener.',
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (input.action === 'OPEN_SURVEY') {
|
} else if (input.action === 'OPEN_SURVEY') {
|
||||||
if (!canOpenSurvey) {
|
if (!canOpenSurvey) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -2323,6 +2347,7 @@ export const adminRouter = router({
|
|||||||
RESUME: 'RUNNING',
|
RESUME: 'RUNNING',
|
||||||
PAUSE: 'PAUSED',
|
PAUSE: 'PAUSED',
|
||||||
STOP: 'STOPPED',
|
STOP: 'STOPPED',
|
||||||
|
CLOSE_COMPLETED: 'STOPPED',
|
||||||
SHUTDOWN: 'DISABLED',
|
SHUTDOWN: 'DISABLED',
|
||||||
} as const;
|
} as const;
|
||||||
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
|
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
|||||||
|
|
||||||
export interface ProfileRuntimeSettingsSnapshot {
|
export interface ProfileRuntimeSettingsSnapshot {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
|
/** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */
|
||||||
|
isUnited: number;
|
||||||
turnTermMinutes: number;
|
turnTermMinutes: number;
|
||||||
blockGeneralCreate: 0 | 1 | 2;
|
blockGeneralCreate: 0 | 1 | 2;
|
||||||
autorunUser: {
|
autorunUser: {
|
||||||
@@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
|
isUnited: Number(meta.isunited ?? meta.isUnited ?? 0),
|
||||||
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
||||||
blockGeneralCreate,
|
blockGeneralCreate,
|
||||||
autorunUser:
|
autorunUser:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const buildCaller = async (
|
|||||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||||
profileScenario?: string | null;
|
profileScenario?: string | null;
|
||||||
profileMeta?: GatewayProfileRecord['meta'];
|
profileMeta?: GatewayProfileRecord['meta'];
|
||||||
|
gameIsUnited?: number;
|
||||||
releaseCommitSha?: string;
|
releaseCommitSha?: string;
|
||||||
initialOperation?: GatewayOperationRecord;
|
initialOperation?: GatewayOperationRecord;
|
||||||
profileLogVisibilityAfterPolls?: number;
|
profileLogVisibilityAfterPolls?: number;
|
||||||
@@ -291,6 +292,7 @@ const buildCaller = async (
|
|||||||
listRuntimeSettings: async () => [
|
listRuntimeSettings: async () => [
|
||||||
{
|
{
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
|
isUnited: options.gameIsUnited ?? 0,
|
||||||
turnTermMinutes: 20,
|
turnTermMinutes: 20,
|
||||||
blockGeneralCreate: 2,
|
blockGeneralCreate: 2,
|
||||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||||
@@ -405,6 +407,7 @@ describe('admin profile navigation API', () => {
|
|||||||
|
|
||||||
expect(result[0]?.runtimeSettings).toEqual({
|
expect(result[0]?.runtimeSettings).toEqual({
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
|
isUnited: 0,
|
||||||
turnTermMinutes: 20,
|
turnTermMinutes: 20,
|
||||||
blockGeneralCreate: 2,
|
blockGeneralCreate: 2,
|
||||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||||
@@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => {
|
|||||||
expect(harness.getReconcileCount()).toBe(1);
|
expect(harness.getReconcileCount()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets a scoped scenario opener close a unified game without runtime authority', async () => {
|
||||||
|
const harness = await buildCaller(unusedCreateOperation, {
|
||||||
|
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||||
|
firstUserIsAdmin: false,
|
||||||
|
initialProfileStatus: 'RUNNING',
|
||||||
|
gameIsUnited: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.profiles.requestAction({
|
||||||
|
profileName: 'che:2',
|
||||||
|
action: 'CLOSE_COMPLETED',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(harness.updatedStatuses).toEqual(['STOPPED']);
|
||||||
|
expect(harness.getReconcileCount()).toBe(1);
|
||||||
|
expect(harness.auditEvents.at(-1)).toMatchObject({ capability: 'admin.scenarios.reset' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a scenario opener close a game before unification', async () => {
|
||||||
|
const harness = await buildCaller(unusedCreateOperation, {
|
||||||
|
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||||
|
firstUserIsAdmin: false,
|
||||||
|
initialProfileStatus: 'RUNNING',
|
||||||
|
gameIsUnited: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.profiles.requestAction({
|
||||||
|
profileName: 'che:2',
|
||||||
|
action: 'CLOSE_COMPLETED',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Only a unified game can be closed by a scenario opener.',
|
||||||
|
});
|
||||||
|
expect(harness.updatedStatuses).toEqual([]);
|
||||||
|
expect(harness.getReconcileCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn runtime authority into scenario-opener cleanup authority', async () => {
|
||||||
|
const harness = await buildCaller(unusedCreateOperation, {
|
||||||
|
adminRoles: ['user', 'admin.profiles.runtime:che:2'],
|
||||||
|
firstUserIsAdmin: false,
|
||||||
|
initialProfileStatus: 'RUNNING',
|
||||||
|
gameIsUnited: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.profiles.requestAction({
|
||||||
|
profileName: 'che:2',
|
||||||
|
action: 'CLOSE_COMPLETED',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
expect(harness.updatedStatuses).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ const installFixture = async (
|
|||||||
initialActions?: RuntimeAction[];
|
initialActions?: RuntimeAction[];
|
||||||
afterRequestActions?: RuntimeAction[];
|
afterRequestActions?: RuntimeAction[];
|
||||||
pendingProfileReads?: number;
|
pendingProfileReads?: number;
|
||||||
profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED';
|
profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||||
currentScenario?: string | null;
|
currentScenario?: string | null;
|
||||||
|
gameIsUnited?: number;
|
||||||
|
openerOnly?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
let requested = false;
|
let requested = false;
|
||||||
@@ -46,6 +48,7 @@ const installFixture = async (
|
|||||||
let installActive = false;
|
let installActive = false;
|
||||||
let postRequestProfileReads = 0;
|
let postRequestProfileReads = 0;
|
||||||
let requestedRuntimeSettings = false;
|
let requestedRuntimeSettings = false;
|
||||||
|
let completedCloseRequested = false;
|
||||||
const requestBodies: unknown[] = [];
|
const requestBodies: unknown[] = [];
|
||||||
let releaseRequest = (): void => {};
|
let releaseRequest = (): void => {};
|
||||||
const requestGate = options.deferRequest
|
const requestGate = options.deferRequest
|
||||||
@@ -67,7 +70,9 @@ const installFixture = async (
|
|||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
if (operations.includes('admin.profiles.requestAction')) {
|
if (operations.includes('admin.profiles.requestAction')) {
|
||||||
requested = true;
|
requested = true;
|
||||||
requestedRuntimeSettings = JSON.stringify(body).includes('UPDATE_RUNTIME_SETTINGS');
|
const requestJson = JSON.stringify(body);
|
||||||
|
requestedRuntimeSettings = requestJson.includes('UPDATE_RUNTIME_SETTINGS');
|
||||||
|
completedCloseRequested = requestJson.includes('CLOSE_COMPLETED');
|
||||||
requestBodies.push(body);
|
requestBodies.push(body);
|
||||||
await requestGate;
|
await requestGate;
|
||||||
}
|
}
|
||||||
@@ -94,7 +99,7 @@ const installFixture = async (
|
|||||||
return response({ enabled: true });
|
return response({ enabled: true });
|
||||||
}
|
}
|
||||||
if (operation === 'admin.capabilities.list') {
|
if (operation === 'admin.capabilities.list') {
|
||||||
return response([
|
const capabilities = [
|
||||||
{
|
{
|
||||||
permission: 'admin.users.manage',
|
permission: 'admin.users.manage',
|
||||||
label: '사용자·제재 관리',
|
label: '사용자·제재 관리',
|
||||||
@@ -134,7 +139,12 @@ const installFixture = async (
|
|||||||
scope: 'PROFILE',
|
scope: 'PROFILE',
|
||||||
scopes: ['*'],
|
scopes: ['*'],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
return response(
|
||||||
|
options.openerOnly
|
||||||
|
? capabilities.filter((entry) => entry.permission === 'admin.scenarios.reset')
|
||||||
|
: capabilities
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (operation === 'admin.profiles.listScenarios') {
|
if (operation === 'admin.profiles.listScenarios') {
|
||||||
return response([
|
return response([
|
||||||
@@ -170,11 +180,12 @@ const installFixture = async (
|
|||||||
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||||
scenario: options.currentScenario ?? 'default',
|
scenario: options.currentScenario ?? 'default',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
status: options.profileStatus ?? 'RUNNING',
|
status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'),
|
||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
meta: {},
|
meta: {},
|
||||||
runtimeSettings: requestedRuntimeSettings
|
runtimeSettings: requestedRuntimeSettings
|
||||||
? {
|
? {
|
||||||
|
isUnited: options.gameIsUnited ?? 0,
|
||||||
turnTermMinutes: 20,
|
turnTermMinutes: 20,
|
||||||
blockGeneralCreate: 1,
|
blockGeneralCreate: 1,
|
||||||
autorunUser: {
|
autorunUser: {
|
||||||
@@ -183,6 +194,7 @@ const installFixture = async (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
isUnited: options.gameIsUnited ?? 0,
|
||||||
turnTermMinutes: 10,
|
turnTermMinutes: 10,
|
||||||
blockGeneralCreate: 2,
|
blockGeneralCreate: 2,
|
||||||
autorunUser: null,
|
autorunUser: null,
|
||||||
@@ -384,6 +396,63 @@ test('distinguishes a turn pause from an inaccessible stopped server in operator
|
|||||||
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled();
|
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lets a scenario opener close only a unified server and keeps the control usable on mobile', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const fixture = await installFixture(page, { gameIsUnited: 2, openerOnly: true });
|
||||||
|
|
||||||
|
await page.goto('/gateway/admin/servers/hwe%3Adefault');
|
||||||
|
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('천하통일 완료');
|
||||||
|
await expect(page.getByTestId('runtime-settings')).toHaveCount(0);
|
||||||
|
const cleanup = page.getByTestId('completed-profile-cleanup');
|
||||||
|
await expect(cleanup).toContainText('현재 기수 DB와 완료 기록은 보존');
|
||||||
|
const submit = page.getByTestId('completed-profile-cleanup-submit');
|
||||||
|
const desktopGeometry = await cleanup.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
viewport: window.innerWidth,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
color: style.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktopGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(desktopGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewport);
|
||||||
|
expect(desktopGeometry.fontSize).toBe('14px');
|
||||||
|
expect(desktopGeometry.color).not.toBe('rgba(0, 0, 0, 0)');
|
||||||
|
const buttonBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
await submit.hover();
|
||||||
|
const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
expect(hoverBackground).not.toBe(buttonBackground);
|
||||||
|
await submit.focus();
|
||||||
|
await expect(submit).toBeFocused();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
|
await submit.click();
|
||||||
|
await expect.poll(() => JSON.stringify(fixture.requestBodies)).toContain('CLOSE_COMPLETED');
|
||||||
|
await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 접근 불가');
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const lifecycle = page.getByTestId('profile-lifecycle-description');
|
||||||
|
const geometry = await lifecycle.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { left: rect.left, right: rect.right, viewport: window.innerWidth };
|
||||||
|
});
|
||||||
|
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.right).toBeLessThanOrEqual(geometry.viewport);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not offer completed cleanup to a scenario opener before unification', async ({ page }) => {
|
||||||
|
await installFixture(page, { gameIsUnited: 0, openerOnly: true });
|
||||||
|
|
||||||
|
await page.goto('/gateway/admin/servers/hwe%3Adefault');
|
||||||
|
await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => {
|
test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => {
|
||||||
await installFixture(page, { profileStatus: 'STOPPED' });
|
await installFixture(page, { profileStatus: 'STOPPED' });
|
||||||
|
|
||||||
|
|||||||
@@ -217,12 +217,16 @@ type AdminProfile = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProfileRuntimeSettings = Pick<ProfileResetDefaults, 'turnTermMinutes' | 'blockGeneralCreate' | 'autorunUser'>;
|
type ProfileRuntimeSettings = Pick<ProfileResetDefaults, 'turnTermMinutes' | 'blockGeneralCreate' | 'autorunUser'> & {
|
||||||
|
isUnited: number;
|
||||||
|
};
|
||||||
|
type ProfileRuntimeSettingsUpdate = Omit<ProfileRuntimeSettings, 'isUnited'>;
|
||||||
|
|
||||||
type AdminAction =
|
type AdminAction =
|
||||||
| 'RESUME'
|
| 'RESUME'
|
||||||
| 'PAUSE'
|
| 'PAUSE'
|
||||||
| 'STOP'
|
| 'STOP'
|
||||||
|
| 'CLOSE_COMPLETED'
|
||||||
| 'ACCELERATE'
|
| 'ACCELERATE'
|
||||||
| 'DELAY'
|
| 'DELAY'
|
||||||
| 'UPDATE_RUNTIME_SETTINGS'
|
| 'UPDATE_RUNTIME_SETTINGS'
|
||||||
@@ -366,7 +370,7 @@ type AdminClient = {
|
|||||||
profileName: string;
|
profileName: string;
|
||||||
action: AdminAction;
|
action: AdminAction;
|
||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
runtimeSettings?: ProfileRuntimeSettings;
|
runtimeSettings?: ProfileRuntimeSettingsUpdate;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>;
|
}) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>;
|
||||||
@@ -446,13 +450,15 @@ const runtimeActionPending = (profile: AdminProfile): boolean => {
|
|||||||
|
|
||||||
const profileLifecycleText = (profile: AdminProfile): string => {
|
const profileLifecycleText = (profile: AdminProfile): string => {
|
||||||
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
|
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
|
||||||
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
|
|
||||||
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
|
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
|
||||||
|
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
|
||||||
|
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
||||||
|
if (Number(profile.runtimeSettings?.isUnited ?? 0) !== 0)
|
||||||
|
return '천하통일 완료 · 종료 기수 조회 가능 · 외부 목록 정리 대기';
|
||||||
|
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
|
||||||
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
|
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
|
||||||
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
|
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
|
||||||
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
|
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
|
||||||
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
|
|
||||||
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
|
||||||
return '준비 중 · 게임 접근 불가';
|
return '준비 중 · 게임 접근 불가';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,6 +466,9 @@ const canResumeProfile = (profile: AdminProfile): boolean =>
|
|||||||
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
|
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
|
||||||
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
|
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
|
||||||
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
|
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
|
||||||
|
const canCloseCompletedProfile = (profile: AdminProfile): boolean =>
|
||||||
|
gatewayProfileCapabilities(profile.status).runtimeExpected &&
|
||||||
|
(profile.status === 'COMPLETED' || Number(profile.runtimeSettings?.isUnited ?? 0) !== 0);
|
||||||
|
|
||||||
const validDuration = (profileName: string): boolean => {
|
const validDuration = (profileName: string): boolean => {
|
||||||
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
||||||
@@ -885,7 +894,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
|||||||
? serverDateTimeInputToIso(actionState.scheduledAt)
|
? serverDateTimeInputToIso(actionState.scheduledAt)
|
||||||
: undefined;
|
: undefined;
|
||||||
const reason = actionState?.reason.trim() || undefined;
|
const reason = actionState?.reason.trim() || undefined;
|
||||||
const runtimeSettings: ProfileRuntimeSettings | undefined =
|
const runtimeSettings: ProfileRuntimeSettingsUpdate | undefined =
|
||||||
action === 'UPDATE_RUNTIME_SETTINGS' && actionState
|
action === 'UPDATE_RUNTIME_SETTINGS' && actionState
|
||||||
? {
|
? {
|
||||||
turnTermMinutes: actionState.turnTermMinutes,
|
turnTermMinutes: actionState.turnTermMinutes,
|
||||||
@@ -2206,6 +2215,29 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
hasCapability('admin.scenarios.reset', profile.profileName) &&
|
||||||
|
canCloseCompletedProfile(profile)
|
||||||
|
"
|
||||||
|
class="rounded border border-amber-700/70 bg-amber-950/30 p-3 text-sm text-amber-100"
|
||||||
|
data-testid="completed-profile-cleanup"
|
||||||
|
>
|
||||||
|
<div class="font-semibold">천하통일 서버 정리</div>
|
||||||
|
<p class="mt-1 text-xs text-amber-200/80">
|
||||||
|
서버를 외부 목록에서 닫고 실행 프로세스를 중지합니다. 현재 기수 DB와 완료 기록은
|
||||||
|
보존되며, 다음 시나리오 초기화로 다시 열 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mt-3 rounded bg-red-700 px-3 py-2 font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
data-testid="completed-profile-cleanup-submit"
|
||||||
|
:disabled="profileActionSubmitting[profile.profileName]"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'CLOSE_COMPLETED')"
|
||||||
|
>
|
||||||
|
완료 서버 닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-3">
|
<div class="grid md:grid-cols-2 gap-3">
|
||||||
<div
|
<div
|
||||||
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
||||||
|
|||||||
Reference in New Issue
Block a user