최종 read model projection과 저장된 로그를 소유 transaction 안에서 판정하고 revision 및 outbox와 함께 커밋한다. clock 전용 변화와 rollback은 revision을 남기지 않으며 커밋 receipt를 유실 없이 큐잉한다.
1019 lines
43 KiB
TypeScript
1019 lines
43 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';
|
|
|
|
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 = {
|
|
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<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,
|
|
});
|
|
}
|
|
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, {
|
|
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<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,
|
|
});
|
|
|
|
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<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;
|
|
}
|
|
};
|