Ref nation table에 없는 id=0 행이 정찰 차단과 월간 경매 난수 소비에 관여하지 않도록 모집단을 맞춘다. Ref의 maxnation 건국 표시 제한도 실행 제약을 늘리지 않고 명령표에 이관한다.
1034 lines
44 KiB
TypeScript
1034 lines
44 KiB
TypeScript
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';
|
|
import { applyRuntimeGameSettings } from './runtimeGameSettings.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<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
|
|
}
|
|
|
|
export interface TurnDaemonRuntime {
|
|
lifecycle: TurnDaemonLifecycle;
|
|
world: InMemoryTurnWorld;
|
|
controlQueue: TurnDaemonControlQueue;
|
|
stateStore: InMemoryTurnStateStore;
|
|
stateManager: EngineStateManager;
|
|
processor: InMemoryTurnProcessor;
|
|
hooks?: TurnDaemonHooks;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
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<Map<string, number>> => {
|
|
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<string, number>();
|
|
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<number> => {
|
|
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<ReturnType<typeof loadTurnWorldFromDatabase>>;
|
|
type MonthlyActionModuleBundle = Awaited<ReturnType<typeof loadActionModuleBundle>>;
|
|
type NationTraitModuleMap = Map<string, Awaited<ReturnType<typeof loadNationTraitModules>>[number]>;
|
|
type ReservedTurnStoreHandle = Awaited<ReturnType<typeof createReservedTurnStore>>;
|
|
type RedisConnector = ReturnType<typeof createRedisConnector>;
|
|
type GamePostgresConnector = ReturnType<typeof createGamePostgresConnector>;
|
|
|
|
const resolveRuntimeState = (
|
|
state: LoadedTurnWorld['state'],
|
|
options: Pick<TurnDaemonRuntimeOptions, 'tickMinutes' | 'gameClockMode'>,
|
|
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<typeof buildCommandEnv>;
|
|
actionModules: MonthlyActionModuleBundle;
|
|
nationTraits: NationTraitModuleMap;
|
|
incomeHandler: ReturnType<typeof createIncomeHandler>;
|
|
}): Map<string, MonthlyEventActionHandler> => {
|
|
const eventActions = new Map<string, MonthlyEventActionHandler>();
|
|
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<typeof buildCommandEnv>;
|
|
incomeHandler: ReturnType<typeof createIncomeHandler>;
|
|
monthlyEventHandler: ReturnType<typeof createMonthlyEventHandler>;
|
|
hasEventAction: (name: string) => boolean;
|
|
calendarHandlerOverride?: TurnCalendarHandler;
|
|
getWorld: () => InMemoryTurnWorld | null;
|
|
getRedisClient: () => ReturnType<typeof createRedisConnector>['client'] | undefined;
|
|
clock: Clock;
|
|
}) => {
|
|
const cache: MonthlyRuntimeCache = {
|
|
// Ref's monthly nation query has no Core-only id=0 neutral row.
|
|
nationPowerRollCount: options.snapshot.nations.filter((nation) => nation.id > 0).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.getWorld()?.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.getWorld()?.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<void> => {
|
|
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
|
};
|
|
const publishReadModelChanges = async (changes: RealtimeReadModelChanges): Promise<number> => {
|
|
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<number | undefined> => {
|
|
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,
|
|
});
|
|
}
|
|
if (action.action === 'UPDATE_RUNTIME_SETTINGS') {
|
|
if (!options.commandConnector) {
|
|
return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' };
|
|
}
|
|
return applyRuntimeGameSettings({
|
|
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<TurnDaemonRuntime> => {
|
|
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<void> => {};
|
|
const prefetchedNationTurns = new Set<string>();
|
|
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, {
|
|
dispatchScenarioEvent: (targetCode, context) => monthlyEventHandler.dispatchTarget(targetCode, context),
|
|
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<unknown>[] = [];
|
|
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<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
|
let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
|
|
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
|
let pauseGate: (() => Promise<boolean>) | undefined;
|
|
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | 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,
|
|
loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
|
|
});
|
|
|
|
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
|
budgetMs: 5000,
|
|
maxGenerals: 200,
|
|
catchUpCap: 1,
|
|
};
|
|
|
|
const lifecycle = new TurnDaemonLifecycle(
|
|
{
|
|
clock,
|
|
controlQueue: resolvedControlQueue,
|
|
getNextTickTime: (lastTurnTime) =>
|
|
getNextTickTime(lastTurnTime, Math.max(1, Math.round(world.getState().tickSeconds / 60))),
|
|
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<TurnDaemonRuntime> => {
|
|
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;
|
|
}
|
|
};
|