import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; import { buildGameEventChannel, buildGameReadModelDomainRevisionKey, buildGameReadModelRevisionKey, createEmptyRealtimeReadModelChanges, GameClock, hasRealtimeReadModelChanges, SystemClock, type GameClockMode, type RealtimeEvent, type RealtimeReadModelChanges, } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; 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, type CommittedReadModelChangeReceipt } 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 { calculateNpcNationFinance } from './npcTaxHandler.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 { loadPendingUnificationAuctionCancellations } from './unificationAuctionCancellation.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'; import { EngineStateManager } from './engineStateManager.js'; import { applyRuntimeClockShift } from './runtimeClockShift.js'; export interface TurnDaemonRuntimeOptions { profile: string; profileName?: string; databaseUrl: string; gatewayDatabaseUrl?: string; defaultBudget?: TurnRunBudget; clock?: Clock; gameClockMode?: GameClockMode; 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; /** * Isolated, single-process fixture acceleration only. Reserved turns are * loaded once and no concurrent API writer may touch this database. */ exclusiveFastForward?: boolean; databaseTransactionTimeoutMs?: number; onActionResolved?: NonNullable[0]['onActionResolved']>; } export interface TurnDaemonRuntime { lifecycle: TurnDaemonLifecycle; world: InMemoryTurnWorld; controlQueue: TurnDaemonControlQueue; stateStore: InMemoryTurnStateStore; stateManager: EngineStateManager; 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); }; type LoadedTurnWorld = Awaited>; type MonthlyActionModuleBundle = Awaited>; type NationTraitModuleMap = Map>[number]>; type ReservedTurnStoreHandle = Awaited>; type RedisConnector = ReturnType; type GamePostgresConnector = ReturnType; const resolveRuntimeState = ( state: LoadedTurnWorld['state'], options: Pick, clock: Clock ) => { const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); const nextTickSeconds = tickMinutes * 60; const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds; const clockBaseTime = tickSecondsChanged ? GameClock.baseTimeForProjection( new GameClock({ baseTime: state.clockBaseTime ?? state.lastTurnTime, tick: state.clockTick ?? state.lastTurnTick ?? 0, mode: state.clockMode ?? 'manual', wallAnchor: state.clockWallAnchor ?? state.lastTurnTime, turnSeconds: state.tickSeconds, }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0), state.clockTick ?? state.lastTurnTick ?? 0, nextTickSeconds ) : state.clockBaseTime; const modeChanged = options.gameClockMode !== undefined && options.gameClockMode !== state.clockMode; const resolvedState = { ...state, ...(options.tickMinutes ? { tickSeconds: nextTickSeconds, clockBaseTime } : {}), ...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}), ...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}), }; return { tickMinutes, resolvedState }; }; const hasMonthlyEventAction = (events: LoadedTurnWorld['snapshot']['events'], name: string): boolean => events.some( (event) => Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name) ); const requiresReservedTurnStore = (events: LoadedTurnWorld['snapshot']['events']): boolean => [ 'UpdateNationLevel', 'CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'AutoDeleteInvader', 'ProvideNPCTroopLeader', ].some((name) => hasMonthlyEventAction(events, name)); const createMonthlyEventActions = (options: { databaseUrl: string; snapshot: LoadedTurnWorld['snapshot']; getWorld: () => InMemoryTurnWorld | null; reservedTurnStoreHandle: ReservedTurnStoreHandle | null; commandEnv: ReturnType; actionModules: MonthlyActionModuleBundle; nationTraits: NationTraitModuleMap; incomeHandler: ReturnType; }): Map => { const eventActions = new Map(); eventActions.set( 'RandomizeCityTradeRate', createRandomizeCityTradeRateHandler({ getWorld: options.getWorld, }) ); eventActions.set( 'RaiseDisaster', createRaiseDisasterHandler({ getWorld: options.getWorld, generalActionModules: options.actionModules.general, }) ); eventActions.set( 'UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: options.getWorld, map: options.snapshot.map, }) ); eventActions.set( 'ProcessSemiAnnual', createProcessSemiAnnualHandler({ getWorld: options.getWorld, nationTraits: options.nationTraits, }) ); eventActions.set( 'ProcessWarIncome', createProcessWarIncomeHandler({ getWorld: options.getWorld, nationTraits: options.nationTraits, }) ); eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler()); if (options.reservedTurnStoreHandle) { const reservedTurns = options.reservedTurnStoreHandle.store; eventActions.set( 'CreateManyNPC', createCreateManyNpcHandler({ getWorld: options.getWorld, reservedTurns, env: options.commandEnv, }) ); for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) { eventActions.set( actionName, createRegisterNpcHandler({ actionName, getWorld: options.getWorld, reservedTurns, env: options.commandEnv, worldConfig: options.snapshot.worldConfig, scenarioFiction: options.snapshot.scenarioMeta?.fiction, }) ); } eventActions.set( 'RaiseNPCNation', createRaiseNpcNationHandler({ getWorld: options.getWorld, reservedTurns, env: options.commandEnv, map: options.snapshot.map, loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), }) ); eventActions.set( 'RaiseInvader', createRaiseInvaderHandler({ getWorld: options.getWorld, reservedTurns, env: options.commandEnv, loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), }) ); eventActions.set( 'AutoDeleteInvader', createAutoDeleteInvaderHandler({ getWorld: options.getWorld, reservedTurns, }) ); eventActions.set( 'ProvideNPCTroopLeader', createProvideNpcTroopLeaderHandler({ getWorld: options.getWorld, reservedTurns, env: options.commandEnv, }) ); eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ getWorld: options.getWorld, reservedTurns, itemModules: options.actionModules.itemModules, loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl), }) ); } eventActions.set('InvaderEnding', createInvaderEndingHandler({ getWorld: options.getWorld })); eventActions.set('ChangeCity', createChangeCityHandler({ getWorld: options.getWorld })); eventActions.set('OpenNationBetting', createOpenNationBettingHandler({ getWorld: options.getWorld })); eventActions.set('FinishNationBetting', createFinishNationBettingHandler({ getWorld: options.getWorld })); for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) { eventActions.set( actionName, createScoutBlockHandler({ actionName, getWorld: options.getWorld, }) ); } eventActions.set('AssignGeneralSpeciality', createAssignGeneralSpecialityHandler({ getWorld: options.getWorld })); eventActions.set('AddGlobalBetray', createAddGlobalBetrayHandler({ getWorld: options.getWorld })); eventActions.set( 'LostUniqueItem', createLostUniqueItemHandler({ getWorld: options.getWorld, itemModules: options.actionModules.itemModules, }) ); eventActions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: options.getWorld })); eventActions.set('ProcessIncome', createProcessIncomeActionHandler(options.incomeHandler)); eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: options.getWorld })); eventActions.set('NewYear', createNewYearHandler({ getWorld: options.getWorld })); eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: options.getWorld })); return eventActions; }; interface MonthlyRuntimeCache { nationPowerRollCount: number; tournamentRollConsumed: boolean; } const createMonthlyCalendarRuntime = async (options: { databaseUrl: string; profileName: string; databaseFlushEnabled: boolean; snapshot: LoadedTurnWorld['snapshot']; currentYear: number; commandEnv: ReturnType; incomeHandler: ReturnType; monthlyEventHandler: ReturnType; hasEventAction: (name: string) => boolean; calendarHandlerOverride?: TurnCalendarHandler; getWorld: () => InMemoryTurnWorld | null; getRedisClient: () => ReturnType['client'] | undefined; clock: Clock; }) => { const cache: MonthlyRuntimeCache = { nationPowerRollCount: options.snapshot.nations.length, tournamentRollConsumed: false, }; const unification = options.calendarHandlerOverride ? null : createUnificationHandler({ profileName: options.profileName, getWorld: options.getWorld, loadPendingUniqueAuctions: options.databaseFlushEnabled ? () => loadPendingUnificationAuctionCancellations(options.databaseUrl) : undefined, dispatchUnitedEvents: (context) => options.monthlyEventHandler.dispatchTarget('united', context), }); const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ getWorld: options.getWorld, startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, commandEnv: options.commandEnv, }); const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ getWorld: options.getWorld, onNationPowerRollCount: (count) => { cache.nationPowerRollCount = count; }, }); const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({ databaseUrl: options.databaseUrl, profileName: options.profileName, getWorld: options.getWorld, getRedisClient: options.getRedisClient, getWorldConfig: () => options.snapshot.worldConfig ?? null, getNationPowerRollCount: () => cache.nationPowerRollCount, getTournamentRollConsumed: () => cache.tournamentRollConsumed, now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()), }); const tournamentAutoStartHandler = createTournamentAutoStartHandler({ profileName: options.profileName, getWorld: options.getWorld, getRedisClient: options.getRedisClient, getWorldConfig: () => options.snapshot.worldConfig ?? null, getNationPowerRollCount: () => cache.nationPowerRollCount, onTournamentRollConsumed: (consumed) => { cache.tournamentRollConsumed = consumed; }, // Deterministic/manual runtimes must schedule the tournament from the // same clock that advances the game world. Production still falls // back to the system clock. now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()), }); const calendarHandler = composeCalendarHandlers( options.monthlyEventHandler, options.hasEventAction('ProcessIncome') ? null : options.incomeHandler, createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler, monthlyBoundaryPreHandler, createNationTurnMonthlyHandler({ getWorld: options.getWorld }), monthlyNationStatsHandler, createMonthlyDiplomacyHandler({ getWorld: options.getWorld }), createMonthlyWarSettingHandler({ getWorld: options.getWorld }), createMonthlyWanderHandler({ getWorld: options.getWorld, startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, commandEnv: options.commandEnv, }), createMonthlyNationCountHandler({ getWorld: options.getWorld }), options.calendarHandlerOverride ?? unification?.handler, tournamentAutoStartHandler, neutralAuctionRegistrar.handler, createFrontStateHandler({ getWorld: options.getWorld, map: options.snapshot.map ?? null }) ); return { calendarHandler, neutralAuctionRegistrar, cache }; }; const createRealtimeRuntime = async (options: { redisUrl?: string; profileName: string; hooks?: TurnDaemonHooks; takeCommittedReadModelChangeReceipt: (() => CommittedReadModelChangeReceipt | null) | null; }): Promise<{ redisConnector: RedisConnector | null; hooks?: TurnDaemonHooks }> => { const redisConfig = resolveRedisConfig(options.redisUrl); if (!redisConfig) { return { redisConnector: null, hooks: options.hooks }; } const redisConnector = createRedisConnector(redisConfig); await redisConnector.connect(); const redisClient = redisConnector.client; const realtimeChannel = buildGameEventChannel(options.profileName); const revisionKey = buildGameReadModelRevisionKey(options.profileName); const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName); const publishRealtimeEvent = async (event: RealtimeEvent): Promise => { await redisClient.publish(realtimeChannel, JSON.stringify(event)); }; const publishReadModelChanges = async (changes: RealtimeReadModelChanges): Promise => { if ( changes.worldChanged || (changes.mapCityIds ?? changes.cityIds).length > 0 || (changes.mapNationIds ?? changes.nationIds).length > 0 ) { await redisClient.hIncrBy(domainRevisionKey, 'world', 1); } return redisClient.incr(revisionKey); }; const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise => { if (!hasRealtimeReadModelChanges(changes)) { return undefined; } return publishReadModelChanges(changes); }; const basePublishEvents = options.hooks?.publishEvents; const basePublishCommandEvents = options.hooks?.publishCommandEvents; const hooks: TurnDaemonHooks = { ...options.hooks, publishEvents: async (result) => { try { const changes = options.takeCommittedReadModelChangeReceipt?.()?.changes ?? createEmptyRealtimeReadModelChanges(); const revision = await publishCommittedChanges(changes); await publishRealtimeEvent({ type: 'turnCompleted', at: new Date().toISOString(), lastTurnTime: result.lastTurnTime, changes, revision, }); } catch { // 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다. } await basePublishEvents?.(result); }, publishCommandEvents: async (result) => { try { const changes = options.takeCommittedReadModelChangeReceipt?.()?.changes; if (changes && hasRealtimeReadModelChanges(changes)) { const revision = await publishCommittedChanges(changes); if (revision !== undefined) { await publishRealtimeEvent({ type: 'readModelChanged', at: new Date().toISOString(), changes, revision, }); } } } catch { // 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다. } await basePublishCommandEvents?.(result); }, }; return { redisConnector, hooks }; }; const createStartedAdminActionConsumer = async (options: { runtimeOptions: TurnDaemonRuntimeOptions; turnDaemonLease: DatabaseTurnDaemonLease | null; commandConnector: GamePostgresConnector | null; redisConnector: RedisConnector | null; controlQueue: TurnDaemonControlQueue; }) => { const profileName = options.runtimeOptions.profileName; if (!profileName) { return null; } const consumer = await createGatewayAdminActionConsumer({ databaseUrl: options.runtimeOptions.databaseUrl, gatewayDatabaseUrl: options.runtimeOptions.gatewayDatabaseUrl, profileName, pollIntervalMs: options.runtimeOptions.adminActionIntervalMs, handler: async (action) => { const reason = action.reason ?? `admin:${action.action ?? 'action'}`; if (options.turnDaemonLease?.isLost()) { return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' }; } if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; } if (action.action === 'ACCELERATE' || action.action === 'DELAY') { if (!options.commandConnector) { return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' }; } return applyRuntimeClockShift({ action, profileName, db: options.commandConnector.prisma, redis: options.redisConnector?.client, }); } switch (action.action) { case 'RESUME': options.controlQueue.enqueue({ type: 'resume', reason }); return { status: 'APPLIED', detail: 'resume queued' }; case 'PAUSE': options.controlQueue.enqueue({ type: 'pause', reason }); return { status: 'APPLIED', detail: 'pause queued' }; case 'STOP': case 'SHUTDOWN': options.controlQueue.enqueue({ type: 'shutdown', reason }); return { status: 'APPLIED', detail: 'shutdown queued' }; default: return { status: 'IGNORED', detail: 'not implemented' }; } }, }); consumer.start(); return consumer; }; const createTurnDaemonRuntimeWithLease = async ( options: TurnDaemonRuntimeOptions, databaseFlushEnabled: boolean, turnDaemonLease: DatabaseTurnDaemonLease | null ): Promise => { if (options.exclusiveFastForward && options.profileName) { throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.'); } // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. const { state, snapshot } = await loadTurnWorldFromDatabase({ databaseUrl: options.databaseUrl, mapOptions: options.mapOptions, }); const clock = options.clock ?? new SystemClock(); const { tickMinutes, resolvedState } = resolveRuntimeState(state, options, clock); const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); const hasEventAction = (name: string): boolean => hasMonthlyEventAction(snapshot.events, name); const eventRequiresReservedTurns = requiresReservedTurnStore(snapshot.events); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null : await createReservedTurnStore({ databaseUrl: options.databaseUrl, leaseOwner: options.leaseOwnerId, leaseDurationMs: options.leaseDurationMs, }); const commandProfile = options.commandProfile ?? (options.commandProfilePath ? await loadTurnCommandProfile({ filePath: options.commandProfilePath, }) : await loadTurnCommandProfile()); let worldRef: InMemoryTurnWorld | null = null; let redisConnector: RedisConnector | 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, snapshot.scenarioConfig.environment.scenarioEffect ); const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); const incomeHandler = createIncomeHandler({ getWorld: () => worldRef, scenarioConfig: snapshot.scenarioConfig, nationTraits: nationTraitMap, }); const eventActions = createMonthlyEventActions({ databaseUrl: options.databaseUrl, snapshot, getWorld: () => worldRef, reservedTurnStoreHandle, commandEnv: monthlyCommandEnv, actionModules: monthlyActionModules, nationTraits: nationTraitMap, incomeHandler, }); const monthlyEventHandler = createMonthlyEventHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, actions: eventActions, }); const { calendarHandler, neutralAuctionRegistrar, cache: monthlyRuntimeCache, } = await createMonthlyCalendarRuntime({ databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, databaseFlushEnabled, snapshot, currentYear: state.currentYear, commandEnv: monthlyCommandEnv, incomeHandler, monthlyEventHandler, hasEventAction, calendarHandlerOverride: options.calendarHandler, getWorld: () => worldRef, getRedisClient: () => redisConnector?.client, clock, }); let occupiedAuctionUniqueItemKeys: string[] = []; let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; const prefetchedNationTurns = new Set(); 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, calculateNpcNationFinance: (financeWorld, nation, currentMonth) => calculateNpcNationFinance(financeWorld, nation, currentMonth, { commandEnv: monthlyCommandEnv, scenarioConfig: snapshot.scenarioConfig, unitSet: snapshot.unitSet, nationTraits: nationTraitMap, }), getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, onActionResolved: options.onActionResolved, })), calendarHandler: calendarHandler ?? undefined, autoAdvanceDiplomacyMonth: false, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; const stateManager = new EngineStateManager(); stateManager.register('world', { capture: () => world.captureState(), restore: (captured) => world.restoreState(captured), inspect: () => world.inspectState(), }); if (reservedTurnStoreHandle) { stateManager.register('reservedTurns', { capture: () => reservedTurnStoreHandle.store.captureTransactionState(), restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured), inspect: () => reservedTurnStoreHandle.store.inspectState(), }); } stateManager.register('runtimeCaches', { capture: () => ({ monthlyNationPowerRollCount: monthlyRuntimeCache.nationPowerRollCount, monthlyTournamentRollConsumed: monthlyRuntimeCache.tournamentRollConsumed, occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys], prefetchedNationTurns: Array.from(prefetchedNationTurns), }), restore: (captured) => { monthlyRuntimeCache.nationPowerRollCount = captured.monthlyNationPowerRollCount; monthlyRuntimeCache.tournamentRollConsumed = captured.monthlyTournamentRollConsumed; occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys]; prefetchedNationTurns.clear(); for (const key of captured.prefetchedNationTurns) { prefetchedNationTurns.add(key); } }, }); const stateStore = new InMemoryTurnStateStore(world); let fastForwardPreparedMonth = ''; const processor = new InMemoryTurnProcessor(world, { tickMinutes, beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { if (options.exclusiveFastForward) { const state = world.getState(); const monthKey = `${state.currentYear}-${state.currentMonth}`; if (fastForwardPreparedMonth !== monthKey) { await refreshOccupiedAuctionUniqueItemKeys(); fastForwardPreparedMonth = monthKey; } return; } 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(); let hooks: TurnDaemonHooks | undefined; let takeCommittedReadModelChangeReceipt: (() => CommittedReadModelChangeReceipt | null) | 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, { profileName: options.profileName ?? options.profile, reservedTurns: reservedTurnStoreHandle?.store, turnDaemonLease: turnDaemonLease ?? undefined, transactionTimeoutMs: options.databaseTransactionTimeoutMs, }); 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); }, }; takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt; 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 realtimeRuntime = await createRealtimeRuntime({ redisUrl: options.redisUrl, profileName: options.profileName ?? options.profile, hooks, takeCommittedReadModelChangeReceipt, }); redisConnector = realtimeRuntime.redisConnector; hooks = realtimeRuntime.hooks; 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 (redisConnector) { await redisConnector.disconnect(); } await commandConnector?.disconnect(); }; const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue; const commandHandler = createTurnDaemonCommandHandler({ world, reservedTurns: reservedTurnStoreHandle?.store, scenarioMeta: snapshot.scenarioMeta, map: snapshot.map, commandProfile, getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, 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), // The exclusive fixture runner aborts the entire in-memory runtime // on failure and has no concurrent writer. Avoid cloning the whole // accumulated world before every due tick in that isolated mode; // production and gateway-managed runtimes keep rollback savepoints. stateManager: options.exclusiveFastForward ? undefined : stateManager, }, { profile: options.profile, defaultBudget } ); adminActionConsumer = await createStartedAdminActionConsumer({ runtimeOptions: options, turnDaemonLease, commandConnector, redisConnector, controlQueue: resolvedControlQueue, }); return { lifecycle, world, controlQueue: resolvedControlQueue, stateStore, stateManager, 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; } };