merge: 최신 main을 신규 장수 특기 수정에 통합한다
This commit is contained in:
@@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
||||
{
|
||||
permission: 'admin.scenarios.reset',
|
||||
label: '시나리오 초기화',
|
||||
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
|
||||
description:
|
||||
'지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
@@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
||||
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
|
||||
const action = (rawInput as { action?: unknown }).action;
|
||||
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 === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
||||
|
||||
@@ -45,6 +45,7 @@ const zServerAction = z.enum([
|
||||
'RESUME',
|
||||
'PAUSE',
|
||||
'STOP',
|
||||
'CLOSE_COMPLETED',
|
||||
'ACCELERATE',
|
||||
'DELAY',
|
||||
'UPDATE_RUNTIME_SETTINGS',
|
||||
@@ -2227,6 +2228,7 @@ export const adminRouter = router({
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
|
||||
const canOpenSurvey =
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||
const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName);
|
||||
|
||||
if (input.action === 'RESUME') {
|
||||
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
||||
@@ -2257,6 +2259,28 @@ export const adminRouter = router({
|
||||
code: 'BAD_REQUEST',
|
||||
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') {
|
||||
if (!canOpenSurvey) {
|
||||
throw new TRPCError({
|
||||
@@ -2323,6 +2347,7 @@ export const adminRouter = router({
|
||||
RESUME: 'RUNNING',
|
||||
PAUSE: 'PAUSED',
|
||||
STOP: 'STOPPED',
|
||||
CLOSE_COMPLETED: 'STOPPED',
|
||||
SHUTDOWN: 'DISABLED',
|
||||
} as const;
|
||||
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
|
||||
export interface ProfileRuntimeSettingsSnapshot {
|
||||
profileName: string;
|
||||
/** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */
|
||||
isUnited: number;
|
||||
turnTermMinutes: number;
|
||||
blockGeneralCreate: 0 | 1 | 2;
|
||||
autorunUser: {
|
||||
@@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
);
|
||||
return {
|
||||
profileName,
|
||||
isUnited: Number(meta.isunited ?? meta.isUnited ?? 0),
|
||||
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
||||
blockGeneralCreate,
|
||||
autorunUser:
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildCaller = async (
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string | null;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
gameIsUnited?: number;
|
||||
releaseCommitSha?: string;
|
||||
initialOperation?: GatewayOperationRecord;
|
||||
profileLogVisibilityAfterPolls?: number;
|
||||
@@ -291,6 +292,7 @@ const buildCaller = async (
|
||||
listRuntimeSettings: async () => [
|
||||
{
|
||||
profileName: 'che:2',
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -405,6 +407,7 @@ describe('admin profile navigation API', () => {
|
||||
|
||||
expect(result[0]?.runtimeSettings).toEqual({
|
||||
profileName: 'che:2',
|
||||
isUnited: 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => {
|
||||
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 () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
|
||||
@@ -37,8 +37,10 @@ const installFixture = async (
|
||||
initialActions?: RuntimeAction[];
|
||||
afterRequestActions?: RuntimeAction[];
|
||||
pendingProfileReads?: number;
|
||||
profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED';
|
||||
profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||
currentScenario?: string | null;
|
||||
gameIsUnited?: number;
|
||||
openerOnly?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
let requested = false;
|
||||
@@ -46,6 +48,7 @@ const installFixture = async (
|
||||
let installActive = false;
|
||||
let postRequestProfileReads = 0;
|
||||
let requestedRuntimeSettings = false;
|
||||
let completedCloseRequested = false;
|
||||
const requestBodies: unknown[] = [];
|
||||
let releaseRequest = (): void => {};
|
||||
const requestGate = options.deferRequest
|
||||
@@ -67,7 +70,9 @@ const installFixture = async (
|
||||
const operations = operationNames(route);
|
||||
if (operations.includes('admin.profiles.requestAction')) {
|
||||
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);
|
||||
await requestGate;
|
||||
}
|
||||
@@ -94,7 +99,7 @@ const installFixture = async (
|
||||
return response({ enabled: true });
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([
|
||||
const capabilities = [
|
||||
{
|
||||
permission: 'admin.users.manage',
|
||||
label: '사용자·제재 관리',
|
||||
@@ -134,7 +139,12 @@ const installFixture = async (
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
]);
|
||||
];
|
||||
return response(
|
||||
options.openerOnly
|
||||
? capabilities.filter((entry) => entry.permission === 'admin.scenarios.reset')
|
||||
: capabilities
|
||||
);
|
||||
}
|
||||
if (operation === 'admin.profiles.listScenarios') {
|
||||
return response([
|
||||
@@ -170,11 +180,12 @@ const installFixture = async (
|
||||
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||
scenario: options.currentScenario ?? 'default',
|
||||
apiPort: 15015,
|
||||
status: options.profileStatus ?? 'RUNNING',
|
||||
status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'),
|
||||
buildStatus: 'SUCCEEDED',
|
||||
meta: {},
|
||||
runtimeSettings: requestedRuntimeSettings
|
||||
? {
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 1,
|
||||
autorunUser: {
|
||||
@@ -183,6 +194,7 @@ const installFixture = async (
|
||||
},
|
||||
}
|
||||
: {
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 10,
|
||||
blockGeneralCreate: 2,
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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 =
|
||||
| 'RESUME'
|
||||
| 'PAUSE'
|
||||
| 'STOP'
|
||||
| 'CLOSE_COMPLETED'
|
||||
| 'ACCELERATE'
|
||||
| 'DELAY'
|
||||
| 'UPDATE_RUNTIME_SETTINGS'
|
||||
@@ -366,7 +370,7 @@ type AdminClient = {
|
||||
profileName: string;
|
||||
action: AdminAction;
|
||||
durationMinutes?: number;
|
||||
runtimeSettings?: ProfileRuntimeSettings;
|
||||
runtimeSettings?: ProfileRuntimeSettingsUpdate;
|
||||
scheduledAt?: string;
|
||||
reason?: string;
|
||||
}) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>;
|
||||
@@ -446,13 +450,15 @@ const runtimeActionPending = (profile: AdminProfile): boolean => {
|
||||
|
||||
const profileLifecycleText = (profile: AdminProfile): string => {
|
||||
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
|
||||
if (profile.status === 'PAUSED') 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 === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
|
||||
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
|
||||
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
|
||||
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
||||
return '준비 중 · 게임 접근 불가';
|
||||
};
|
||||
|
||||
@@ -460,6 +466,9 @@ 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 canCloseCompletedProfile = (profile: AdminProfile): boolean =>
|
||||
gatewayProfileCapabilities(profile.status).runtimeExpected &&
|
||||
(profile.status === 'COMPLETED' || Number(profile.runtimeSettings?.isUnited ?? 0) !== 0);
|
||||
|
||||
const validDuration = (profileName: string): boolean => {
|
||||
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
||||
@@ -885,7 +894,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
||||
? serverDateTimeInputToIso(actionState.scheduledAt)
|
||||
: undefined;
|
||||
const reason = actionState?.reason.trim() || undefined;
|
||||
const runtimeSettings: ProfileRuntimeSettings | undefined =
|
||||
const runtimeSettings: ProfileRuntimeSettingsUpdate | undefined =
|
||||
action === 'UPDATE_RUNTIME_SETTINGS' && actionState
|
||||
? {
|
||||
turnTermMinutes: actionState.turnTermMinutes,
|
||||
@@ -2206,6 +2215,29 @@ onMounted(() => {
|
||||
|
||||
<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
|
||||
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
||||
|
||||
Reference in New Issue
Block a user