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
@@ -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;