- Added `adjustGeneralResources` command to handle resource adjustments for generals. - Introduced `patchGeneral` command for updating general attributes. - Created `auctionBid` command to facilitate bidding in auctions with validation. - Refactored database interactions to use transactions for auction bids and resource adjustments. - Enhanced error handling for auction and resource operations. - Updated command handling in the turn daemon to support new commands. - Introduced `AuctionBidder` interface and implementation for managing auction bids. - Improved overall structure and readability of auction-related code.
392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
|
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
|
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
|
|
|
import { SystemClock } from '../lifecycle/clock.js';
|
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
|
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
|
|
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
|
|
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
|
|
import { buildTurnDaemonStreamKeys, RedisTurnDaemonCommandStream } from '../lifecycle/redisCommandStream.js';
|
|
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
|
import { createDatabaseTurnHooks } from './databaseHooks.js';
|
|
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
|
|
import { InMemoryTurnWorld } from './inMemoryWorld.js';
|
|
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
|
|
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
|
|
import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
|
|
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
|
import { composeCalendarHandlers } from './calendarHandlers.js';
|
|
import { createIncomeHandler } from './incomeHandler.js';
|
|
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
|
import { createReservedTurnStore } from './reservedTurnStore.js';
|
|
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
|
|
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
|
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
|
import { shouldUseAi } from './ai/generalAi.js';
|
|
import { createUnificationHandler } from './unificationHandler.js';
|
|
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
|
import { createAuctionBidder } from '../auction/bidder.js';
|
|
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
|
|
|
export interface TurnDaemonRuntimeOptions {
|
|
profile: string;
|
|
profileName?: string;
|
|
databaseUrl: string;
|
|
gatewayDatabaseUrl?: string;
|
|
defaultBudget?: TurnRunBudget;
|
|
clock?: Clock;
|
|
controlQueue?: TurnDaemonControlQueue;
|
|
schedule?: TurnSchedule;
|
|
tickMinutes?: number;
|
|
mapOptions?: MapLoaderOptions;
|
|
generalTurnHandler?: GeneralTurnHandler;
|
|
calendarHandler?: TurnCalendarHandler;
|
|
enableDatabaseFlush?: boolean;
|
|
pauseGateIntervalMs?: number;
|
|
commandProfile?: TurnCommandProfile;
|
|
commandProfilePath?: string;
|
|
adminActionIntervalMs?: number;
|
|
redisUrl?: string;
|
|
commandStreamStartId?: string;
|
|
}
|
|
|
|
export interface TurnDaemonRuntime {
|
|
lifecycle: TurnDaemonLifecycle;
|
|
world: InMemoryTurnWorld;
|
|
controlQueue: TurnDaemonControlQueue;
|
|
stateStore: InMemoryTurnStateStore;
|
|
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 resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => {
|
|
if (redisUrl) {
|
|
return { url: redisUrl };
|
|
}
|
|
if (!env.REDIS_URL) {
|
|
return null;
|
|
}
|
|
return resolveRedisConfigFromEnv(env);
|
|
};
|
|
|
|
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
|
|
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
|
|
const { state, snapshot } = await loadTurnWorldFromDatabase({
|
|
databaseUrl: options.databaseUrl,
|
|
mapOptions: options.mapOptions,
|
|
});
|
|
|
|
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
|
|
const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state;
|
|
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
|
|
const reservedTurnStoreHandle = options.generalTurnHandler
|
|
? null
|
|
: await createReservedTurnStore({
|
|
databaseUrl: options.databaseUrl,
|
|
});
|
|
const commandProfile =
|
|
options.commandProfile ??
|
|
(options.commandProfilePath
|
|
? await loadTurnCommandProfile({
|
|
filePath: options.commandProfilePath,
|
|
})
|
|
: await loadTurnCommandProfile());
|
|
let worldRef: InMemoryTurnWorld | null = null;
|
|
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
|
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
|
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
|
const unification = options.calendarHandler
|
|
? null
|
|
: createUnificationHandler({
|
|
databaseUrl: options.databaseUrl,
|
|
profileName: options.profileName ?? options.profile,
|
|
getWorld: () => worldRef,
|
|
});
|
|
const incomeHandler = createIncomeHandler({
|
|
getWorld: () => worldRef,
|
|
scenarioConfig: snapshot.scenarioConfig,
|
|
nationTraits: nationTraitMap,
|
|
});
|
|
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
|
profileName: options.profileName ?? options.profile,
|
|
getRedisClient: () => redisConnector?.client,
|
|
getWorldConfig: () => snapshot.worldConfig ?? null,
|
|
getTickSeconds: () => worldRef?.getState().tickSeconds ?? null,
|
|
});
|
|
const calendarHandler = composeCalendarHandlers(
|
|
options.calendarHandler ?? unification?.handler,
|
|
incomeHandler,
|
|
tournamentAutoStartHandler
|
|
);
|
|
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,
|
|
})),
|
|
calendarHandler: calendarHandler ?? undefined,
|
|
};
|
|
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
|
worldRef = world;
|
|
|
|
const stateStore = new InMemoryTurnStateStore(world);
|
|
const prefetchedNationTurns = new Set<string>();
|
|
const processor = new InMemoryTurnProcessor(world, {
|
|
tickMinutes,
|
|
beforeExecuteGeneral: reservedTurnStoreHandle
|
|
? async (general) => {
|
|
const promises: Promise<unknown>[] = [];
|
|
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
|
|
if (general.nationId > 0 && general.officerLevel >= 5) {
|
|
promises.push(
|
|
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
|
|
);
|
|
}
|
|
if (general.nationId > 0 && general.officerLevel >= 5 && shouldUseAi(general, world.getState())) {
|
|
const key = `${general.nationId}:${world.getState().currentYear}:${world.getState().currentMonth}`;
|
|
if (!prefetchedNationTurns.has(key)) {
|
|
prefetchedNationTurns.add(key);
|
|
const generalIds = world
|
|
.listGenerals()
|
|
.filter((candidate) => candidate.nationId === general.nationId)
|
|
.map((candidate) => candidate.id);
|
|
promises.push(reservedTurnStoreHandle.store.prefetchGeneralTurns(generalIds));
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
: undefined,
|
|
});
|
|
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
|
|
const clock = options.clock ?? new SystemClock();
|
|
|
|
let hooks: TurnDaemonHooks | undefined;
|
|
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | 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 redisCommandStream: RedisTurnDaemonCommandStream | 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 (options.enableDatabaseFlush ?? true) {
|
|
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
|
reservedTurns: reservedTurnStoreHandle?.store,
|
|
});
|
|
auctionBidder = await createAuctionBidder({
|
|
databaseUrl: options.databaseUrl,
|
|
world,
|
|
hooks: dbHooks.hooks,
|
|
});
|
|
auctionFinalizer = await createAuctionFinalizer({
|
|
databaseUrl: options.databaseUrl,
|
|
world,
|
|
hooks: dbHooks.hooks,
|
|
});
|
|
tournamentRewardFinalizer = await createTournamentRewardFinalizer({
|
|
databaseUrl: options.databaseUrl,
|
|
world,
|
|
hooks: dbHooks.hooks,
|
|
});
|
|
hooks = {
|
|
...dbHooks.hooks,
|
|
onRunError: async (error) => {
|
|
await dbHooks.hooks.onRunError?.(error);
|
|
await gatewayGate?.markPaused(error);
|
|
},
|
|
};
|
|
close = async () => {
|
|
if (auctionBidder) {
|
|
await auctionBidder.close();
|
|
}
|
|
if (auctionFinalizer) {
|
|
await auctionFinalizer.close();
|
|
}
|
|
if (tournamentRewardFinalizer) {
|
|
await tournamentRewardFinalizer.close();
|
|
}
|
|
await dbHooks.close();
|
|
if (reservedTurnStoreHandle) {
|
|
await reservedTurnStoreHandle.close();
|
|
}
|
|
await gatewayGate?.close();
|
|
await adminActionConsumer?.stop();
|
|
};
|
|
} else if (reservedTurnStoreHandle) {
|
|
hooks = {
|
|
onRunError: async (error) => {
|
|
await gatewayGate?.markPaused(error);
|
|
},
|
|
};
|
|
close = async () => {
|
|
await reservedTurnStoreHandle.close();
|
|
await gatewayGate?.close();
|
|
await adminActionConsumer?.stop();
|
|
};
|
|
} else if (gatewayGate) {
|
|
hooks = {
|
|
onRunError: async (error) => {
|
|
await gatewayGate?.markPaused(error);
|
|
},
|
|
};
|
|
close = async () => {
|
|
await gatewayGate.close();
|
|
await adminActionConsumer?.stop();
|
|
};
|
|
} else if (adminActionConsumer) {
|
|
close = async () => {
|
|
await adminActionConsumer?.stop();
|
|
};
|
|
}
|
|
|
|
const redisConfig = resolveRedisConfig(options.redisUrl);
|
|
if (redisConfig) {
|
|
redisConnector = createRedisConnector(redisConfig);
|
|
await redisConnector.connect();
|
|
const redisClient = redisConnector.client;
|
|
redisCommandStream = new RedisTurnDaemonCommandStream(redisClient, {
|
|
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
|
|
startId: options.commandStreamStartId,
|
|
});
|
|
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
|
|
publishRealtimeEvent = async (event: RealtimeEvent) => {
|
|
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
|
};
|
|
}
|
|
|
|
if (publishRealtimeEvent) {
|
|
const basePublishEvents = hooks?.publishEvents;
|
|
// 턴 처리 완료 이벤트를 실시간 채널로 전파한다.
|
|
hooks = {
|
|
...hooks,
|
|
publishEvents: async (result) => {
|
|
try {
|
|
await publishRealtimeEvent({
|
|
type: 'turnCompleted',
|
|
at: new Date().toISOString(),
|
|
lastTurnTime: result.lastTurnTime,
|
|
});
|
|
} catch {
|
|
// 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
|
|
}
|
|
await basePublishEvents?.(result);
|
|
},
|
|
};
|
|
}
|
|
|
|
const baseClose = close;
|
|
close = async () => {
|
|
await baseClose();
|
|
if (unification) {
|
|
await unification.close();
|
|
}
|
|
if (redisConnector) {
|
|
await redisConnector.disconnect();
|
|
}
|
|
};
|
|
|
|
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue;
|
|
const commandHandler = createTurnDaemonCommandHandler({
|
|
world,
|
|
hooks,
|
|
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,
|
|
commandHandler,
|
|
commandResponder: redisCommandStream ?? undefined,
|
|
},
|
|
{ profile: options.profile, defaultBudget }
|
|
);
|
|
|
|
if (options.profileName) {
|
|
adminActionConsumer = await createGatewayAdminActionConsumer({
|
|
databaseUrl: options.databaseUrl,
|
|
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
|
|
profileName: options.profileName,
|
|
pollIntervalMs: options.adminActionIntervalMs,
|
|
handler: async (action) => {
|
|
const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
|
|
if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
|
|
// 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다.
|
|
return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' };
|
|
}
|
|
switch (action.action) {
|
|
case 'RESUME':
|
|
resolvedControlQueue.enqueue({ type: 'resume', reason });
|
|
return { status: 'APPLIED', detail: 'resume queued' };
|
|
case 'PAUSE':
|
|
resolvedControlQueue.enqueue({ type: 'pause', reason });
|
|
return { status: 'APPLIED', detail: 'pause queued' };
|
|
case 'STOP':
|
|
case 'SHUTDOWN':
|
|
resolvedControlQueue.enqueue({ type: 'shutdown', reason });
|
|
return { status: 'APPLIED', detail: 'shutdown queued' };
|
|
default:
|
|
return { status: 'IGNORED', detail: 'not implemented' };
|
|
}
|
|
},
|
|
});
|
|
adminActionConsumer.start();
|
|
}
|
|
|
|
return {
|
|
lifecycle,
|
|
world,
|
|
controlQueue: resolvedControlQueue,
|
|
stateStore,
|
|
processor,
|
|
hooks,
|
|
close,
|
|
};
|
|
};
|