feat: implement input event system with durable command handling

- Introduced InputEvent model with status tracking (PENDING, PROCESSING, SUCCEEDED, FAILED) and unique request IDs.
- Added DatabaseTurnDaemonTransport for sending commands and handling idempotency.
- Implemented executeInputEvent function to manage input event lifecycle and error handling.
- Created DatabaseTurnDaemonCommandQueue for managing command processing and lease recovery.
- Enhanced turn daemon lifecycle to support atomic command execution and error recovery.
- Added tests for input event atomicity, command queuing, and error handling scenarios.
This commit is contained in:
2026-07-25 05:36:34 +00:00
parent c5da507df9
commit 5040691d7c
34 changed files with 1587 additions and 448 deletions
+32 -25
View File
@@ -1,10 +1,6 @@
import { z } from 'zod';
import type {
TurnDaemonCommand,
TurnDaemonCommandType,
TurnDaemonCommandByType,
} from '@sammo-ts/common';
import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common';
export type TurnDaemonCommandEnvelope = {
requestId: string;
@@ -161,22 +157,21 @@ const zSetNationMeta = z.object({
expectedUpdatedAt: z.string().optional(),
});
const zAdjustGeneralResources = z
.object({
type: z.literal('adjustGeneralResources'),
reason: z.string().optional(),
adjustments: z
.array(
z
.object({
generalId: zFiniteNumber,
goldDelta: zFiniteNumber.optional(),
riceDelta: zFiniteNumber.optional(),
})
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
)
.min(1),
});
const zAdjustGeneralResources = z.object({
type: z.literal('adjustGeneralResources'),
reason: z.string().optional(),
adjustments: z
.array(
z
.object({
generalId: zFiniteNumber,
goldDelta: zFiniteNumber.optional(),
riceDelta: zFiniteNumber.optional(),
})
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
)
.min(1),
});
const zAdjustGeneralMeta = z.object({
type: z.literal('adjustGeneralMeta'),
@@ -430,10 +425,22 @@ const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => {
};
};
const normalizeRun: CommandNormalizer<'run'> = (envelope) => parseWith(zRun, envelope.command);
const normalizePause: CommandNormalizer<'pause'> = (envelope) => parseWith(zPause, envelope.command);
const normalizeResume: CommandNormalizer<'resume'> = (envelope) => parseWith(zResume, envelope.command);
const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => parseWith(zShutdown, envelope.command);
const normalizeRun: CommandNormalizer<'run'> = (envelope) => {
const command = parseWith(zRun, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizePause: CommandNormalizer<'pause'> = (envelope) => {
const command = parseWith(zPause, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeResume: CommandNormalizer<'resume'> = (envelope) => {
const command = parseWith(zResume, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
const command = parseWith(zShutdown, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizers: CommandNormalizerMap = {
auctionFinalize: normalizeAuctionFinalize,
+78 -32
View File
@@ -1,5 +1,6 @@
import {
createGamePostgresConnector,
type GamePrisma,
type InputJsonValue,
type TurnEngineCityUpdateInput,
type TurnEngineDiplomacyCreateManyInput,
@@ -15,7 +16,7 @@ import {
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { TurnDaemonHooks } from '../lifecycle/types.js';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
@@ -305,32 +306,37 @@ export const createDatabaseTurnHooks = async (
await connector.connect();
const prisma = connector.prisma;
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
const state = world.getState();
const {
generals,
cities,
nations,
troops,
deletedTroops,
deletedGenerals,
deletedNations,
deletedNationSnapshots,
diplomacy,
logs,
createdGenerals,
createdNations,
createdTroops,
createdDiplomacy,
} = world.consumeDirtyState();
const persistChanges = async (
transaction?: GamePrisma.TransactionClient,
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }
): Promise<() => void> => {
const state = world.getState();
const changes = world.peekDirtyState();
const {
generals,
cities,
nations,
troops,
deletedTroops,
deletedGenerals,
deletedNations,
deletedNationSnapshots,
diplomacy,
logs,
createdGenerals,
createdNations,
createdTroops,
createdDiplomacy,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
meta: asJson(state.meta),
};
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
await prisma.worldState.update({
where: { id: state.id },
data: worldStateUpdate,
@@ -462,10 +468,7 @@ export const createDatabaseTurnHooks = async (
if (deletedNations.length > 0) {
await prisma.diplomacy.deleteMany({
where: {
OR: [
{ srcNationId: { in: deletedNations } },
{ destNationId: { in: deletedNations } },
],
OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }],
},
});
await prisma.nationTurn.deleteMany({
@@ -563,9 +566,52 @@ export const createDatabaseTurnHooks = async (
});
}
}
if (options?.reservedTurns) {
await options.reservedTurns.flushChanges();
if (options?.reservedTurns && reservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, reservedTurnChanges);
}
if (commandCompletion) {
await prisma.inputEvent.update({
where: { requestId: commandCompletion.requestId },
data: {
status: 'SUCCEEDED',
result: asJson(commandCompletion.result),
completedAt: new Date(),
error: null,
},
});
}
};
if (transaction) {
await persist(transaction);
} else {
await prisma.$transaction(persist);
}
return () => {
world.acknowledgeDirtyState(changes);
if (options?.reservedTurns && reservedTurnChanges) {
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
}
};
};
const hooks: TurnDaemonHooks = {
flushChanges: async () => {
const acknowledge = await persistChanges();
acknowledge();
},
commitCommand: async (requestId, result) => {
const acknowledge = await persistChanges(undefined, { requestId, result });
acknowledge();
},
executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction(async (transaction) => {
const result = await execute({ db: transaction });
const acknowledge = await persistChanges(transaction, { requestId, result });
return { result, acknowledge };
});
committed.acknowledge();
return committed.result;
},
};
+47 -31
View File
@@ -68,6 +68,23 @@ export interface InMemoryTurnWorldOptions {
calendarHandler?: TurnCalendarHandler;
}
export interface TurnWorldChanges {
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
troops: Troop[];
deletedTroops: number[];
deletedGenerals: number[];
deletedNations: number[];
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
if (timeDiff !== 0) {
@@ -694,22 +711,7 @@ export class InMemoryTurnWorld {
}
}
consumeDirtyState(): {
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
troops: Troop[];
deletedTroops: number[];
deletedGenerals: number[];
deletedNations: number[];
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
} {
peekDirtyState(): TurnWorldChanges {
const generals = Array.from(this.dirtyGeneralIds)
.map((id) => this.generals.get(id))
.filter((general): general is TurnGeneral => Boolean(general));
@@ -740,21 +742,8 @@ export class InMemoryTurnWorld {
const deletedTroops = Array.from(this.deletedTroopIds);
const deletedGenerals = Array.from(this.deletedGeneralIds);
const deletedNations = Array.from(this.deletedNationIds);
const deletedNationSnapshots = this.deletedNationSnapshots.splice(0, this.deletedNationSnapshots.length);
const logs = this.logs.splice(0, this.logs.length);
this.dirtyGeneralIds.clear();
this.dirtyCityIds.clear();
this.dirtyNationIds.clear();
this.dirtyTroopIds.clear();
this.dirtyDiplomacyKeys.clear();
this.createdGeneralIds.clear();
this.createdNationIds.clear();
this.createdTroopIds.clear();
this.createdDiplomacyKeys.clear();
this.deletedTroopIds.clear();
this.deletedGeneralIds.clear();
this.deletedNationIds.clear();
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
return {
generals,
@@ -774,6 +763,33 @@ export class InMemoryTurnWorld {
};
}
acknowledgeDirtyState(changes: TurnWorldChanges): void {
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
for (const troop of changes.troops) this.dirtyTroopIds.delete(troop.id);
for (const entry of changes.diplomacy) {
this.dirtyDiplomacyKeys.delete(buildDiplomacyKey(entry.fromNationId, entry.toNationId));
}
for (const general of changes.createdGenerals) this.createdGeneralIds.delete(general.id);
for (const nation of changes.createdNations) this.createdNationIds.delete(nation.id);
for (const troop of changes.createdTroops) this.createdTroopIds.delete(troop.id);
for (const entry of changes.createdDiplomacy) {
this.createdDiplomacyKeys.delete(buildDiplomacyKey(entry.fromNationId, entry.toNationId));
}
for (const id of changes.deletedTroops) this.deletedTroopIds.delete(id);
for (const id of changes.deletedGenerals) this.deletedGeneralIds.delete(id);
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
}
consumeDirtyState(): TurnWorldChanges {
const changes = this.peekDirtyState();
this.acknowledgeDirtyState(changes);
return changes;
}
private removeCollapsedNations(): void {
const collapsedNationIds: number[] = [];
for (const nation of this.nations.values()) {
+33 -11
View File
@@ -72,6 +72,11 @@ const buildNationKey = (nationId: number, officerLevel: number): string => `${na
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'>;
export interface ReservedTurnChanges {
generalIds: number[];
nationKeys: string[];
}
export class InMemoryReservedTurnStore {
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
@@ -213,12 +218,27 @@ export class InMemoryReservedTurnStore {
this.dirtyNationKeys.add(key);
}
async flushChanges(): Promise<void> {
const generalIds = Array.from(this.dirtyGeneralIds);
for (const generalId of generalIds) {
peekDirtyState(): ReservedTurnChanges {
return {
generalIds: Array.from(this.dirtyGeneralIds),
nationKeys: Array.from(this.dirtyNationKeys),
};
}
acknowledgeDirtyState(changes: ReservedTurnChanges): void {
for (const generalId of changes.generalIds) {
this.dirtyGeneralIds.delete(generalId);
}
for (const key of changes.nationKeys) {
this.dirtyNationKeys.delete(key);
}
}
async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise<void> {
for (const generalId of changes.generalIds) {
const turns = this.getGeneralTurns(generalId);
await this.prisma.generalTurn.deleteMany({ where: { generalId } });
await this.prisma.generalTurn.createMany({
await prisma.generalTurn.deleteMany({ where: { generalId } });
await prisma.generalTurn.createMany({
data: turns.map((entry, turnIdx) => ({
generalId,
turnIdx,
@@ -228,16 +248,15 @@ export class InMemoryReservedTurnStore {
});
}
const nationKeys = Array.from(this.dirtyNationKeys);
for (const key of nationKeys) {
for (const key of changes.nationKeys) {
const [nationIdRaw, officerLevelRaw] = key.split(':');
const nationId = Number(nationIdRaw);
const officerLevel = Number(officerLevelRaw);
const turns = this.getNationTurns(nationId, officerLevel);
await this.prisma.nationTurn.deleteMany({
await prisma.nationTurn.deleteMany({
where: { nationId, officerLevel },
});
await this.prisma.nationTurn.createMany({
await prisma.nationTurn.createMany({
data: turns.map((entry, turnIdx) => ({
nationId,
officerLevel,
@@ -247,9 +266,12 @@ export class InMemoryReservedTurnStore {
})),
});
}
}
this.dirtyGeneralIds.clear();
this.dirtyNationKeys.clear();
async flushChanges(): Promise<void> {
const changes = this.peekDirtyState();
await this.persistChanges(this.prisma, changes);
this.acknowledgeDirtyState(changes);
}
}
+12 -13
View File
@@ -1,6 +1,6 @@
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
import { SystemClock } from '../lifecycle/clock.js';
@@ -8,7 +8,7 @@ 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 { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { createDatabaseTurnHooks } from './databaseHooks.js';
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
@@ -201,7 +201,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
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
@@ -222,17 +221,14 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
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,
@@ -290,10 +286,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
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));
@@ -320,6 +312,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
};
}
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();
}
const baseClose = close;
close = async () => {
await baseClose();
@@ -330,12 +329,12 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
if (redisConnector) {
await redisConnector.disconnect();
}
await commandConnector?.disconnect();
};
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue;
const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
const commandHandler = createTurnDaemonCommandHandler({
world,
hooks,
auctionFinalizer: auctionFinalizer ?? undefined,
auctionBidder: auctionBidder ?? undefined,
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
@@ -357,7 +356,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
hooks,
pauseGate,
commandHandler,
commandResponder: redisCommandStream ?? undefined,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
},
{ profile: options.profile, defaultBudget }
);
+77 -88
View File
@@ -1,10 +1,10 @@
import type {
TurnDaemonHooks,
TurnDaemonCommandHandler,
TurnDaemonCommand,
TurnDaemonCommandExecutionContext,
TurnDaemonCommandResult,
TurnRunResult,
} from '../lifecycle/types.js';
import type { GamePrisma } from '@sammo-ts/infra';
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
LogCategory,
@@ -23,25 +23,6 @@ import {
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
const state = world.getState();
return {
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
checkpoint: world.getCheckpoint(),
};
};
const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise<void> => {
if (!hooks?.flushChanges) {
return;
}
await hooks.flushChanges(buildFlushResult(world));
};
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
@@ -67,29 +48,35 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: nu
interface CommandHandlerContext {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
commandDb?: GamePrisma.TransactionClient;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}
interface AuctionFinalizer {
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
}
interface AuctionBidder {
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>;
bid(
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
}
interface TournamentRewardFinalizer {
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>;
finalize(
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
}
async function handleSetNationMeta(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const nation = world.getNationById(command.nationId);
if (!nation) {
return {
@@ -122,7 +109,6 @@ async function handleSetNationMeta(
world.updateNation(command.nationId, {
meta: nextMeta,
});
await flushWorld(world, hooks);
return {
type: 'setNationMeta',
ok: true,
@@ -135,7 +121,7 @@ async function handleAdjustGeneralResources(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
}
@@ -176,8 +162,6 @@ async function handleAdjustGeneralResources(
totalGoldDelta += goldDelta;
totalRiceDelta += riceDelta;
}
await flushWorld(world, hooks);
return {
type: 'adjustGeneralResources',
ok: true,
@@ -192,7 +176,7 @@ async function handleAdjustGeneralMeta(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
if (!command.adjustments || command.adjustments.length === 0) {
return {
type: 'adjustGeneralMeta',
@@ -224,8 +208,6 @@ async function handleAdjustGeneralMeta(
world.updateGeneral(adjustment.generalId, { meta: nextMeta });
processed += 1;
}
await flushWorld(world, hooks);
return {
type: 'adjustGeneralMeta',
ok: true,
@@ -238,7 +220,7 @@ async function handleTournamentMatchResult(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const resolvePrefix = (type: number): string => {
switch (type) {
case 1:
@@ -342,8 +324,6 @@ async function handleTournamentMatchResult(
nextMeta[rankKey('g')] = defenderG + defenderGDelta;
world.updateGeneral(defender.id, { meta: nextMeta });
}
await flushWorld(world, hooks);
return {
type: 'tournamentMatchResult',
ok: true,
@@ -358,7 +338,7 @@ async function handlePatchGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -396,7 +376,6 @@ async function handlePatchGeneral(
}
world.updateGeneral(command.generalId, patch);
await flushWorld(world, hooks);
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
}
@@ -404,7 +383,7 @@ async function handleTroopJoin(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -448,7 +427,6 @@ async function handleTroopJoin(
world.updateGeneral(command.generalId, {
troopId: command.troopId,
});
await flushWorld(world, hooks);
return {
type: 'troopJoin',
ok: true,
@@ -461,7 +439,7 @@ async function handleTroopExit(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -484,7 +462,6 @@ async function handleTroopExit(
world.updateGeneral(command.generalId, {
troopId: 0,
});
await flushWorld(world, hooks);
return {
type: 'troopExit',
ok: true,
@@ -499,7 +476,6 @@ async function handleTroopExit(
world.updateGeneral(member.id, { troopId: 0 });
}
world.removeTroop(troopId);
await flushWorld(world, hooks);
return {
type: 'troopExit',
ok: true,
@@ -512,7 +488,7 @@ async function handleDieOnPrestart(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -533,7 +509,6 @@ async function handleDieOnPrestart(
}
world.removeGeneral(command.generalId);
await flushWorld(world, hooks);
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
}
@@ -619,7 +594,7 @@ async function handleSetMySetting(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -635,7 +610,6 @@ async function handleSetMySetting(
...command.settings,
},
});
await flushWorld(world, hooks);
return { type: 'setMySetting', ok: true, generalId: command.generalId };
}
@@ -643,7 +617,7 @@ async function handleDropItem(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dropItem' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -664,7 +638,6 @@ async function handleDropItem(
items,
},
});
await flushWorld(world, hooks);
return { type: 'dropItem', ok: true, generalId: command.generalId };
}
@@ -680,7 +653,7 @@ async function handleAuctionFinalize(
reason: '경매 확정기가 준비되지 않았습니다.',
};
}
return ctx.auctionFinalizer.finalize(command.auctionId);
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
}
async function handleAuctionBid(
@@ -695,14 +668,14 @@ async function handleAuctionBid(
reason: '경매 입찰기가 준비되지 않았습니다.',
};
}
return ctx.auctionBidder.bid(command);
return ctx.auctionBidder.bid(command, ctx.commandDb);
}
async function handleChangePermission(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -728,8 +701,6 @@ async function handleChangePermission(
});
}
}
await flushWorld(world, hooks);
return { type: 'changePermission', ok: true, generalId: command.generalId };
}
@@ -737,7 +708,7 @@ async function handleKick(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'kick' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -761,8 +732,6 @@ async function handleKick(
nationId: 0,
officerLevel: 0,
});
await flushWorld(world, hooks);
return { type: 'kick', ok: true, generalId: command.generalId };
}
@@ -770,7 +739,7 @@ async function handleAppoint(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'appoint' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -825,8 +794,6 @@ async function handleAppoint(
});
}
}
await flushWorld(world, hooks);
return { type: 'appoint', ok: true, generalId: command.generalId };
}
@@ -834,7 +801,7 @@ async function handleTournamentRefund(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
if (!command.refunds || command.refunds.length === 0) {
return {
type: 'tournamentRefund',
@@ -866,8 +833,6 @@ async function handleTournamentRefund(
processed += 1;
totalRefund += refund.amount;
}
await flushWorld(world, hooks);
return {
type: 'tournamentRefund',
ok: true,
@@ -882,7 +847,7 @@ async function handleTournamentBettingPayout(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
if (!command.payouts || command.payouts.length === 0) {
return {
type: 'tournamentBettingPayout',
@@ -935,8 +900,6 @@ async function handleTournamentBettingPayout(
nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold;
world.updateGeneral(generalId, { meta: nextMeta });
}
await flushWorld(world, hooks);
return {
type: 'tournamentBettingPayout',
ok: true,
@@ -960,7 +923,7 @@ async function handleTournamentReward(
reason: '보상 처리기가 준비되지 않았습니다.',
};
}
return ctx.tournamentRewardFinalizer.finalize(command);
return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb);
}
// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다.
@@ -968,7 +931,7 @@ async function handleVoteReward(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>
): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx;
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return {
@@ -1153,7 +1116,6 @@ async function handleVoteReward(
}
world.updateGeneral(command.generalId, patch);
await flushWorld(world, hooks);
return {
type: 'voteReward',
ok: true,
@@ -1166,54 +1128,81 @@ async function handleVoteReward(
export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}): TurnDaemonCommandHandler => {
const ctx = {
const ctx: CommandHandlerContext = {
world: options.world,
hooks: options.hooks,
auctionFinalizer: options.auctionFinalizer,
auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
};
type HandlerMap = Partial<Record<TurnDaemonCommand['type'], (command: TurnDaemonCommand) => Promise<TurnDaemonCommandResult>>>;
type HandlerMap = Partial<
Record<TurnDaemonCommand['type'], (command: TurnDaemonCommand) => Promise<TurnDaemonCommandResult>>
>;
const handlers: HandlerMap = {
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
buildNationCandidate: (command) => handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
instantRetreat: (command) => handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
dieOnPrestart: (command) =>
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
buildNationCandidate: (command) =>
handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
instantRetreat: (command) =>
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
setMySetting: (command) => handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
setMySetting: (command) =>
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
auctionFinalize: (command) => handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionFinalize: (command) =>
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
changePermission: (command) => handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
tournamentBettingPayout: (command) => handleTournamentBettingPayout(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>),
tournamentReward: (command) => handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
tournamentBettingPayout: (command) =>
handleTournamentBettingPayout(
ctx,
command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
),
tournamentReward: (command) =>
handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>),
adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
setNationMeta: (command) =>
handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
adjustGeneralResources: (command) =>
handleAdjustGeneralResources(
ctx,
command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
),
adjustGeneralMeta: (command) =>
handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
tournamentMatchResult: (command) =>
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
};
return {
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
handle: async (
command,
executionContext?: TurnDaemonCommandExecutionContext
): Promise<TurnDaemonCommandResult | null> => {
const handler = handlers[command.type];
if (!handler) {
return null;
}
return handler(command);
ctx.commandDb = executionContext?.db;
try {
return await handler(command);
} finally {
ctx.commandDb = undefined;
}
},
};
};