feat: 실행 중 게임 옵션 변경을 지원
Gateway 관리 화면에서 현재 기수의 장수 생성 제한, 유저 자동턴, 턴 간격을 내구성 action으로 변경한다. 턴 간격 변경은 논리 tick과 현재 게임 시각을 보존하며 DB와 Redis의 tick 기반 시각을 재투영하고 기존 로그 timestamp는 유지한다.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user