merge: 최신 main을 신규 장수 특기 수정에 통합한다

This commit is contained in:
2026-08-24 01:58:02 +00:00
6 changed files with 203 additions and 12 deletions
+3 -1
View File
@@ -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';
+25
View File
@@ -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' });
+38 -6
View File
@@ -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)"