feat: 실행 중 게임 옵션 변경을 지원

Gateway 관리 화면에서 현재 기수의 장수 생성 제한, 유저 자동턴, 턴 간격을 내구성 action으로 변경한다.

턴 간격 변경은 논리 tick과 현재 게임 시각을 보존하며 DB와 Redis의 tick 기반 시각을 재투영하고 기존 로그 timestamp는 유지한다.
This commit is contained in:
2026-08-17 16:05:07 +00:00
parent 50e7d894e4
commit ca6823d409
26 changed files with 1680 additions and 115 deletions
+108 -1
View File
@@ -287,6 +287,14 @@ const buildCaller = async (
}
},
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
listRuntimeSettings: async () => [
{
profileName: 'che:2',
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
},
],
listRuntimeStates: async () => {
runtimeStateListCount += 1;
return [];
@@ -299,6 +307,7 @@ const buildCaller = async (
findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }),
},
gatewayRuntimeAction: {
findMany: async () => [],
create: async ({ data }: { data: Record<string, unknown> }) => {
if (options.runtimeActionCreateError) {
throw options.runtimeActionCreateError;
@@ -317,6 +326,9 @@ const buildCaller = async (
};
},
},
gatewayOperation: {
findMany: async () => [],
},
systemSetting: {
findUnique: async () => ({ id: 1, notice: storedNotice }),
upsert: async ({ create, update }: { create: { notice: string }; update: { notice: string } }) => {
@@ -371,6 +383,32 @@ describe('admin profile navigation API', () => {
]);
expect(harness.getRuntimeStateListCount()).toBe(0);
});
it('returns live settings from the profile database separately from reset defaults', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
profileMeta: {
resetDefaults: {
turnTermMinutes: 60,
blockGeneralCreate: 0,
autorunUser: null,
},
},
}
);
const result = await harness.caller.admin.profiles.list();
expect(result[0]?.runtimeSettings).toEqual({
profileName: 'che:2',
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
});
});
});
describe('admin scenario catalog API', () => {
@@ -1131,6 +1169,7 @@ describe('admin runtime clock action API', () => {
{
profileName: 'che:2',
action: 'ACCELERATE',
payload: {},
durationMinutes: 15,
reason: '운영 일정 조정',
requestedBy: harness.admin.id,
@@ -1151,10 +1190,78 @@ describe('admin runtime clock action API', () => {
})
).rejects.toMatchObject({
code: 'CONFLICT',
message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.',
message: '이 프로필의 이전 런타임 변경 요청이 아직 처리 중입니다.',
});
});
it('queues all live game settings as one durable runtime action', async () => {
const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' });
const result = await harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'UPDATE_RUNTIME_SETTINGS',
runtimeSettings: {
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: {
limitMinutes: 720,
options: ['develop', 'recruit_high', 'chief'],
},
},
reason: '운영 중 규칙 변경',
});
expect(result).toMatchObject({
ok: true,
action: { action: 'UPDATE_RUNTIME_SETTINGS', status: 'REQUESTED' },
});
expect(harness.createdRuntimeActions).toEqual([
{
profileName: 'che:2',
action: 'UPDATE_RUNTIME_SETTINGS',
payload: {
settings: {
turnTermMinutes: 20,
blockGeneralCreate: 2,
autorunUser: {
limitMinutes: 720,
options: ['develop', 'recruit_high', 'chief'],
},
},
},
durationMinutes: undefined,
reason: '운영 중 규칙 변경',
requestedBy: harness.admin.id,
},
]);
});
it('rejects a live game setting change without a reason or running database', async () => {
const running = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' });
await expect(
running.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'UPDATE_RUNTIME_SETTINGS',
runtimeSettings: { turnTermMinutes: 20 },
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '변경 사유를 입력해 주세요.' });
const stopped = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'STOPPED' });
await expect(
stopped.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'UPDATE_RUNTIME_SETTINGS',
runtimeSettings: { blockGeneralCreate: 1 },
reason: '운영 정책 변경',
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '실행 중인 프로필에서만 현재 기수 설정을 바꿀 수 있습니다.',
});
expect(running.createdRuntimeActions).toEqual([]);
expect(stopped.createdRuntimeActions).toEqual([]);
});
it('rejects a scheduled clock shift instead of silently applying it immediately', async () => {
const harness = await buildCaller(unusedCreateOperation);