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
+1
View File
@@ -124,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
const action = (rawInput as { action?: unknown }).action;
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
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';
}
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
+63 -3
View File
@@ -41,6 +41,7 @@ const zServerAction = z.enum([
'STOP',
'ACCELERATE',
'DELAY',
'UPDATE_RUNTIME_SETTINGS',
'RESET_NOW',
'RESET_SCHEDULED',
'OPEN_SURVEY',
@@ -440,6 +441,30 @@ const zInstallAutorun = z.object({
});
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
const zRuntimeSettings = z
.object({
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: 'turnTermMinutes must divide 120.',
})
.optional(),
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
autorunUser: z
.object({
limitMinutes: z.number().int().min(1).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)).min(1),
})
.nullable()
.optional(),
})
.strict()
.refine((settings) => Object.values(settings).some((value) => value !== undefined), {
message: 'At least one runtime setting is required.',
});
const isUniqueConstraintError = (error: unknown): boolean =>
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
@@ -1596,7 +1621,7 @@ export const adminRouter = router({
canReadProfile(adminAuth, profile.profileName)
);
const profileNames = profiles.map((profile) => profile.profileName);
const [runtimeActions, activeOperations] = await Promise.all([
const [runtimeActions, activeOperations, runtimeSettings] = await Promise.all([
ctx.prisma.gatewayRuntimeAction.findMany({
where: { profileName: { in: profileNames } },
orderBy: { createdAt: 'desc' },
@@ -1608,6 +1633,7 @@ export const adminRouter = router({
},
select: { id: true, profileName: true, status: true },
}),
ctx.orchestrator.listRuntimeSettings?.(profileNames) ?? Promise.resolve([]),
]);
const activeOperationByProfile = new Map(
activeOperations.map((operation) => [operation.profileName, operation])
@@ -1624,9 +1650,11 @@ export const adminRouter = router({
profiles.map((profile) => profile.profileName)
);
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings]));
return profiles.map((profile) => ({
...profile,
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
runtime: runtimeMap.get(profile.profileName) ?? {
profileName: profile.profileName,
@@ -2014,6 +2042,7 @@ export const adminRouter = router({
profileName: z.string().min(1),
action: zServerAction,
durationMinutes: z.number().int().min(1).max(1440).optional(),
runtimeSettings: zRuntimeSettings.optional(),
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
})
@@ -2032,6 +2061,19 @@ export const adminRouter = router({
message: 'durationMinutes is required for acceleration or delay.',
});
}
if (input.action === 'UPDATE_RUNTIME_SETTINGS') {
if (!input.runtimeSettings) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'runtimeSettings is required.' });
}
if (!input.reason || input.reason.trim().length < 3) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '변경 사유를 입력해 주세요.' });
}
} else if (input.runtimeSettings) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'runtimeSettings is not valid for this action.',
});
}
if (input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -2092,6 +2134,19 @@ export const adminRouter = router({
message: 'Survey permission is required.',
});
}
} else if (input.action === 'UPDATE_RUNTIME_SETTINGS') {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '실행 중인 프로필에서만 현재 기수 설정을 바꿀 수 있습니다.',
});
}
if (!canManageProfiles) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Profile management permission is required.',
});
}
} else if (!canManageProfiles) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -2106,12 +2161,17 @@ export const adminRouter = router({
});
}
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
if (
input.action === 'ACCELERATE' ||
input.action === 'DELAY' ||
input.action === 'UPDATE_RUNTIME_SETTINGS'
) {
try {
const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({
data: {
profileName: input.profileName,
action: input.action,
payload: input.runtimeSettings ? { settings: input.runtimeSettings } : {},
durationMinutes: input.durationMinutes,
reason: input.reason,
requestedBy: adminAuth.user.id,
@@ -2124,7 +2184,7 @@ export const adminRouter = router({
}
throw new TRPCError({
code: 'CONFLICT',
message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.',
message: '이 프로필의 이전 런타임 변경 요청이 아직 처리 중입니다.',
});
}
}
@@ -70,6 +70,23 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
profileName: string;
}
export interface ProfileRuntimeSettingsSnapshot {
profileName: string;
turnTermMinutes: number;
blockGeneralCreate: 0 | 1 | 2;
autorunUser: {
limitMinutes: number;
options: Array<'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief'>;
} | null;
}
type RuntimeAutorunOption =
NonNullable<ProfileRuntimeSettingsSnapshot['autorunUser']> extends {
options: Array<infer Option>;
}
? Option
: never;
export interface GatewayOrchestratorHandle {
start(): void;
stop(): Promise<void>;
@@ -82,6 +99,7 @@ export interface GatewayOrchestratorHandle {
skipped: string[];
}>;
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
}
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
@@ -706,6 +724,67 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return mapRuntimeStates(profileNames, processStates);
}
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
const allowedAutorunOptions = new Set<RuntimeAutorunOption>([
'develop',
'warp',
'recruit',
'recruit_high',
'train',
'battle',
'chief',
] as const);
const snapshots = await Promise.all(
profileNames.map(async (profileName): Promise<ProfileRuntimeSettingsSnapshot | null> => {
const profile = await this.repository.getProfile(profileName);
if (!profile || profile.currentScenario === null) return null;
const connector = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
try {
await connector.connect();
const row = await connector.prisma.worldState.findFirst({
select: { tickSeconds: true, config: true, meta: true },
});
if (!row) return null;
const config = isRecord(row.config) ? row.config : {};
const meta = isRecord(row.meta) ? row.meta : {};
const rawBlock = Number(config.blockGeneralCreate ?? 0);
const blockGeneralCreate = ([0, 1, 2].includes(rawBlock) ? rawBlock : 0) as 0 | 1 | 2;
const rawAutorun = isRecord(meta.autorun_user) ? meta.autorun_user : null;
const limitMinutes = rawAutorun ? Number(rawAutorun.limit_minutes ?? 0) : 0;
const rawOptions = rawAutorun
? Array.isArray(rawAutorun.options)
? rawAutorun.options
: isRecord(rawAutorun.options)
? Object.entries(rawAutorun.options)
.filter(([, enabled]) => enabled === true)
.map(([option]) => option)
: []
: [];
const autorunOptions = rawOptions.filter(
(
option
): option is 'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief' =>
typeof option === 'string' && allowedAutorunOptions.has(option as RuntimeAutorunOption)
);
return {
profileName,
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
blockGeneralCreate,
autorunUser:
Number.isInteger(limitMinutes) && limitMinutes > 0 && autorunOptions.length > 0
? { limitMinutes, options: autorunOptions }
: null,
};
} catch {
return null;
} finally {
await connector.disconnect().catch(() => undefined);
}
})
);
return snapshots.filter((snapshot): snapshot is ProfileRuntimeSettingsSnapshot => snapshot !== null);
}
async reconcileNow(): Promise<void> {
if (this.stopping || this.reconcileInFlight) {
return;
+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);