import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; import { SystemClock } from '../lifecycle/clock.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js'; import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js'; import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js'; import { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { createDatabaseTurnHooks } from './databaseHooks.js'; import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js'; import { InMemoryTurnWorld } from './inMemoryWorld.js'; import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js'; import { InMemoryTurnStateStore } from './inMemoryStateStore.js'; import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js'; import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js'; import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js'; import { createMonthlyWanderHandler } from './monthlyWanderHandler.js'; import { createMonthlyDiplomacyHandler, createMonthlyNationCountHandler, createMonthlyNationStatsHandler, createMonthlyWarSettingHandler, } from './monthlyNationStatsHandler.js'; import { createFrontStateHandler } from './frontStateHandler.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; import { createTurnDaemonCommandHandler } from './worldCommandHandler.js'; import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; import { shouldUseAi } from './ai/generalAi.js'; import { createUnificationHandler } from './unificationHandler.js'; import { createAuctionFinalizer } from '../auction/finalizer.js'; import { createAuctionBidder } from '../auction/bidder.js'; import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js'; import { createTournamentRewardFinalizer } from '../tournament/finalizer.js'; import { createTournamentAutoStartHandler } from './tournamentAutoStart.js'; import { createYearbookHandler } from './yearbookHandler.js'; import { createMonthlyEventHandler, createRandomizeCityTradeRateHandler, type MonthlyEventActionHandler, } from './monthlyEventHandler.js'; import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js'; import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js'; import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js'; import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js'; import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js'; import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js'; import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js'; import { createRaiseNpcNationHandler } from './monthlyRaiseNpcNationAction.js'; import { createAutoDeleteInvaderHandler, createInvaderEndingHandler, createRaiseInvaderHandler, } from './monthlyInvaderAction.js'; import { createChangeCityHandler } from './monthlyChangeCityAction.js'; import { createProvideNpcTroopLeaderHandler } from './monthlyProvideNpcTroopLeaderAction.js'; import { createFinishNationBettingHandler, createOpenNationBettingHandler } from './monthlyNationBettingAction.js'; import { createScoutBlockHandler } from './monthlyScoutBlockAction.js'; import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js'; import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js'; import { createNewYearHandler, createNoticeToHistoryLogHandler, createProcessIncomeActionHandler, createResetOfficerLockHandler, } from './monthlyCoreEventAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { profile: string; profileName?: string; databaseUrl: string; gatewayDatabaseUrl?: string; defaultBudget?: TurnRunBudget; clock?: Clock; controlQueue?: TurnDaemonControlQueue; schedule?: TurnSchedule; tickMinutes?: number; mapOptions?: MapLoaderOptions; generalTurnHandler?: GeneralTurnHandler; calendarHandler?: TurnCalendarHandler; enableDatabaseFlush?: boolean; pauseGateIntervalMs?: number; commandProfile?: TurnCommandProfile; commandProfilePath?: string; adminActionIntervalMs?: number; redisUrl?: string; commandStreamStartId?: string; leaseDurationMs?: number; leaseOwnerId?: string; enableLeaseHeartbeat?: boolean; } export interface TurnDaemonRuntime { lifecycle: TurnDaemonLifecycle; world: InMemoryTurnWorld; controlQueue: TurnDaemonControlQueue; stateStore: InMemoryTurnStateStore; processor: InMemoryTurnProcessor; hooks?: TurnDaemonHooks; close(): Promise; } const resolveTickMinutes = (tickSeconds: number, override?: number): number => { if (override !== undefined) { return Math.max(1, override); } return Math.max(1, Math.round(tickSeconds / 60)); }; const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({ entries: [{ startMinute: 0, tickMinutes }], }); const loadOccupiedAuctionUniqueCounts = async (databaseUrl: string): Promise> => { const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const rows = await connector.prisma.auction.findMany({ where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] }, targetCode: { not: null }, }, select: { targetCode: true }, }); const counts = new Map(); for (const row of rows) { if (row.targetCode) { counts.set(row.targetCode, (counts.get(row.targetCode) ?? 0) + 1); } } return counts; } finally { await connector.disconnect(); } }; const loadArchivedNationMaxId = async (databaseUrl: string, serverId: string): Promise => { const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); try { const row = await connector.prisma.oldNation.aggregate({ where: { serverId }, _max: { nation: true }, }); return row._max.nation ?? 0; } finally { await connector.disconnect(); } }; const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => { if (redisUrl) { return { url: redisUrl }; } if (!env.REDIS_URL) { return null; } return resolveRedisConfigFromEnv(env); }; const createTurnDaemonRuntimeWithLease = async ( options: TurnDaemonRuntimeOptions, databaseFlushEnabled: boolean, turnDaemonLease: DatabaseTurnDaemonLease | null ): Promise => { // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. const { state, snapshot } = await loadTurnWorldFromDatabase({ databaseUrl: options.databaseUrl, mapOptions: options.mapOptions, }); const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state; const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); const hasEventAction = (name: string): boolean => snapshot.events.some( (event) => Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name) ); const eventRequiresReservedTurns = hasEventAction('UpdateNationLevel') || hasEventAction('CreateManyNPC') || hasEventAction('RegNPC') || hasEventAction('RegNeutralNPC') || hasEventAction('RaiseNPCNation') || hasEventAction('RaiseInvader') || hasEventAction('AutoDeleteInvader') || hasEventAction('ProvideNPCTroopLeader'); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null : await createReservedTurnStore({ databaseUrl: options.databaseUrl, }); const commandProfile = options.commandProfile ?? (options.commandProfilePath ? await loadTurnCommandProfile({ filePath: options.commandProfilePath, }) : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; let redisConnector: ReturnType | null = null; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet); const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); const unification = options.calendarHandler ? null : createUnificationHandler({ databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, getWorld: () => worldRef, }); const incomeHandler = createIncomeHandler({ getWorld: () => worldRef, scenarioConfig: snapshot.scenarioConfig, nationTraits: nationTraitMap, }); const eventActions = new Map(); eventActions.set( 'RandomizeCityTradeRate', createRandomizeCityTradeRateHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'RaiseDisaster', createRaiseDisasterHandler({ getWorld: () => worldRef, generalActionModules: monthlyActionModules.general, }) ); eventActions.set( 'UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: () => worldRef, map: snapshot.map, }) ); eventActions.set( 'ProcessSemiAnnual', createProcessSemiAnnualHandler({ getWorld: () => worldRef, nationTraits: nationTraitMap, }) ); eventActions.set( 'ProcessWarIncome', createProcessWarIncomeHandler({ getWorld: () => worldRef, nationTraits: nationTraitMap, }) ); eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler()); if (reservedTurnStoreHandle) { eventActions.set( 'CreateManyNPC', createCreateManyNpcHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, }) ); for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) { eventActions.set( actionName, createRegisterNpcHandler({ actionName, getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, worldConfig: snapshot.worldConfig, scenarioFiction: snapshot.scenarioMeta?.fiction, }) ); } eventActions.set( 'RaiseNPCNation', createRaiseNpcNationHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, map: snapshot.map, loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), }) ); eventActions.set( 'RaiseInvader', createRaiseInvaderHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), }) ); eventActions.set( 'AutoDeleteInvader', createAutoDeleteInvaderHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, }) ); eventActions.set( 'ProvideNPCTroopLeader', createProvideNpcTroopLeaderHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, env: monthlyCommandEnv, }) ); eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ getWorld: () => worldRef, reservedTurns: reservedTurnStoreHandle.store, itemModules: monthlyActionModules.itemModules, loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl), }) ); } eventActions.set( 'InvaderEnding', createInvaderEndingHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'ChangeCity', createChangeCityHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'OpenNationBetting', createOpenNationBettingHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'FinishNationBetting', createFinishNationBettingHandler({ getWorld: () => worldRef, }) ); for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) { eventActions.set( actionName, createScoutBlockHandler({ actionName, getWorld: () => worldRef, }) ); } eventActions.set( 'AssignGeneralSpeciality', createAssignGeneralSpecialityHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'AddGlobalBetray', createAddGlobalBetrayHandler({ getWorld: () => worldRef, }) ); eventActions.set( 'LostUniqueItem', createLostUniqueItemHandler({ getWorld: () => worldRef, itemModules: monthlyActionModules.itemModules, }) ); eventActions.set( 'MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: () => worldRef, }) ); eventActions.set('ProcessIncome', createProcessIncomeActionHandler(incomeHandler)); eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => worldRef })); eventActions.set('NewYear', createNewYearHandler({ getWorld: () => worldRef })); eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => worldRef })); const monthlyEventHandler = createMonthlyEventHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, actions: eventActions, }); const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({ getWorld: () => worldRef, }); const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, commandEnv: monthlyCommandEnv, }); let monthlyNationPowerRollCount = snapshot.nations.length; let monthlyTournamentRollConsumed = false; const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ getWorld: () => worldRef, onNationPowerRollCount: (count) => { monthlyNationPowerRollCount = count; }, }); const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({ getWorld: () => worldRef, }); const monthlyNationCountHandler = createMonthlyNationCountHandler({ getWorld: () => worldRef, }); const monthlyWarSettingHandler = createMonthlyWarSettingHandler({ getWorld: () => worldRef, }); const monthlyWanderHandler = createMonthlyWanderHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, commandEnv: monthlyCommandEnv, }); const frontStateHandler = createFrontStateHandler({ getWorld: () => worldRef, map: snapshot.map ?? null, }); const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({ databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, getWorld: () => worldRef, getRedisClient: () => redisConnector?.client, getWorldConfig: () => snapshot.worldConfig ?? null, getNationPowerRollCount: () => monthlyNationPowerRollCount, getTournamentRollConsumed: () => monthlyTournamentRollConsumed, }); const tournamentAutoStartHandler = createTournamentAutoStartHandler({ profileName: options.profileName ?? options.profile, getWorld: () => worldRef, getRedisClient: () => redisConnector?.client, getWorldConfig: () => snapshot.worldConfig ?? null, getNationPowerRollCount: () => monthlyNationPowerRollCount, onTournamentRollConsumed: (consumed) => { monthlyTournamentRollConsumed = consumed; }, }); const yearbookHandler = createYearbookHandler({ databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, getWorld: () => worldRef, }); const calendarHandler = composeCalendarHandlers( monthlyEventHandler, hasEventAction('ProcessIncome') ? null : incomeHandler, yearbookHandler.handler, monthlyBoundaryPreHandler, nationTurnMonthlyHandler, monthlyNationStatsHandler, monthlyDiplomacyHandler, monthlyWarSettingHandler, monthlyWanderHandler, monthlyNationCountHandler, options.calendarHandler ?? unification?.handler, tournamentAutoStartHandler, neutralAuctionRegistrar.handler, frontStateHandler ); let occupiedAuctionUniqueItemKeys: string[] = []; let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: options.generalTurnHandler ?? (await createReservedTurnHandler({ reservedTurns: reservedTurnStoreHandle!.store, scenarioConfig: snapshot.scenarioConfig, scenarioMeta: snapshot.scenarioMeta, map: snapshot.map, unitSet: snapshot.unitSet, getWorld: () => worldRef, commandProfile, commandEnv: monthlyCommandEnv, getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, })), calendarHandler: calendarHandler ?? undefined, autoAdvanceDiplomacyMonth: false, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; const stateStore = new InMemoryTurnStateStore(world); const prefetchedNationTurns = new Set(); const processor = new InMemoryTurnProcessor(world, { tickMinutes, beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { const promises: Promise[] = []; promises.push( reservedTurnStoreHandle.store.prepareTurnsForExecution( general.id, general.nationId > 0 && general.officerLevel >= 5 ? { nationId: general.nationId, officerLevel: general.officerLevel, } : undefined ) ); promises.push(refreshOccupiedAuctionUniqueItemKeys()); if (general.nationId > 0 && general.officerLevel >= 5 && shouldUseAi(general, world.getState())) { const key = `${general.nationId}:${world.getState().currentYear}:${world.getState().currentMonth}`; if (!prefetchedNationTurns.has(key)) { prefetchedNationTurns.add(key); const generalIds = world .listGenerals() .filter((candidate) => candidate.nationId === general.nationId) .map((candidate) => candidate.id); promises.push(reservedTurnStoreHandle.store.prefetchGeneralTurns(generalIds)); } } await Promise.all(promises); } : undefined, }); const controlQueue = options.controlQueue ?? new InMemoryControlQueue(); const clock = options.clock ?? new SystemClock(); let hooks: TurnDaemonHooks | undefined; let publishRealtimeEvent: ((event: RealtimeEvent) => Promise) | null = null; let close = async () => {}; let auctionFinalizer: Awaited> | null = null; let auctionBidder: Awaited> | null = null; let tournamentRewardFinalizer: Awaited> | null = null; let pauseGate: (() => Promise) | undefined; let adminActionConsumer: Awaited> | null = null; const gatewayGate = options.profileName ? await createGatewayProfileGate({ databaseUrl: options.databaseUrl, gatewayDatabaseUrl: options.gatewayDatabaseUrl, profileName: options.profileName, cacheMs: options.pauseGateIntervalMs, }) : null; if (gatewayGate) { pauseGate = gatewayGate.shouldPause; } if (databaseFlushEnabled) { const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, { reservedTurns: reservedTurnStoreHandle?.store, turnDaemonLease: turnDaemonLease ?? undefined, }); auctionBidder = await createAuctionBidder({ databaseUrl: options.databaseUrl, world, }); auctionFinalizer = await createAuctionFinalizer({ databaseUrl: options.databaseUrl, world, }); tournamentRewardFinalizer = await createTournamentRewardFinalizer({ databaseUrl: options.databaseUrl, world, }); hooks = { ...dbHooks.hooks, onRunError: async (error) => { await dbHooks.hooks.onRunError?.(error); await gatewayGate?.markPaused(error); }, }; close = async () => { if (auctionBidder) { await auctionBidder.close(); } if (auctionFinalizer) { await auctionFinalizer.close(); } if (tournamentRewardFinalizer) { await tournamentRewardFinalizer.close(); } await dbHooks.close(); if (reservedTurnStoreHandle) { await reservedTurnStoreHandle.close(); } await gatewayGate?.close(); await adminActionConsumer?.stop(); await turnDaemonLease?.close(); }; } else if (reservedTurnStoreHandle) { hooks = { onRunError: async (error) => { await gatewayGate?.markPaused(error); }, }; close = async () => { await reservedTurnStoreHandle.close(); await gatewayGate?.close(); await adminActionConsumer?.stop(); }; } else if (gatewayGate) { hooks = { onRunError: async (error) => { await gatewayGate?.markPaused(error); }, }; close = async () => { await gatewayGate.close(); await adminActionConsumer?.stop(); }; } else if (adminActionConsumer) { close = async () => { await adminActionConsumer?.stop(); }; } const redisConfig = resolveRedisConfig(options.redisUrl); if (redisConfig) { redisConnector = createRedisConnector(redisConfig); await redisConnector.connect(); const redisClient = redisConnector.client; const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile); publishRealtimeEvent = async (event: RealtimeEvent) => { await redisClient.publish(realtimeChannel, JSON.stringify(event)); }; } if (publishRealtimeEvent) { const basePublishEvents = hooks?.publishEvents; // 턴 처리 완료 이벤트를 실시간 채널로 전파한다. hooks = { ...hooks, publishEvents: async (result) => { try { await publishRealtimeEvent({ type: 'turnCompleted', at: new Date().toISOString(), lastTurnTime: result.lastTurnTime, }); } catch { // 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다. } await basePublishEvents?.(result); }, }; } const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null; const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null; if (commandConnector && databaseCommandQueue) { await commandConnector.connect(); await databaseCommandQueue.initialize(); refreshOccupiedAuctionUniqueItemKeys = async () => { const rows = await commandConnector.prisma.auction.findMany({ where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] }, targetCode: { not: null }, }, select: { targetCode: true }, }); occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : [])); }; } const baseClose = close; close = async () => { await baseClose(); await neutralAuctionRegistrar.close(); if (unification) { await unification.close(); } await yearbookHandler.close(); if (redisConnector) { await redisConnector.disconnect(); } await commandConnector?.disconnect(); }; const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue; const commandHandler = createTurnDaemonCommandHandler({ world, auctionFinalizer: auctionFinalizer ?? undefined, auctionBidder: auctionBidder ?? undefined, tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined, }); const defaultBudget: TurnRunBudget = options.defaultBudget ?? { budgetMs: 5000, maxGenerals: 200, catchUpCap: 1, }; const lifecycle = new TurnDaemonLifecycle( { clock, controlQueue: resolvedControlQueue, getNextTickTime: (lastTurnTime) => getNextTickTime(lastTurnTime, tickMinutes), stateStore, processor, hooks, pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false), commandHandler, commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined), }, { profile: options.profile, defaultBudget } ); if (options.profileName) { adminActionConsumer = await createGatewayAdminActionConsumer({ databaseUrl: options.databaseUrl, gatewayDatabaseUrl: options.gatewayDatabaseUrl, profileName: options.profileName, pollIntervalMs: options.adminActionIntervalMs, handler: async (action) => { const reason = action.reason ?? `admin:${action.action ?? 'action'}`; if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { // 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다. return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; } switch (action.action) { case 'RESUME': resolvedControlQueue.enqueue({ type: 'resume', reason }); return { status: 'APPLIED', detail: 'resume queued' }; case 'PAUSE': resolvedControlQueue.enqueue({ type: 'pause', reason }); return { status: 'APPLIED', detail: 'pause queued' }; case 'STOP': case 'SHUTDOWN': resolvedControlQueue.enqueue({ type: 'shutdown', reason }); return { status: 'APPLIED', detail: 'shutdown queued' }; default: return { status: 'IGNORED', detail: 'not implemented' }; } }, }); adminActionConsumer.start(); } return { lifecycle, world, controlQueue: resolvedControlQueue, stateStore, processor, hooks, close, }; }; export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise => { const databaseFlushEnabled = options.enableDatabaseFlush ?? true; const turnDaemonLease = databaseFlushEnabled ? await DatabaseTurnDaemonLease.connect(options.databaseUrl, { profile: options.profileName ?? options.profile, ownerId: options.leaseOwnerId, leaseDurationMs: options.leaseDurationMs, heartbeat: options.enableLeaseHeartbeat, }) : null; if (turnDaemonLease && !(await turnDaemonLease.acquire())) { await turnDaemonLease.close(); throw new TurnDaemonLeaseUnavailableError(options.profileName ?? options.profile); } try { return await createTurnDaemonRuntimeWithLease(options, databaseFlushEnabled, turnDaemonLease); } catch (error) { await turnDaemonLease?.close(); throw error; } };