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
+8
View File
@@ -25,6 +25,14 @@
## 개발 도구 및 스크립트 ## 개발 도구 및 스크립트
- 패키지 매니저: pnpm 워크스페이스 - 패키지 매니저: pnpm 워크스페이스
- 현재 개발 호스트는 `fnm`으로 Node.js와 pnpm을 관리한다. 대화형 zsh가
아닌 환경에서 `pnpm`을 찾지 못하면 아래 형태로 실행한다.
```sh
/home/letrhee/.local/share/fnm/fnm exec --using=default -- pnpm <command>
```
2026-07-25 확인 기준 default는 Node.js `v24.18.0`, pnpm은 `11.17.0`이다.
- 초기 개발기간 동안 npm 패키지는 가능한 최신버전을 유지 - 초기 개발기간 동안 npm 패키지는 가능한 최신버전을 유지
- 공통 스크립트 - 공통 스크립트
- `pnpm install` - `pnpm install`
+3
View File
@@ -61,6 +61,7 @@ export type InputJsonValue = GamePrisma.InputJsonValue;
export type DatabaseClient = InfraDatabaseClient; export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext { export interface GameApiContext {
requestId?: string;
db: DatabaseClient; db: DatabaseClient;
redis: RedisConnector['client']; redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport; turnDaemon: TurnDaemonTransport;
@@ -76,6 +77,7 @@ export interface GameApiContext {
} }
export const createGameApiContext = (options: { export const createGameApiContext = (options: {
requestId?: string;
db: DatabaseClient; db: DatabaseClient;
redis: RedisConnector['client']; redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport; turnDaemon: TurnDaemonTransport;
@@ -90,6 +92,7 @@ export const createGameApiContext = (options: {
gameTokenSecret: string; gameTokenSecret: string;
}): GameApiContext => { }): GameApiContext => {
return { return {
requestId: options.requestId,
db: options.db, db: options.db,
redis: options.redis, redis: options.redis,
turnDaemon: options.turnDaemon, turnDaemon: options.turnDaemon,
@@ -0,0 +1,95 @@
import { randomUUID } from 'node:crypto';
import type { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const stableJson = (value: unknown): string => {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
.join(',')}}`;
}
return JSON.stringify(value) ?? 'null';
};
export class ConflictingTurnDaemonCommandError extends Error {
constructor(readonly requestId: string) {
super(`Engine input event ${requestId} already exists with a different payload.`);
this.name = 'ConflictingTurnDaemonCommandError';
}
}
export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
constructor(
private readonly db: DatabaseClient,
private readonly requestTimeoutMs: number
) {}
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
try {
await this.db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(durableCommand),
},
});
} catch (error) {
const isUniqueConflict =
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
if (!isUniqueConflict) {
throw error;
}
const existing = await this.db.inputEvent.findUniqueOrThrow({
where: { requestId },
select: { eventType: true, payload: true },
});
if (existing.eventType !== command.type || stableJson(existing.payload) !== stableJson(durableCommand)) {
throw new ConflictingTurnDaemonCommandError(requestId);
}
}
return requestId;
}
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
return this.waitForResult<TurnDaemonCommandResult>(requestId, timeoutMs);
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = await this.sendCommand({ type: 'getStatus', requestId: randomUUID() });
const payload = await this.waitForResult<{ status: TurnDaemonStatus }>(requestId, timeoutMs);
return payload?.status ?? null;
}
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) {
const event = await this.db.inputEvent.findUnique({
where: { requestId },
select: { status: true, result: true },
});
if (event?.status === 'SUCCEEDED') {
return event.result as T;
}
if (event?.status === 'FAILED') {
return null;
}
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
}
return null;
}
}
@@ -0,0 +1,28 @@
import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
export class IdempotentTurnDaemonTransport implements TurnDaemonTransport {
private sequence = 0;
constructor(
private readonly transport: TurnDaemonTransport,
private readonly parentRequestId: string
) {}
async sendCommand(command: TurnDaemonCommand): Promise<string> {
return this.transport.sendCommand(this.scope(command));
}
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
return this.transport.requestCommand(this.scope(command), timeoutMs);
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
return this.transport.requestStatus(timeoutMs);
}
private scope(command: TurnDaemonCommand): TurnDaemonCommand {
const requestId = `${this.parentRequestId}:engine:${this.sequence++}:${command.type}`;
return { ...command, requestId } as TurnDaemonCommand;
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
// 테스트용: 메모리 큐에 명령을 저장하고 requestId를 반환한다. // 테스트용: 메모리 큐에 명령을 저장하고 requestId를 반환한다.
async sendCommand(command: TurnDaemonCommand): Promise<string> { async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = command.type === 'getStatus' && command.requestId ? command.requestId : randomUUID(); const requestId = command.requestId ?? randomUUID();
this.commands.push({ this.commands.push({
requestId, requestId,
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
+1 -1
View File
@@ -26,7 +26,7 @@ type RedisStreamReadResponse = Array<{
}>; }>;
const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => { const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => {
const requestId = command.type === 'getStatus' && command.requestId ? command.requestId : randomUUID(); const requestId = command.requestId ?? randomUUID();
return { return {
requestId, requestId,
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
+7 -4
View File
@@ -8,11 +8,14 @@ import { runTournamentWorker } from './tournament/worker.js';
export * from './config.js'; export * from './config.js';
export * from './context.js'; export * from './context.js';
export * from './inputEventBoundary.js';
export * from './router.js'; export * from './router.js';
export * from './server.js'; export * from './server.js';
export * from './daemon/types.js'; export * from './daemon/types.js';
export * from './daemon/streamKeys.js'; export * from './daemon/streamKeys.js';
export * from './daemon/transport.js'; export * from './daemon/transport.js';
export * from './daemon/databaseTransport.js';
export * from './daemon/idempotentTransport.js';
export * from './daemon/inMemoryTransport.js'; export * from './daemon/inMemoryTransport.js';
export * from './daemon/redisTransport.js'; export * from './daemon/redisTransport.js';
export * from './auth/flushStore.js'; export * from './auth/flushStore.js';
@@ -51,10 +54,10 @@ if (isMain()) {
role === 'battle-sim-worker' role === 'battle-sim-worker'
? runBattleSimWorker ? runBattleSimWorker
: role === 'auction-worker' : role === 'auction-worker'
? runAuctionWorker ? runAuctionWorker
: role === 'tournament-worker' : role === 'tournament-worker'
? runTournamentWorker ? runTournamentWorker
: runGameApiServer; : runGameApiServer;
run().catch((error) => { run().catch((error) => {
console.error('[game-api] failed to start', error); console.error('[game-api] failed to start', error);
process.exitCode = 1; process.exitCode = 1;
+86
View File
@@ -0,0 +1,86 @@
import type { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
export class DuplicateInputEventError extends Error {
constructor(readonly requestId: string) {
super(`Input event ${requestId} was already accepted.`);
this.name = 'DuplicateInputEventError';
}
}
export const executeInputEvent = async <T>(options: {
db: DatabaseClient;
requestId: string;
eventType: string;
actorUserId?: string | null;
execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => {
const { db, requestId, eventType, actorUserId, execute } = options;
if (!db.$transaction) {
return execute(db);
}
const processingAt = new Date();
try {
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType,
payload: asJson({}),
actorUserId: actorUserId ?? null,
status: 'PROCESSING',
processingAt,
attempts: 1,
},
});
} catch (error) {
const isUniqueConflict =
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
if (!isUniqueConflict) {
throw error;
}
const claimedRetry = await db.inputEvent.updateMany({
where: { requestId, status: 'FAILED' },
data: {
status: 'PROCESSING',
error: null,
processingAt,
completedAt: null,
attempts: { increment: 1 },
},
});
if (claimedRetry.count === 0) {
throw new DuplicateInputEventError(requestId);
}
}
try {
return await db.$transaction(async (transaction) => {
const result = await execute(transaction);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson({ ok: true }),
completedAt: new Date(),
},
});
return result;
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await db.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
error: message,
completedAt: new Date(),
},
});
throw error;
}
};
+7 -10
View File
@@ -14,8 +14,7 @@ import {
import { resolveGameApiConfigFromEnv } from './config.js'; import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js'; import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js'; import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import { appRouter } from './router.js'; import { appRouter } from './router.js';
@@ -66,10 +65,7 @@ export const createGameApiServer = async () => {
await postgres.connect(); await postgres.connect();
await redis.connect(); await redis.connect();
const turnDaemon = new RedisTurnDaemonTransport(redis.client, { const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const battleSim = new RedisBattleSimTransport(redis.client, { const battleSim = new RedisBattleSimTransport(redis.client, {
keys: buildBattleSimQueueKeys(config.profileName), keys: buildBattleSimQueueKeys(config.profileName),
requestTimeoutMs: config.battleSimRequestTimeoutMs, requestTimeoutMs: config.battleSimRequestTimeoutMs,
@@ -83,10 +79,7 @@ export const createGameApiServer = async () => {
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName); const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
const realtimeSubscriberClient = redis.client.duplicate(); const realtimeSubscriberClient = redis.client.duplicate();
await realtimeSubscriberClient.connect(); await realtimeSubscriberClient.connect();
const realtimeHub = new RedisRealtimeEventHub( const realtimeHub = new RedisRealtimeEventHub(realtimeSubscriberClient, buildGameEventChannel(config.profileName));
realtimeSubscriberClient,
buildGameEventChannel(config.profileName)
);
await realtimeHub.start(); await realtimeHub.start();
const app = fastify({ const app = fastify({
@@ -111,6 +104,10 @@ export const createGameApiServer = async () => {
const token = extractBearerToken(req.headers.authorization); const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
return createGameApiContext({ return createGameApiContext({
requestId:
(Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0]
: req.headers['idempotency-key']) || undefined,
db: postgres.prisma, db: postgres.prisma,
redis: redis.client, redis: redis.client,
turnDaemon, turnDaemon,
+42 -2
View File
@@ -1,12 +1,52 @@
import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server'; import { initTRPC, TRPCError } from '@trpc/server';
import type { GameApiContext } from './context.js'; import type { GameApiContext } from './context.js';
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
const t = initTRPC.context<GameApiContext>().create(); const t = initTRPC.context<GameApiContext>().create();
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const requestId = `${ctx.requestId ?? randomUUID()}:${path}`;
try {
return await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
});
if (!result.ok) {
throw result.error;
}
return result;
},
});
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
});
}
throw error;
}
});
export const router = t.router; export const router = t.router;
export const procedure = t.procedure; export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof t.procedure = t.procedure.use(({ ctx, next }) => { export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
if (!ctx.auth) { if (!ctx.auth) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { IdempotentTurnDaemonTransport } from '../src/daemon/idempotentTransport.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
describe('IdempotentTurnDaemonTransport', () => {
it('derives stable ordered engine request IDs from the API input event', async () => {
const inner = new InMemoryTurnDaemonTransport();
const firstAttempt = new IdempotentTurnDaemonTransport(inner, 'api-event');
const retry = new IdempotentTurnDaemonTransport(inner, 'api-event');
await firstAttempt.sendCommand({ type: 'vacation', generalId: 7 });
await firstAttempt.sendCommand({ type: 'dropItem', generalId: 7, itemType: 'weapon' });
await retry.sendCommand({ type: 'vacation', generalId: 7 });
expect(inner.commands.map((entry) => entry.requestId)).toEqual([
'api-event:engine:0:vacation',
'api-event:engine:1:dropItem',
'api-event:engine:0:vacation',
]);
});
});
@@ -0,0 +1,147 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js';
import { ConflictingTurnDaemonCommandError, DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('API input event boundary', () => {
let close: (() => Promise<void>) | undefined;
let db: GamePrismaClient;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:api:' } },
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:api:' } },
});
await close?.();
});
it('commits a direct DB mutation and its event marker together', async () => {
const requestId = 'integration:api:success';
const markerId = 'integration:api:success:marker';
await executeInputEvent({
db,
requestId,
eventType: 'test.success',
actorUserId: 'user-7',
execute: async (transaction) => {
await transaction.inputEvent.create({
data: {
requestId: markerId,
target: 'API',
eventType: 'test.marker',
},
});
return { ok: true };
},
});
const [event, marker] = await Promise.all([
db.inputEvent.findUniqueOrThrow({ where: { requestId } }),
db.inputEvent.findUniqueOrThrow({ where: { requestId: markerId } }),
]);
expect(event).toMatchObject({
status: 'SUCCEEDED',
actorUserId: 'user-7',
attempts: 1,
});
expect(marker.status).toBe('PENDING');
});
it('rolls back business writes, records failure, and permits one explicit retry', async () => {
const requestId = 'integration:api:retry';
const markerId = 'integration:api:retry:marker';
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.failure',
execute: async (transaction) => {
await transaction.inputEvent.create({
data: {
requestId: markerId,
target: 'API',
eventType: 'test.marker',
},
});
throw new Error('injected transaction failure');
},
})
).rejects.toThrow('injected transaction failure');
expect(await db.inputEvent.findUnique({ where: { requestId: markerId } })).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'FAILED',
attempts: 1,
error: 'injected transaction failure',
});
await executeInputEvent({
db,
requestId,
eventType: 'test.failure',
execute: async () => ({ ok: true }),
});
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
attempts: 2,
});
});
it('rejects a concurrent duplicate idempotency key', async () => {
const requestId = 'integration:api:duplicate';
let releaseFirst: (() => void) | undefined;
let signalStarted: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const first = executeInputEvent({
db,
requestId,
eventType: 'test.duplicate',
execute: async () => {
signalStarted?.();
await release;
return { ok: true };
},
});
await started;
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.duplicate',
execute: async () => ({ ok: true }),
})
).rejects.toBeInstanceOf(DuplicateInputEventError);
releaseFirst?.();
await first;
});
it('reuses the same engine child event but rejects a changed retry payload', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child';
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
ConflictingTurnDaemonCommandError
);
});
});
+38 -41
View File
@@ -2,11 +2,14 @@ import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export interface AuctionBidder { export interface AuctionBidder {
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>; bid(
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -37,25 +40,6 @@ interface AuctionDetail {
availableLatestBidCloseDate?: string | null; availableLatestBidCloseDate?: string | null;
} }
const buildFlushResult = (world: InMemoryTurnWorld) => {
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));
};
const parseDetail = (detail: unknown): AuctionDetail => { const parseDetail = (detail: unknown): AuctionDetail => {
if (!detail || typeof detail !== 'object') { if (!detail || typeof detail !== 'object') {
return {}; return {};
@@ -94,7 +78,9 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
return myPrevBid; return myPrevBid;
}; };
const loadAuction = async (prisma: GamePrismaClient, auctionId: number): Promise<AuctionRow | null> => { type QueryClient = Pick<GamePrismaClient, '$queryRaw'>;
const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<AuctionRow | null> => {
const rows = await prisma.$queryRaw<AuctionRow[]>( const rows = await prisma.$queryRaw<AuctionRow[]>(
GamePrisma.sql` GamePrisma.sql`
SELECT id, SELECT id,
@@ -110,7 +96,7 @@ const loadAuction = async (prisma: GamePrismaClient, auctionId: number): Promise
}; };
const loadHighestBid = async ( const loadHighestBid = async (
prisma: GamePrismaClient, prisma: QueryClient,
auctionId: number, auctionId: number,
isReverse: boolean isReverse: boolean
): Promise<AuctionBidRow | null> => { ): Promise<AuctionBidRow | null> => {
@@ -135,7 +121,7 @@ const loadHighestBid = async (
}; };
const loadMyPrevBid = async ( const loadMyPrevBid = async (
prisma: GamePrismaClient, prisma: QueryClient,
auctionId: number, auctionId: number,
generalId: number, generalId: number,
isReverse: boolean isReverse: boolean
@@ -160,8 +146,6 @@ const loadMyPrevBid = async (
return rows[0] ?? null; return rows[0] ?? null;
}; };
type QueryClient = Pick<GamePrismaClient, '$queryRaw'>;
const resolveUserId = async (prisma: QueryClient, generalId: number): Promise<string | null> => { const resolveUserId = async (prisma: QueryClient, generalId: number): Promise<string | null> => {
const rows = await prisma.$queryRaw<{ userId: string | null }[]>( const rows = await prisma.$queryRaw<{ userId: string | null }[]>(
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}` GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}`
@@ -172,17 +156,16 @@ const resolveUserId = async (prisma: QueryClient, generalId: number): Promise<st
export const createAuctionBidder = async (options: { export const createAuctionBidder = async (options: {
databaseUrl: string; databaseUrl: string;
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
}): Promise<AuctionBidder> => { }): Promise<AuctionBidder> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl }); const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
const world = options.world; const world = options.world;
const hooks = options.hooks;
return { return {
bid: async (command): Promise<TurnDaemonCommandResult> => { bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
const auction = await loadAuction(prisma, command.auctionId); const db = commandDb ?? prisma;
const auction = await loadAuction(db, command.auctionId);
if (!auction) { if (!auction) {
return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 없습니다.' }; return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 없습니다.' };
} }
@@ -206,8 +189,8 @@ export const createAuctionBidder = async (options: {
const detail = parseDetail(auction.detail); const detail = parseDetail(auction.detail);
const isReverse = detail.isReverse === true; const isReverse = detail.isReverse === true;
const highestBid = await loadHighestBid(prisma, command.auctionId, isReverse); const highestBid = await loadHighestBid(db, command.auctionId, isReverse);
const myPrevBidRaw = await loadMyPrevBid(prisma, command.auctionId, command.generalId, isReverse); const myPrevBidRaw = await loadMyPrevBid(db, command.auctionId, command.generalId, isReverse);
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw); const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
if (highestBid) { if (highestBid) {
@@ -292,7 +275,12 @@ export const createAuctionBidder = async (options: {
}; };
} }
if (auction.type !== 'UNIQUE_ITEM' && highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { if (
auction.type !== 'UNIQUE_ITEM' &&
highestBid &&
highestBid.generalId !== command.generalId &&
!myPrevBid
) {
const prev = world.getGeneralById(highestBid.generalId); const prev = world.getGeneralById(highestBid.generalId);
if (!prev) { if (!prev) {
return { return {
@@ -319,7 +307,7 @@ export const createAuctionBidder = async (options: {
const eventAt = now; const eventAt = now;
try { try {
await prisma.$transaction(async (tx) => { const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta) INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
@@ -407,7 +395,20 @@ export const createAuctionBidder = async (options: {
} }
} }
} }
}); };
if (commandDb) {
await commandDb.$executeRawUnsafe('SAVEPOINT auction_bid_attempt');
try {
await persistBid(commandDb);
await commandDb.$executeRawUnsafe('RELEASE SAVEPOINT auction_bid_attempt');
} catch (error) {
await commandDb.$executeRawUnsafe('ROLLBACK TO SAVEPOINT auction_bid_attempt');
await commandDb.$executeRawUnsafe('RELEASE SAVEPOINT auction_bid_attempt');
throw error;
}
} else {
await prisma.$transaction(persistBid);
}
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : 'CONFLICT'; const reason = error instanceof Error ? error.message : 'CONFLICT';
if (reason === 'INSUFFICIENT_POINT') { if (reason === 'INSUFFICIENT_POINT') {
@@ -445,15 +446,11 @@ export const createAuctionBidder = async (options: {
const prev = world.getGeneralById(highestBid.generalId); const prev = world.getGeneralById(highestBid.generalId);
if (prev) { if (prev) {
world.updateGeneral(highestBid.generalId, { world.updateGeneral(highestBid.generalId, {
gold: gold: resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold,
resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold, rice: resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice,
rice:
resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice,
}); });
} }
} }
await flushWorld(world, hooks);
} }
return { return {
+27 -42
View File
@@ -1,13 +1,13 @@
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic'; import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { LogEntryDraft } from '@sammo-ts/logic'; import type { LogEntryDraft } from '@sammo-ts/logic';
export interface AuctionFinalizer { export interface AuctionFinalizer {
finalize(auctionId: number): Promise<TurnDaemonCommandResult>; finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -44,25 +44,6 @@ interface AuctionDetailResource extends AuctionDetailBase {
amount?: number; amount?: number;
} }
const buildFlushResult = (world: InMemoryTurnWorld) => {
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));
};
const parseDetail = (detail: unknown): AuctionDetailResource => { const parseDetail = (detail: unknown): AuctionDetailResource => {
if (!detail || typeof detail !== 'object') { if (!detail || typeof detail !== 'object') {
return {}; return {};
@@ -72,7 +53,9 @@ const parseDetail = (detail: unknown): AuctionDetailResource => {
const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60)); const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60));
const resolveTurnMinutes = async (prisma: GamePrismaClient): Promise<number> => { type AuctionDb = GamePrisma.TransactionClient;
const resolveTurnMinutes = async (prisma: AuctionDb): Promise<number> => {
const rows = (await prisma.$queryRaw( const rows = (await prisma.$queryRaw(
GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1` GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1`
)) as Array<{ tickSeconds: number }>; )) as Array<{ tickSeconds: number }>;
@@ -104,7 +87,7 @@ const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
}; };
const refundInheritancePoint = async (options: { const refundInheritancePoint = async (options: {
prisma: GamePrismaClient; prisma: AuctionDb;
userId: string; userId: string;
amount: number; amount: number;
}): Promise<void> => { }): Promise<void> => {
@@ -142,25 +125,24 @@ const refundInheritancePoint = async (options: {
export const createAuctionFinalizer = async (options: { export const createAuctionFinalizer = async (options: {
databaseUrl: string; databaseUrl: string;
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
}): Promise<AuctionFinalizer> => { }): Promise<AuctionFinalizer> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl }); const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
const world = options.world; const world = options.world;
const hooks = options.hooks;
const itemLoader = new ItemLoader(); const itemLoader = new ItemLoader();
const getGeneralUserId = async (generalId: number): Promise<string | null> => { const getGeneralUserId = async (db: AuctionDb, generalId: number): Promise<string | null> => {
const rows = await prisma.$queryRaw<{ userId: string | null }[]>( const rows = await db.$queryRaw<{ userId: string | null }[]>(
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}` GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}`
); );
return rows[0]?.userId ?? null; return rows[0]?.userId ?? null;
}; };
return { return {
finalize: async (auctionId: number): Promise<TurnDaemonCommandResult> => { finalize: async (auctionId: number, commandDb): Promise<TurnDaemonCommandResult> => {
const rows = await prisma.$queryRaw<AuctionRow[]>( const db = commandDb ?? prisma;
const rows = await db.$queryRaw<AuctionRow[]>(
GamePrisma.sql` GamePrisma.sql`
SELECT id, SELECT id,
type, type,
@@ -200,7 +182,7 @@ export const createAuctionFinalizer = async (options: {
const detail = parseDetail(auction.detail); const detail = parseDetail(auction.detail);
const isReverse = detail.isReverse === true; const isReverse = detail.isReverse === true;
const bidRows = await prisma.$queryRaw<AuctionBidRow[]>( const bidRows = await db.$queryRaw<AuctionBidRow[]>(
isReverse isReverse
? GamePrisma.sql` ? GamePrisma.sql`
SELECT id, general_id as "generalId", amount SELECT id, general_id as "generalId", amount
@@ -224,7 +206,7 @@ export const createAuctionFinalizer = async (options: {
const globalLogger = new ActionLogger(); const globalLogger = new ActionLogger();
const finalizeStatus = async (status: AuctionStatus) => { const finalizeStatus = async (status: AuctionStatus) => {
await prisma.$executeRaw( await db.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = ${status}, SET status = ${status},
@@ -263,7 +245,10 @@ export const createAuctionFinalizer = async (options: {
[resourceKey]: host[resourceKey] + amount, [resourceKey]: host[resourceKey] + amount,
}); });
const hostLogger = new ActionLogger({ generalId: host.id, nationId: host.nationId }); const hostLogger = new ActionLogger({ generalId: host.id, nationId: host.nationId });
hostLogger.pushGeneralActionLog(`경매가 유찰되어 ${resourceKey === 'rice' ? '쌀' : '금'} ${amount}을 회수했습니다.`, LogFormat.PLAIN); hostLogger.pushGeneralActionLog(
`경매가 유찰되어 ${resourceKey === 'rice' ? '쌀' : '금'} ${amount}을 회수했습니다.`,
LogFormat.PLAIN
);
logs.push(...hostLogger.flush()); logs.push(...hostLogger.flush());
globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN); globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN);
} }
@@ -271,7 +256,6 @@ export const createAuctionFinalizer = async (options: {
logs.push(...globalLogger.flush()); logs.push(...globalLogger.flush());
pushLogs(world, logs); pushLogs(world, logs);
await flushWorld(world, hooks);
await finalizeStatus('FINISHED'); await finalizeStatus('FINISHED');
return { type: 'auctionFinalize', ok: true, auctionId }; return { type: 'auctionFinalize', ok: true, auctionId };
} }
@@ -358,8 +342,8 @@ export const createAuctionFinalizer = async (options: {
if (!itemModule) { if (!itemModule) {
await finalizeStatus('CANCELED'); await finalizeStatus('CANCELED');
await refundInheritancePoint({ await refundInheritancePoint({
prisma, prisma: db,
userId: (await getGeneralUserId(bidder.id)) ?? '', userId: (await getGeneralUserId(db, bidder.id)) ?? '',
amount: highestBid.amount, amount: highestBid.amount,
}); });
return { return {
@@ -375,7 +359,7 @@ export const createAuctionFinalizer = async (options: {
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) { if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
const currentModule = await itemLoader.load(currentItem).catch(() => null); const currentModule = await itemLoader.load(currentItem).catch(() => null);
if (currentModule && !currentModule.buyable) { if (currentModule && !currentModule.buyable) {
const turnMinutes = await resolveTurnMinutes(prisma); const turnMinutes = await resolveTurnMinutes(db);
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
? new Date(detail.availableLatestBidCloseDate) ? new Date(detail.availableLatestBidCloseDate)
: null; : null;
@@ -385,7 +369,7 @@ export const createAuctionFinalizer = async (options: {
turnMinutes, turnMinutes,
availableLatestBidCloseDate, availableLatestBidCloseDate,
}); });
await prisma.$executeRaw( await db.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = 'OPEN', SET status = 'OPEN',
@@ -400,7 +384,6 @@ export const createAuctionFinalizer = async (options: {
); );
logs.push(...globalLogger.flush()); logs.push(...globalLogger.flush());
pushLogs(world, logs); pushLogs(world, logs);
await flushWorld(world, hooks);
return { return {
type: 'auctionFinalize', type: 'auctionFinalize',
ok: false, ok: false,
@@ -427,12 +410,15 @@ export const createAuctionFinalizer = async (options: {
); );
logs.push(...bidderLogger.flush()); logs.push(...bidderLogger.flush());
const bidderUserId = await getGeneralUserId(bidder.id); const bidderUserId = await getGeneralUserId(db, bidder.id);
if (bidderUserId) { if (bidderUserId) {
const userIdNum = Number(bidderUserId); const userIdNum = Number(bidderUserId);
if (Number.isFinite(userIdNum)) { if (Number.isFinite(userIdNum)) {
const userLogger = new UserLogger(userIdNum); const userLogger = new UserLogger(userIdNum);
userLogger.push(`유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`, 'inheritPoint'); userLogger.push(
`유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`,
'inheritPoint'
);
logs.push(...userLogger.flush()); logs.push(...userLogger.flush());
} }
} }
@@ -442,7 +428,6 @@ export const createAuctionFinalizer = async (options: {
logs.push(...globalLogger.flush()); logs.push(...globalLogger.flush());
pushLogs(world, logs); pushLogs(world, logs);
await flushWorld(world, hooks);
await finalizeStatus('FINISHED'); await finalizeStatus('FINISHED');
return { type: 'auctionFinalize', ok: true, auctionId }; return { type: 'auctionFinalize', ok: true, auctionId };
+1
View File
@@ -5,6 +5,7 @@ import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js'; export * from './lifecycle/types.js';
export * from './lifecycle/clock.js'; export * from './lifecycle/clock.js';
export * from './lifecycle/databaseCommandQueue.js';
export * from './lifecycle/inMemoryControlQueue.js'; export * from './lifecycle/inMemoryControlQueue.js';
export * from './lifecycle/turnDaemonLifecycle.js'; export * from './lifecycle/turnDaemonLifecycle.js';
export * from './lifecycle/getNextTickTime.js'; export * from './lifecycle/getNextTickTime.js';
@@ -0,0 +1,164 @@
import { randomUUID } from 'node:crypto';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandResponder,
TurnDaemonCommandResult,
TurnDaemonControlQueue,
TurnDaemonStatus,
} from './types.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = [];
private readonly workerId = randomUUID();
private readonly leaseDurationMs = 60_000;
constructor(private readonly db: GamePrismaClient) {}
async initialize(): Promise<void> {
await this.recoverExpiredLeases();
}
enqueue(command: TurnDaemonCommand): void {
this.localQueue.push(command);
}
async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length);
const remote = await this.claimPending();
return local.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) {
const local = this.localQueue.shift();
if (local) {
return local;
}
const remote = await this.claimPending(1);
if (remote[0]) {
return remote[0];
}
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
await delay(remaining);
}
return null;
}
getDepth(): number {
return this.localQueue.length;
}
async publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void> {
await this.complete(requestId, { status });
}
async publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void> {
await this.complete(requestId, result);
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => {
const rows = await transaction.$queryRaw<
Array<{
sequence: bigint;
requestId: string;
eventType: string;
payload: unknown;
createdAt: Date;
}>
>(GamePrisma.sql`
SELECT
"sequence",
"request_id" AS "requestId",
"event_type" AS "eventType",
"payload",
"created_at" AS "createdAt"
FROM "input_event"
WHERE "target" = 'ENGINE'::"InputEventTarget"
AND "status" = 'PENDING'::"InputEventStatus"
ORDER BY "sequence" ASC
FOR UPDATE SKIP LOCKED
LIMIT ${limit}
`);
if (rows.length === 0) {
return [];
}
await transaction.inputEvent.updateMany({
where: {
sequence: { in: rows.map((row) => row.sequence) },
target: 'ENGINE',
status: 'PENDING',
},
data: {
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: this.workerId,
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
attempts: { increment: 1 },
},
});
const commands: TurnDaemonCommand[] = [];
for (const row of rows) {
const command = normalizeTurnDaemonCommand({
requestId: row.requestId,
sentAt: row.createdAt.toISOString(),
command: row.payload as TurnDaemonCommand,
});
if (!command) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'FAILED',
error: `Invalid command payload for ${row.eventType}`,
completedAt: new Date(),
lockedBy: null,
leaseUntil: null,
},
});
continue;
}
commands.push(command);
}
return commands;
});
}
private async complete(requestId: string, result: unknown): Promise<void> {
await this.db.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson(result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
}
private async recoverExpiredLeases(): Promise<void> {
const now = new Date();
await this.db.inputEvent.updateMany({
where: {
target: 'ENGINE',
status: 'PROCESSING',
leaseUntil: { lt: now },
},
data: {
status: 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
},
});
}
}
@@ -13,6 +13,7 @@ import type {
TurnDaemonCommandHandler, TurnDaemonCommandHandler,
TurnDaemonCommandResponder, TurnDaemonCommandResponder,
TurnDaemonCommandResult, TurnDaemonCommandResult,
TurnDaemonCommandExecutionContext,
} from './types.js'; } from './types.js';
type PendingRun = { type PendingRun = {
@@ -21,6 +22,12 @@ type PendingRun = {
budget?: TurnRunBudget; budget?: TurnRunBudget;
}; };
type TurnDaemonControlCommand = Extract<
TurnDaemonCommand,
{ type: 'pause' | 'resume' | 'shutdown' | 'getStatus' | 'run' }
>;
type TurnDaemonMutationCommand = Exclude<TurnDaemonCommand, TurnDaemonControlCommand>;
export interface TurnDaemonLifecycleOptions { export interface TurnDaemonLifecycleOptions {
profile: string; profile: string;
defaultBudget: TurnRunBudget; defaultBudget: TurnRunBudget;
@@ -217,15 +224,24 @@ export class TurnDaemonLifecycle {
this.manualPaused = true; this.manualPaused = true;
this.status.paused = true; this.status.paused = true;
this.status.state = 'paused'; this.status.state = 'paused';
if (command.requestId) {
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
}
return; return;
case 'resume': case 'resume':
this.manualPaused = false; this.manualPaused = false;
this.status.paused = this.errorPaused; this.status.paused = this.errorPaused;
this.status.state = 'idle'; this.status.state = 'idle';
if (command.requestId) {
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
}
return; return;
case 'shutdown': case 'shutdown':
this.status.state = 'stopping'; this.status.state = 'stopping';
this.stopping = true; this.stopping = true;
if (command.requestId) {
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
}
return; return;
case 'getStatus': { case 'getStatus': {
if (command.requestId) { if (command.requestId) {
@@ -240,79 +256,61 @@ export class TurnDaemonLifecycle {
budget: command.budget, budget: command.budget,
}; };
this.status.pendingReason = command.reason; this.status.pendingReason = command.reason;
if (command.requestId) {
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
}
return; return;
case 'troopJoin': default:
case 'troopExit':
case 'dieOnPrestart':
case 'buildNationCandidate':
case 'instantRetreat':
case 'vacation':
case 'setMySetting':
case 'dropItem':
case 'auctionFinalize':
case 'changePermission':
case 'kick':
case 'appoint':
await this.handleMutationCommand(command); await this.handleMutationCommand(command);
return; return;
} }
} }
private async handleMutationCommand( private async handleMutationCommand(command: TurnDaemonMutationCommand): Promise<void> {
command: Extract< let result: TurnDaemonCommandResult;
TurnDaemonCommand, let committedByExecutionBoundary = false;
| { type: 'troopJoin' } const executeHandler = async (
| { type: 'troopExit' } context?: TurnDaemonCommandExecutionContext
| { type: 'dieOnPrestart' } ): Promise<TurnDaemonCommandResult> => {
| { type: 'buildNationCandidate' } const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
| { type: 'instantRetreat' } return (
| { type: 'vacation' } handled ?? {
| { type: 'setMySetting' } type: 'commandRejected',
| { type: 'dropItem' } ok: false,
| { type: 'auctionFinalize' } commandType: command.type,
| { type: 'changePermission' } reason: '턴 데몬이 명령을 처리할 수 없습니다.',
| { type: 'kick' }
| { type: 'appoint' }
>
): Promise<void> {
let result: TurnDaemonCommandResult | null = null;
try {
result = this.commandHandler ? await this.commandHandler.handle(command) : null;
if (!result) {
if (command.type === 'auctionFinalize') {
result = {
type: 'auctionFinalize',
ok: false,
auctionId: command.auctionId,
reason: '턴 데몬이 경매 확정을 처리할 수 없습니다.',
};
} else {
result = {
type: command.type,
ok: false,
generalId: command.generalId,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
} as TurnDaemonCommandResult;
} }
);
};
try {
if (command.requestId && this.hooks?.executeCommand) {
result = await this.hooks.executeCommand(command.requestId, executeHandler);
committedByExecutionBoundary = true;
} else {
result = await executeHandler();
} }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : 'Unknown command error.'; // A handler may already have changed the in-memory world. Do not commit
if (command.type === 'auctionFinalize') { // either those changes or the inbox completion marker after an exception.
result = { // Pausing forces a reload/retry instead of acknowledging a partial event.
type: 'auctionFinalize', this.status.state = 'paused';
ok: false, this.status.paused = true;
auctionId: command.auctionId, this.errorPaused = true;
reason, this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
}; await this.hooks?.onRunError?.(error);
} else { return;
result = { }
type: command.type,
ok: false, if (!committedByExecutionBoundary && command.requestId && this.hooks?.commitCommand) {
generalId: command.generalId, try {
reason, await this.hooks.commitCommand(command.requestId, result);
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}), } catch (error) {
} as TurnDaemonCommandResult; this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown input event commit error.';
await this.hooks.onRunError?.(error);
return;
} }
} }
@@ -347,12 +345,26 @@ export class TurnDaemonLifecycle {
} }
this.status.state = 'flushing'; this.status.state = 'flushing';
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime)); try {
await this.stateStore.saveCheckpoint(result.checkpoint); await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
await this.hooks?.flushChanges?.(result); await this.stateStore.saveCheckpoint(result.checkpoint);
await this.hooks?.publishEvents?.(result); await this.hooks?.flushChanges?.(result);
} catch (error) {
this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown turn flush error.';
await this.hooks?.onRunError?.(error);
return;
}
await this.applyRunResult(result, startMs); await this.applyRunResult(result, startMs);
this.status.state = 'idle'; this.status.state = 'idle';
try {
await this.hooks?.publishEvents?.(result);
} catch (error) {
this.status.lastError = error instanceof Error ? error.message : 'Unknown event publication error.';
}
} }
private async applyRunResult(result: TurnRunResult, startMs: number): Promise<void> { private async applyRunResult(result: TurnRunResult, startMs: number): Promise<void> {
+14 -1
View File
@@ -6,6 +6,7 @@ import type {
TurnRunBudget, TurnRunBudget,
TurnRunResult, TurnRunResult,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
export type { export type {
RunReason, RunReason,
@@ -19,7 +20,14 @@ export type {
} from '@sammo-ts/common'; } from '@sammo-ts/common';
export interface TurnDaemonCommandHandler { export interface TurnDaemonCommandHandler {
handle(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null>; handle(
command: TurnDaemonCommand,
context?: TurnDaemonCommandExecutionContext
): Promise<TurnDaemonCommandResult | null>;
}
export interface TurnDaemonCommandExecutionContext {
db?: GamePrisma.TransactionClient;
} }
export interface TurnDaemonCommandResponder { export interface TurnDaemonCommandResponder {
@@ -53,6 +61,11 @@ export interface TurnDaemonControlQueue {
export interface TurnDaemonHooks { export interface TurnDaemonHooks {
flushChanges?(result: TurnRunResult): Promise<void>; flushChanges?(result: TurnRunResult): Promise<void>;
commitCommand?(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
executeCommand?(
requestId: string,
execute: (context: TurnDaemonCommandExecutionContext) => Promise<TurnDaemonCommandResult>
): Promise<TurnDaemonCommandResult>;
publishEvents?(result: TurnRunResult): Promise<void>; publishEvents?(result: TurnRunResult): Promise<void>;
onRunError?(error: unknown): Promise<void>; onRunError?(error: unknown): Promise<void>;
} }
+14 -30
View File
@@ -1,12 +1,15 @@
import { JosaUtil, asRecord } from '@sammo-ts/common'; import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic'; import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export interface TournamentRewardFinalizer { export interface TournamentRewardFinalizer {
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>; finalize(
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -57,39 +60,22 @@ const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush
} }
}; };
const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise<void> => {
if (!hooks?.flushChanges) {
return;
}
const state = world.getState();
await hooks.flushChanges({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
checkpoint: world.getCheckpoint(),
});
};
export const createTournamentRewardFinalizer = async (options: { export const createTournamentRewardFinalizer = async (options: {
databaseUrl: string; databaseUrl: string;
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
}): Promise<TournamentRewardFinalizer> => { }): Promise<TournamentRewardFinalizer> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl }); const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
const finalize = async ( const finalize = async (
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }> command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
commandDb?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult> => { ): Promise<TurnDaemonCommandResult> => {
const { world, hooks } = options; const { world } = options;
const db = commandDb ?? prisma;
const { winnerId, runnerUpId } = command; const { winnerId, runnerUpId } = command;
const rewardMap = new Map< const rewardMap = new Map<number, { gold: number; exp: number; label: string; inheritPoint: number }>();
number,
{ gold: number; exp: number; label: string; inheritPoint: number }
>();
const applyTier = ( const applyTier = (
ids: number[], ids: number[],
@@ -126,7 +112,7 @@ export const createTournamentRewardFinalizer = async (options: {
} }
const nameMap = new Map<number, string>(); const nameMap = new Map<number, string>();
const generals = await prisma.general.findMany({ const generals = await db.general.findMany({
where: { id: { in: Array.from(rewardMap.keys()) } }, where: { id: { in: Array.from(rewardMap.keys()) } },
select: { id: true, userId: true, name: true }, select: { id: true, userId: true, name: true },
}); });
@@ -240,7 +226,7 @@ export const createTournamentRewardFinalizer = async (options: {
.filter((entry) => !!entry.userId); .filter((entry) => !!entry.userId);
for (const entry of pointUpdates) { for (const entry of pointUpdates) {
await prisma.inheritancePoint.upsert({ await db.inheritancePoint.upsert({
where: { where: {
userId_key: { userId: entry.userId!, key: 'tournament' }, userId_key: { userId: entry.userId!, key: 'tournament' },
}, },
@@ -249,8 +235,6 @@ export const createTournamentRewardFinalizer = async (options: {
}); });
} }
await flushWorld(world, hooks);
return { return {
type: 'tournamentReward', type: 'tournamentReward',
ok: true, ok: true,
@@ -269,4 +253,4 @@ export const createTournamentRewardFinalizer = async (options: {
await connector.disconnect(); await connector.disconnect();
}, },
}; };
}; };
+32 -25
View File
@@ -1,10 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import type { import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common';
TurnDaemonCommand,
TurnDaemonCommandType,
TurnDaemonCommandByType,
} from '@sammo-ts/common';
export type TurnDaemonCommandEnvelope = { export type TurnDaemonCommandEnvelope = {
requestId: string; requestId: string;
@@ -161,22 +157,21 @@ const zSetNationMeta = z.object({
expectedUpdatedAt: z.string().optional(), expectedUpdatedAt: z.string().optional(),
}); });
const zAdjustGeneralResources = z const zAdjustGeneralResources = z.object({
.object({ type: z.literal('adjustGeneralResources'),
type: z.literal('adjustGeneralResources'), reason: z.string().optional(),
reason: z.string().optional(), adjustments: z
adjustments: z .array(
.array( z
z .object({
.object({ generalId: zFiniteNumber,
generalId: zFiniteNumber, goldDelta: zFiniteNumber.optional(),
goldDelta: zFiniteNumber.optional(), riceDelta: zFiniteNumber.optional(),
riceDelta: zFiniteNumber.optional(), })
}) .refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined) )
) .min(1),
.min(1), });
});
const zAdjustGeneralMeta = z.object({ const zAdjustGeneralMeta = z.object({
type: z.literal('adjustGeneralMeta'), type: z.literal('adjustGeneralMeta'),
@@ -430,10 +425,22 @@ const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => {
}; };
}; };
const normalizeRun: CommandNormalizer<'run'> = (envelope) => parseWith(zRun, envelope.command); const normalizeRun: CommandNormalizer<'run'> = (envelope) => {
const normalizePause: CommandNormalizer<'pause'> = (envelope) => parseWith(zPause, envelope.command); const command = parseWith(zRun, envelope.command);
const normalizeResume: CommandNormalizer<'resume'> = (envelope) => parseWith(zResume, envelope.command); return command ? { ...command, requestId: envelope.requestId } : null;
const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => parseWith(zShutdown, envelope.command); };
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 = { const normalizers: CommandNormalizerMap = {
auctionFinalize: normalizeAuctionFinalize, auctionFinalize: normalizeAuctionFinalize,
+78 -32
View File
@@ -1,5 +1,6 @@
import { import {
createGamePostgresConnector, createGamePostgresConnector,
type GamePrisma,
type InputJsonValue, type InputJsonValue,
type TurnEngineCityUpdateInput, type TurnEngineCityUpdateInput,
type TurnEngineDiplomacyCreateManyInput, type TurnEngineDiplomacyCreateManyInput,
@@ -15,7 +16,7 @@ import {
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic'; import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common'; 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 { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { buildDiplomacyMeta } from '@sammo-ts/logic';
@@ -305,32 +306,37 @@ export const createDatabaseTurnHooks = async (
await connector.connect(); await connector.connect();
const prisma = connector.prisma; const prisma = connector.prisma;
const hooks: TurnDaemonHooks = { const persistChanges = async (
flushChanges: async () => { transaction?: GamePrisma.TransactionClient,
const state = world.getState(); commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }
const { ): Promise<() => void> => {
generals, const state = world.getState();
cities, const changes = world.peekDirtyState();
nations, const {
troops, generals,
deletedTroops, cities,
deletedGenerals, nations,
deletedNations, troops,
deletedNationSnapshots, deletedTroops,
diplomacy, deletedGenerals,
logs, deletedNations,
createdGenerals, deletedNationSnapshots,
createdNations, diplomacy,
createdTroops, logs,
createdDiplomacy, createdGenerals,
} = world.consumeDirtyState(); createdNations,
createdTroops,
createdDiplomacy,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
const worldStateUpdate: TurnEngineWorldStateUpdateInput = { const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, tickSeconds: state.tickSeconds,
meta: asJson(state.meta), meta: asJson(state.meta),
}; };
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
await prisma.worldState.update({ await prisma.worldState.update({
where: { id: state.id }, where: { id: state.id },
data: worldStateUpdate, data: worldStateUpdate,
@@ -462,10 +468,7 @@ export const createDatabaseTurnHooks = async (
if (deletedNations.length > 0) { if (deletedNations.length > 0) {
await prisma.diplomacy.deleteMany({ await prisma.diplomacy.deleteMany({
where: { where: {
OR: [ OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }],
{ srcNationId: { in: deletedNations } },
{ destNationId: { in: deletedNations } },
],
}, },
}); });
await prisma.nationTurn.deleteMany({ await prisma.nationTurn.deleteMany({
@@ -563,9 +566,52 @@ export const createDatabaseTurnHooks = async (
}); });
} }
} }
if (options?.reservedTurns) { if (options?.reservedTurns && reservedTurnChanges) {
await options.reservedTurns.flushChanges(); 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; 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 compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime(); const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
if (timeDiff !== 0) { if (timeDiff !== 0) {
@@ -694,22 +711,7 @@ export class InMemoryTurnWorld {
} }
} }
consumeDirtyState(): { peekDirtyState(): 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 generals = Array.from(this.dirtyGeneralIds) const generals = Array.from(this.dirtyGeneralIds)
.map((id) => this.generals.get(id)) .map((id) => this.generals.get(id))
.filter((general): general is TurnGeneral => Boolean(general)); .filter((general): general is TurnGeneral => Boolean(general));
@@ -740,21 +742,8 @@ export class InMemoryTurnWorld {
const deletedTroops = Array.from(this.deletedTroopIds); const deletedTroops = Array.from(this.deletedTroopIds);
const deletedGenerals = Array.from(this.deletedGeneralIds); const deletedGenerals = Array.from(this.deletedGeneralIds);
const deletedNations = Array.from(this.deletedNationIds); const deletedNations = Array.from(this.deletedNationIds);
const deletedNationSnapshots = this.deletedNationSnapshots.splice(0, this.deletedNationSnapshots.length); const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.splice(0, this.logs.length); const logs = this.logs.slice();
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();
return { return {
generals, 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 { private removeCollapsedNations(): void {
const collapsedNationIds: number[] = []; const collapsedNationIds: number[] = [];
for (const nation of this.nations.values()) { 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'>; type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'>;
export interface ReservedTurnChanges {
generalIds: number[];
nationKeys: string[];
}
export class InMemoryReservedTurnStore { export class InMemoryReservedTurnStore {
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>(); private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>(); private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
@@ -213,12 +218,27 @@ export class InMemoryReservedTurnStore {
this.dirtyNationKeys.add(key); this.dirtyNationKeys.add(key);
} }
async flushChanges(): Promise<void> { peekDirtyState(): ReservedTurnChanges {
const generalIds = Array.from(this.dirtyGeneralIds); return {
for (const generalId of generalIds) { 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); const turns = this.getGeneralTurns(generalId);
await this.prisma.generalTurn.deleteMany({ where: { generalId } }); await prisma.generalTurn.deleteMany({ where: { generalId } });
await this.prisma.generalTurn.createMany({ await prisma.generalTurn.createMany({
data: turns.map((entry, turnIdx) => ({ data: turns.map((entry, turnIdx) => ({
generalId, generalId,
turnIdx, turnIdx,
@@ -228,16 +248,15 @@ export class InMemoryReservedTurnStore {
}); });
} }
const nationKeys = Array.from(this.dirtyNationKeys); for (const key of changes.nationKeys) {
for (const key of nationKeys) {
const [nationIdRaw, officerLevelRaw] = key.split(':'); const [nationIdRaw, officerLevelRaw] = key.split(':');
const nationId = Number(nationIdRaw); const nationId = Number(nationIdRaw);
const officerLevel = Number(officerLevelRaw); const officerLevel = Number(officerLevelRaw);
const turns = this.getNationTurns(nationId, officerLevel); const turns = this.getNationTurns(nationId, officerLevel);
await this.prisma.nationTurn.deleteMany({ await prisma.nationTurn.deleteMany({
where: { nationId, officerLevel }, where: { nationId, officerLevel },
}); });
await this.prisma.nationTurn.createMany({ await prisma.nationTurn.createMany({
data: turns.map((entry, turnIdx) => ({ data: turns.map((entry, turnIdx) => ({
nationId, nationId,
officerLevel, officerLevel,
@@ -247,9 +266,12 @@ export class InMemoryReservedTurnStore {
})), })),
}); });
} }
}
this.dirtyGeneralIds.clear(); async flushChanges(): Promise<void> {
this.dirtyNationKeys.clear(); 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 type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; 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 { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
import { SystemClock } from '../lifecycle/clock.js'; import { SystemClock } from '../lifecycle/clock.js';
@@ -8,7 +8,7 @@ import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js'; import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js'; import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.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 type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { createDatabaseTurnHooks } from './databaseHooks.js'; import { createDatabaseTurnHooks } from './databaseHooks.js';
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.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 auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null; let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null; let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let pauseGate: (() => Promise<boolean>) | undefined; let pauseGate: (() => Promise<boolean>) | undefined;
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null; let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
const gatewayGate = options.profileName const gatewayGate = options.profileName
@@ -222,17 +221,14 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
auctionBidder = await createAuctionBidder({ auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl, databaseUrl: options.databaseUrl,
world, world,
hooks: dbHooks.hooks,
}); });
auctionFinalizer = await createAuctionFinalizer({ auctionFinalizer = await createAuctionFinalizer({
databaseUrl: options.databaseUrl, databaseUrl: options.databaseUrl,
world, world,
hooks: dbHooks.hooks,
}); });
tournamentRewardFinalizer = await createTournamentRewardFinalizer({ tournamentRewardFinalizer = await createTournamentRewardFinalizer({
databaseUrl: options.databaseUrl, databaseUrl: options.databaseUrl,
world, world,
hooks: dbHooks.hooks,
}); });
hooks = { hooks = {
...dbHooks.hooks, ...dbHooks.hooks,
@@ -290,10 +286,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
redisConnector = createRedisConnector(redisConfig); redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect(); await redisConnector.connect();
const redisClient = redisConnector.client; const redisClient = redisConnector.client;
redisCommandStream = new RedisTurnDaemonCommandStream(redisClient, {
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
startId: options.commandStreamStartId,
});
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile); const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
publishRealtimeEvent = async (event: RealtimeEvent) => { publishRealtimeEvent = async (event: RealtimeEvent) => {
await redisClient.publish(realtimeChannel, JSON.stringify(event)); 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; const baseClose = close;
close = async () => { close = async () => {
await baseClose(); await baseClose();
@@ -330,12 +329,12 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
if (redisConnector) { if (redisConnector) {
await redisConnector.disconnect(); await redisConnector.disconnect();
} }
await commandConnector?.disconnect();
}; };
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue; const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
const commandHandler = createTurnDaemonCommandHandler({ const commandHandler = createTurnDaemonCommandHandler({
world, world,
hooks,
auctionFinalizer: auctionFinalizer ?? undefined, auctionFinalizer: auctionFinalizer ?? undefined,
auctionBidder: auctionBidder ?? undefined, auctionBidder: auctionBidder ?? undefined,
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined, tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
@@ -357,7 +356,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
hooks, hooks,
pauseGate, pauseGate,
commandHandler, commandHandler,
commandResponder: redisCommandStream ?? undefined, commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
}, },
{ profile: options.profile, defaultBudget } { profile: options.profile, defaultBudget }
); );
+77 -88
View File
@@ -1,10 +1,10 @@
import type { import type {
TurnDaemonHooks,
TurnDaemonCommandHandler, TurnDaemonCommandHandler,
TurnDaemonCommand, TurnDaemonCommand,
TurnDaemonCommandExecutionContext,
TurnDaemonCommandResult, TurnDaemonCommandResult,
TurnRunResult,
} from '../lifecycle/types.js'; } from '../lifecycle/types.js';
import type { GamePrisma } from '@sammo-ts/infra';
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { import {
LogCategory, LogCategory,
@@ -23,25 +23,6 @@ import {
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.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; let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => { const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
@@ -67,29 +48,35 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: nu
interface CommandHandlerContext { interface CommandHandlerContext {
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks; commandDb?: GamePrisma.TransactionClient;
auctionFinalizer?: AuctionFinalizer; auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder; auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer; tournamentRewardFinalizer?: TournamentRewardFinalizer;
} }
interface AuctionFinalizer { interface AuctionFinalizer {
finalize(auctionId: number): Promise<TurnDaemonCommandResult>; finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
} }
interface AuctionBidder { interface AuctionBidder {
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>; bid(
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
} }
interface TournamentRewardFinalizer { interface TournamentRewardFinalizer {
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>; finalize(
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
} }
async function handleSetNationMeta( async function handleSetNationMeta(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }> command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const nation = world.getNationById(command.nationId); const nation = world.getNationById(command.nationId);
if (!nation) { if (!nation) {
return { return {
@@ -122,7 +109,6 @@ async function handleSetNationMeta(
world.updateNation(command.nationId, { world.updateNation(command.nationId, {
meta: nextMeta, meta: nextMeta,
}); });
await flushWorld(world, hooks);
return { return {
type: 'setNationMeta', type: 'setNationMeta',
ok: true, ok: true,
@@ -135,7 +121,7 @@ async function handleAdjustGeneralResources(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
} }
@@ -176,8 +162,6 @@ async function handleAdjustGeneralResources(
totalGoldDelta += goldDelta; totalGoldDelta += goldDelta;
totalRiceDelta += riceDelta; totalRiceDelta += riceDelta;
} }
await flushWorld(world, hooks);
return { return {
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
ok: true, ok: true,
@@ -192,7 +176,7 @@ async function handleAdjustGeneralMeta(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { return {
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
@@ -224,8 +208,6 @@ async function handleAdjustGeneralMeta(
world.updateGeneral(adjustment.generalId, { meta: nextMeta }); world.updateGeneral(adjustment.generalId, { meta: nextMeta });
processed += 1; processed += 1;
} }
await flushWorld(world, hooks);
return { return {
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
ok: true, ok: true,
@@ -238,7 +220,7 @@ async function handleTournamentMatchResult(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }> command: Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const resolvePrefix = (type: number): string => { const resolvePrefix = (type: number): string => {
switch (type) { switch (type) {
case 1: case 1:
@@ -342,8 +324,6 @@ async function handleTournamentMatchResult(
nextMeta[rankKey('g')] = defenderG + defenderGDelta; nextMeta[rankKey('g')] = defenderG + defenderGDelta;
world.updateGeneral(defender.id, { meta: nextMeta }); world.updateGeneral(defender.id, { meta: nextMeta });
} }
await flushWorld(world, hooks);
return { return {
type: 'tournamentMatchResult', type: 'tournamentMatchResult',
ok: true, ok: true,
@@ -358,7 +338,7 @@ async function handlePatchGeneral(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'patchGeneral' }> command: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -396,7 +376,6 @@ async function handlePatchGeneral(
} }
world.updateGeneral(command.generalId, patch); world.updateGeneral(command.generalId, patch);
await flushWorld(world, hooks);
return { type: 'patchGeneral', ok: true, generalId: command.generalId }; return { type: 'patchGeneral', ok: true, generalId: command.generalId };
} }
@@ -404,7 +383,7 @@ async function handleTroopJoin(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }> command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -448,7 +427,6 @@ async function handleTroopJoin(
world.updateGeneral(command.generalId, { world.updateGeneral(command.generalId, {
troopId: command.troopId, troopId: command.troopId,
}); });
await flushWorld(world, hooks);
return { return {
type: 'troopJoin', type: 'troopJoin',
ok: true, ok: true,
@@ -461,7 +439,7 @@ async function handleTroopExit(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'troopExit' }> command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -484,7 +462,6 @@ async function handleTroopExit(
world.updateGeneral(command.generalId, { world.updateGeneral(command.generalId, {
troopId: 0, troopId: 0,
}); });
await flushWorld(world, hooks);
return { return {
type: 'troopExit', type: 'troopExit',
ok: true, ok: true,
@@ -499,7 +476,6 @@ async function handleTroopExit(
world.updateGeneral(member.id, { troopId: 0 }); world.updateGeneral(member.id, { troopId: 0 });
} }
world.removeTroop(troopId); world.removeTroop(troopId);
await flushWorld(world, hooks);
return { return {
type: 'troopExit', type: 'troopExit',
ok: true, ok: true,
@@ -512,7 +488,7 @@ async function handleDieOnPrestart(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }> command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -533,7 +509,6 @@ async function handleDieOnPrestart(
} }
world.removeGeneral(command.generalId); world.removeGeneral(command.generalId);
await flushWorld(world, hooks);
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId }; return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
} }
@@ -619,7 +594,7 @@ async function handleSetMySetting(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }> command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -635,7 +610,6 @@ async function handleSetMySetting(
...command.settings, ...command.settings,
}, },
}); });
await flushWorld(world, hooks);
return { type: 'setMySetting', ok: true, generalId: command.generalId }; return { type: 'setMySetting', ok: true, generalId: command.generalId };
} }
@@ -643,7 +617,7 @@ async function handleDropItem(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'dropItem' }> command: Extract<TurnDaemonCommand, { type: 'dropItem' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -664,7 +638,6 @@ async function handleDropItem(
items, items,
}, },
}); });
await flushWorld(world, hooks);
return { type: 'dropItem', ok: true, generalId: command.generalId }; return { type: 'dropItem', ok: true, generalId: command.generalId };
} }
@@ -680,7 +653,7 @@ async function handleAuctionFinalize(
reason: '경매 확정기가 준비되지 않았습니다.', reason: '경매 확정기가 준비되지 않았습니다.',
}; };
} }
return ctx.auctionFinalizer.finalize(command.auctionId); return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
} }
async function handleAuctionBid( async function handleAuctionBid(
@@ -695,14 +668,14 @@ async function handleAuctionBid(
reason: '경매 입찰기가 준비되지 않았습니다.', reason: '경매 입찰기가 준비되지 않았습니다.',
}; };
} }
return ctx.auctionBidder.bid(command); return ctx.auctionBidder.bid(command, ctx.commandDb);
} }
async function handleChangePermission( async function handleChangePermission(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'changePermission' }> command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -728,8 +701,6 @@ async function handleChangePermission(
}); });
} }
} }
await flushWorld(world, hooks);
return { type: 'changePermission', ok: true, generalId: command.generalId }; return { type: 'changePermission', ok: true, generalId: command.generalId };
} }
@@ -737,7 +708,7 @@ async function handleKick(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'kick' }> command: Extract<TurnDaemonCommand, { type: 'kick' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
@@ -761,8 +732,6 @@ async function handleKick(
nationId: 0, nationId: 0,
officerLevel: 0, officerLevel: 0,
}); });
await flushWorld(world, hooks);
return { type: 'kick', ok: true, generalId: command.generalId }; return { type: 'kick', ok: true, generalId: command.generalId };
} }
@@ -770,7 +739,7 @@ async function handleAppoint(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'appoint' }> command: Extract<TurnDaemonCommand, { type: 'appoint' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; 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 }; return { type: 'appoint', ok: true, generalId: command.generalId };
} }
@@ -834,7 +801,7 @@ async function handleTournamentRefund(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentRefund' }> command: Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
if (!command.refunds || command.refunds.length === 0) { if (!command.refunds || command.refunds.length === 0) {
return { return {
type: 'tournamentRefund', type: 'tournamentRefund',
@@ -866,8 +833,6 @@ async function handleTournamentRefund(
processed += 1; processed += 1;
totalRefund += refund.amount; totalRefund += refund.amount;
} }
await flushWorld(world, hooks);
return { return {
type: 'tournamentRefund', type: 'tournamentRefund',
ok: true, ok: true,
@@ -882,7 +847,7 @@ async function handleTournamentBettingPayout(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }> command: Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
if (!command.payouts || command.payouts.length === 0) { if (!command.payouts || command.payouts.length === 0) {
return { return {
type: 'tournamentBettingPayout', type: 'tournamentBettingPayout',
@@ -935,8 +900,6 @@ async function handleTournamentBettingPayout(
nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold; nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold;
world.updateGeneral(generalId, { meta: nextMeta }); world.updateGeneral(generalId, { meta: nextMeta });
} }
await flushWorld(world, hooks);
return { return {
type: 'tournamentBettingPayout', type: 'tournamentBettingPayout',
ok: true, ok: true,
@@ -960,7 +923,7 @@ async function handleTournamentReward(
reason: '보상 처리기가 준비되지 않았습니다.', reason: '보상 처리기가 준비되지 않았습니다.',
}; };
} }
return ctx.tournamentRewardFinalizer.finalize(command); return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb);
} }
// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다. // 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다.
@@ -968,7 +931,7 @@ async function handleVoteReward(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'voteReward' }> command: Extract<TurnDaemonCommand, { type: 'voteReward' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world, hooks } = ctx; const { world } = ctx;
const general = world.getGeneralById(command.generalId); const general = world.getGeneralById(command.generalId);
if (!general) { if (!general) {
return { return {
@@ -1153,7 +1116,6 @@ async function handleVoteReward(
} }
world.updateGeneral(command.generalId, patch); world.updateGeneral(command.generalId, patch);
await flushWorld(world, hooks);
return { return {
type: 'voteReward', type: 'voteReward',
ok: true, ok: true,
@@ -1166,54 +1128,81 @@ async function handleVoteReward(
export const createTurnDaemonCommandHandler = (options: { export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
auctionFinalizer?: AuctionFinalizer; auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder; auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer; tournamentRewardFinalizer?: TournamentRewardFinalizer;
}): TurnDaemonCommandHandler => { }): TurnDaemonCommandHandler => {
const ctx = { const ctx: CommandHandlerContext = {
world: options.world, world: options.world,
hooks: options.hooks,
auctionFinalizer: options.auctionFinalizer, auctionFinalizer: options.auctionFinalizer,
auctionBidder: options.auctionBidder, auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer, 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 = { const handlers: HandlerMap = {
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>), troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>), troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>), dieOnPrestart: (command) =>
buildNationCandidate: (command) => handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>), handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
instantRetreat: (command) => handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>), 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' }>), 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' }>), 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' }>), 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' }>), kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>), appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>), tournamentRefund: (command) =>
tournamentBettingPayout: (command) => handleTournamentBettingPayout(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>), handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
tournamentReward: (command) => handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>), 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' }>), voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>), setNationMeta: (command) =>
adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>), handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>), adjustGeneralResources: (command) =>
handleAdjustGeneralResources(
ctx,
command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
),
adjustGeneralMeta: (command) =>
handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
tournamentMatchResult: (command) => tournamentMatchResult: (command) =>
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>), 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 { return {
handle: async (command): Promise<TurnDaemonCommandResult | null> => { handle: async (
command,
executionContext?: TurnDaemonCommandExecutionContext
): Promise<TurnDaemonCommandResult | null> => {
const handler = handlers[command.type]; const handler = handlers[command.type];
if (!handler) { if (!handler) {
return null; return null;
} }
return handler(command); ctx.commandDb = executionContext?.db;
try {
return await handler(command);
} finally {
ctx.commandDb = undefined;
}
}, },
}; };
}; };
@@ -0,0 +1,95 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('database command queue', () => {
let close: (() => Promise<void>) | undefined;
let db: GamePrismaClient;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:engine:' } },
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:engine:' } },
});
await close?.();
});
it('claims one durable event only once across concurrent consumers and persists its result', async () => {
const requestId = 'integration:engine:claim-once';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue,
},
});
const first = new DatabaseTurnDaemonCommandQueue(db);
const second = new DatabaseTurnDaemonCommandQueue(db);
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands);
expect(commands).toEqual([{ type: 'vacation', requestId, generalId: 7 }]);
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(stored).toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
result: { type: 'vacation', ok: true, generalId: 7 },
});
});
it('recovers only an expired processing lease', async () => {
const expiredId = 'integration:engine:expired';
const activeId = 'integration:engine:active';
await db.inputEvent.createMany({
data: [
{
requestId: expiredId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: expiredId, generalId: 8 } as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(Date.now() - 120_000),
lockedBy: 'dead-worker',
leaseUntil: new Date(Date.now() - 60_000),
},
{
requestId: activeId,
target: 'ENGINE',
eventType: 'vacation',
payload: { type: 'vacation', requestId: activeId, generalId: 9 } as GamePrisma.InputJsonValue,
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: 'active-worker',
leaseUntil: new Date(Date.now() + 60_000),
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const commands = await queue.drain();
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, generalId: 8 }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING',
lockedBy: 'active-worker',
});
});
});
@@ -0,0 +1,285 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryControlQueue,
ManualClock,
TurnDaemonLifecycle,
type TurnDaemonCommandResult,
type TurnProcessor,
type TurnStateStore,
} from '../src/index.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
const createStateStore = (): TurnStateStore => ({
loadLastTurnTime: async () => new Date('2026-01-01T00:00:00.000Z'),
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
});
const processor: TurnProcessor = {
run: async () => {
throw new Error('scheduled turn must not run in this test');
},
};
describe('input event atomicity', () => {
it('keeps reserved-turn dirty state when persistence fails', async () => {
let failCreate = true;
const prisma = {
generalTurn: {
findMany: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => {
if (failCreate) {
throw new Error('injected write failure');
}
return { count: 1 };
}),
},
nationTurn: {
findMany: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
};
const store = new InMemoryReservedTurnStore(prisma, {
maxGeneralTurns: 1,
maxNationTurns: 1,
});
store.shiftGeneralTurns(7, -1);
await expect(store.flushChanges()).rejects.toThrow('injected write failure');
expect(store.peekDirtyState()).toEqual({ generalIds: [7], nationKeys: [] });
failCreate = false;
await store.flushChanges();
expect(store.peekDirtyState()).toEqual({ generalIds: [], nationKeys: [] });
});
it('dispatches registry mutations that the old lifecycle switch dropped, then commits before responding', async () => {
const queue = new InMemoryControlQueue();
const order: string[] = [];
const result: TurnDaemonCommandResult = {
type: 'auctionBid',
ok: true,
auctionId: 3,
closeAt: '2026-01-01T00:10:00.000Z',
};
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async (command) => {
order.push(`handle:${command.type}`);
return result;
},
},
hooks: {
commitCommand: async (requestId, committedResult) => {
order.push(`commit:${requestId}:${committedResult.type}`);
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult: async (requestId, response) => {
order.push(`respond:${requestId}:${response.type}`);
resolveResponse?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({
type: 'auctionBid',
requestId: 'event-1',
auctionId: 3,
generalId: 7,
amount: 1000,
});
const loop = lifecycle.start();
await responded;
expect(order).toEqual(['handle:auctionBid', 'commit:event-1:auctionBid', 'respond:event-1:auctionBid']);
await lifecycle.stop('done');
await loop;
});
it('runs a mutation inside the database-owned execution boundary before responding', async () => {
const queue = new InMemoryControlQueue();
const order: string[] = [];
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async (command) => {
order.push(`handle:${command.type}`);
return { type: 'vacation', ok: true, generalId: 7 };
},
},
hooks: {
executeCommand: async (requestId, execute) => {
order.push(`transaction:${requestId}:begin`);
const result = await execute({});
order.push(`transaction:${requestId}:commit`);
return result;
},
commitCommand: async () => {
order.push('legacy-commit');
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult: async () => {
order.push('respond');
resolveResponse?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 });
const loop = lifecycle.start();
await responded;
expect(order).toEqual([
'transaction:event-uow:begin',
'handle:vacation',
'transaction:event-uow:commit',
'respond',
]);
await lifecycle.stop('done');
await loop;
});
it('pauses without publishing success when the atomic command commit fails', async () => {
const queue = new InMemoryControlQueue();
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const publishCommandResult = vi.fn(async () => {});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async () => ({ type: 'vacation', ok: true, generalId: 7 }),
},
hooks: {
commitCommand: async () => {
throw new Error('injected commit failure');
},
onRunError: async () => {
resolveError?.();
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'injected commit failure',
});
expect(publishCommandResult).not.toHaveBeenCalled();
await lifecycle.stop('done');
await loop;
});
it('pauses without committing or acknowledging a handler that throws after changing memory', async () => {
const queue = new InMemoryControlQueue();
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const commitCommand = vi.fn(async () => {});
const publishCommandResult = vi.fn(async () => {});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async () => {
throw new Error('injected handler failure');
},
},
hooks: {
commitCommand,
onRunError: async () => {
resolveError?.();
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'injected handler failure',
});
expect(commitCommand).not.toHaveBeenCalled();
expect(publishCommandResult).not.toHaveBeenCalled();
await lifecycle.stop('done');
await loop;
});
});
+34 -30
View File
@@ -82,32 +82,37 @@ Gateway runs a lightweight cron loop (setInterval) that:
## Current Implementation Status ## Current Implementation Status
- Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks. - Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks.
- Redis transport for daemon control is implemented and wired into the daemon. - 모든 tRPC mutation은 PostgreSQL `input_event` 경계에서 request ID와 처리
- API server already exposes turn-daemon commands (run/pause/resume/status) via tRPC, communicating via Redis Streams. 상태를 기록한다.
- API server writes reserved turns and messages directly to the DB; daemon focuses on world state/logs. - 월드 mutation과 daemon control은 PostgreSQL inbox로 전달된다. Redis는
더 이상 이 명령들의 영속 큐가 아니며 realtime/battle-sim 전송에만 남아
있다.
- API가 직접 변경하는 예약 턴·메시지 등의 DB 쓰기는 API input event 완료와
같은 transaction에서 commit된다.
- 엔진 명령은 도메인 DB 쓰기, in-memory world flush, 예약 턴, 로그, 결과
저장과 inbox 완료를 하나의 transaction으로 commit한다.
## Turn Daemon and API Server Behavior (Outline) ## Turn Daemon and API Server Behavior (Outline)
- Turn daemon responsibilities: scheduling, turn resolution, state persistence - Turn daemon responsibilities: scheduling, turn resolution, state persistence
- API server responsibilities: query/command intake, validation, response shaping - API server responsibilities: query/command intake, validation, response shaping
- Concurrency model between daemon and API server - Concurrency model between daemon and API server
- Communication channel: Redis Stream or Redis pub/sub - Durable command channel: PostgreSQL `input_event`
- Client updates: SSE between API server and frontend where appropriate - Client updates: SSE between API server and frontend where appropriate
## Redis Communication Recommendation (Draft) ## Durable Input Event Split
Use Redis Streams for daemon control and mutation requests, and Redis pub/sub PostgreSQL is the source of truth for accepted input. Redis pub/sub remains a
for transient fan-out events. Streams provide durability, backpressure, and best-effort notification/fan-out path and must not decide whether a gameplay
replay while pub/sub keeps live updates simple and low-latency. mutation was committed.
### Recommended Split ### Recommended Split
- Redis Streams: - PostgreSQL `input_event`:
- API server -> daemon: mutation requests, turn-run commands. - API mutation acceptance, idempotency, attempts and audit state.
- Daemon -> API server: run status events, job results, error reports. - API server -> daemon mutation/control payloads and durable results.
- Use consumer groups for daemon workers and API server listeners. - `FOR UPDATE SKIP LOCKED` claim plus worker lease for concurrent consumers.
- Require `requestId` for correlation and idempotency. - engine result and gameplay state commit in the same transaction.
- Ack on success; move failed items to a dead-letter stream after retry.
- Redis pub/sub: - Redis pub/sub:
- Daemon -> API server: low-stakes live update signals (run started/ended). - Daemon -> API server: low-stakes live update signals (run started/ended).
- API server -> frontend: SSE fan-out triggered by pub/sub updates. - API server -> frontend: SSE fan-out triggered by pub/sub updates.
@@ -115,11 +120,11 @@ replay while pub/sub keeps live updates simple and low-latency.
### Operational Notes ### Operational Notes
- Stream keys should be namespaced per server+scenario profile. - 각 profile은 별도 game DB schema/connection을 사용한다.
- Use bounded stream length (`MAXLEN`) to cap storage. - HTTP `Idempotency-Key`(없으면 Fastify request ID)와 tRPC path를 합친 값이
- API server should guard against duplicate processing by `requestId`. API input event key다.
- When the daemon is busy, API queues new mutations to stream and responds - 처리 중 lease가 만료된 engine event만 `PENDING`으로 회수한다.
with an accepted status to clients. - 완료 event 보존/정리 기간과 `FAILED` 재처리 운영 정책은 별도로 정해야 한다.
Detailed lifecycle and control flow are defined in Detailed lifecycle and control flow are defined in
`docs/architecture/turn-daemon-lifecycle.md`. `docs/architecture/turn-daemon-lifecycle.md`.
@@ -167,13 +172,14 @@ also supports local ID/password login for users who cannot use Kakao.
immediately even if requests remain queued. immediately even if requests remain queued.
- While the daemon is resolving a turn, the API server queues incoming requests. - While the daemon is resolving a turn, the API server queues incoming requests.
Note: the current implementation does not yet process API mutation requests 현재 구현은 control 명령과 registry의 모든 world mutation을 같은 DB queue로
between turns; only control commands are handled by the in-process queue. 읽어 턴 사이에 처리한다.
### Daemon Control Contract (Draft) ### Daemon Control Contract (Draft)
API server commands are delivered to the daemon over the control channel API server commands are inserted into PostgreSQL `input_event`; the daemon
(Redis Stream or in-process). The daemon replies with status and run events. stores status/results on the same row. An in-process queue remains available
for tests and internal signals.
```ts ```ts
export type RunReason = 'schedule' | 'manual' | 'poke'; export type RunReason = 'schedule' | 'manual' | 'poke';
@@ -193,19 +199,17 @@ export type DaemonEvent =
### API Server Flow ### API Server Flow
- The API server validates queries/commands and writes them to Redis Streams - The API server validates commands and records every tRPC mutation in
or Redis pub/sub. PostgreSQL.
- After a request is processed, the API server returns the result to clients. - After a request is processed, the API server returns the result to clients.
- Read-only queries may access the DBMS directly. - Read-only queries may access the DBMS directly.
- The API server may use SSE to stream live updates to the frontend. - The API server may use SSE to stream live updates to the frontend.
### Queue and Rate Limits ### Queue and Rate Limits
- API server requests are delivered to the daemon via Redis Streams or - Engine-owned requests are delivered through the PostgreSQL inbox.
Redis pub/sub. - Per-user pending limits and event retention remain operational follow-up
- Redis Stream mutation requests are rate-limited per user. work; idempotency and exclusive claim are implemented.
- Each user can have up to 30 pending mutation requests.
- Additional requests are rejected once the limit is exceeded.
### In-Memory and DBMS Flush ### In-Memory and DBMS Flush
+8 -8
View File
@@ -8,14 +8,14 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Make profile turn execution a durable single-writer contract: - [AI suggestion] Make profile turn execution a durable single-writer contract:
add a DB-backed lease with fencing epoch/world version, and reject stale add a DB-backed lease with fencing epoch/world version, and reject stale
daemon commits after a restart or split-brain. daemon commits after a restart or split-brain.
- [AI suggestion] Replace the current destructive dirty-state drain and - [AI suggestion] Bound very large world flush batches and add a transactional
multi-query flush with bounded atomic batches. Persist entity deltas, logs, realtime outbox. Entity deltas, logs, reserved turns, checkpoint and input
reserved turns, checkpoint, and outbox events in one transaction, then clear results are atomic now, but realtime publication remains post-commit
dirty state only after commit. best-effort.
- [AI suggestion] Introduce durable command inbox/outbox records with unique - [AI suggestion] Define `input_event` retention, failed-event inspection/DLQ,
request IDs, idempotent results, and per-profile ordering. Keep Redis as a manual replay authorization and per-user pending limits. Durable request IDs,
wake-up/fan-out layer, or add consumer groups, ACK/pending recovery, DLQ, and idempotent results, ordering, exclusive claim and expired-lease recovery are
stream trimming if Redis Streams remain the command source. implemented.
- [AI suggestion] Add ownership authorization to all turn reservation - [AI suggestion] Add ownership authorization to all turn reservation
procedures (`auth user -> owned general -> current officer role -> nation`) procedures (`auth user -> owned general -> current officer role -> nation`)
and add revision/optimistic concurrency to reservation queue updates. and add revision/optimistic concurrency to reservation queue updates.
+32 -7
View File
@@ -8,14 +8,15 @@ from the API server.
- One daemon process per server+scenario profile. - One daemon process per server+scenario profile.
- API server is the only ingress for user/admin requests. - API server is the only ingress for user/admin requests.
- The daemon owns world-state mutations; the API server mutates reserved turns and messages. - The daemon owns world-state mutations; the API server mutates reserved turns
and messages inside the common API input-event transaction.
## Current Implementation Status ## Current Implementation Status
- The daemon loop handles scheduled runs plus Redis-based control queue commands - The daemon loop handles scheduled runs plus PostgreSQL inbox control commands
(run/pause/resume/shutdown/getStatus). (run/pause/resume/shutdown/getStatus).
- API mutation requests (troopJoin, vacation, etc.) are drained and handled by the daemon loop between turns. - API mutation requests (troopJoin, vacation, etc.) are drained and handled by the daemon loop between turns.
- `getStatus` is fully supported and responds via Redis. - `getStatus` and command results are stored durably on their input-event row.
- API server currently writes reserved turns and messages directly to the DB. - API server currently writes reserved turns and messages directly to the DB.
## Responsibilities ## Responsibilities
@@ -47,8 +48,7 @@ Idle -> Running -> Flushing -> Idle
The daemon interleaves API request handling between scheduled turn executions. The daemon interleaves API request handling between scheduled turn executions.
API requests are drained until the next turn time is reached; once the turn API requests are drained until the next turn time is reached; once the turn
starts, incoming requests are queued and processed after the run. starts, incoming requests are queued and processed after the run.
Current implementation does not yet drain API requests between turns; this The current implementation follows this interleaving model.
section is the intended target behavior.
```ts ```ts
while (!stopping) { while (!stopping) {
@@ -101,7 +101,7 @@ Minimum checkpoint data:
## API Server Interaction ## API Server Interaction
- API server enqueues mutations and pokes the daemon. - API server inserts mutations and pokes into the PostgreSQL inbox.
- Read-only queries can read from DBMS or a read model. - Read-only queries can read from DBMS or a read model.
- During `running`, incoming requests are queued and processed after the run. - During `running`, incoming requests are queued and processed after the run.
@@ -171,7 +171,8 @@ Admin controls should toggle `paused`, trigger manual run, and request catch-up.
### Daemon Control Contract (Draft) ### Daemon Control Contract (Draft)
API server to daemon control messages (Redis Stream or in-process channel): API server to daemon control payloads (PostgreSQL inbox or in-process test
channel):
```ts ```ts
export type RunReason = 'schedule' | 'manual' | 'poke'; export type RunReason = 'schedule' | 'manual' | 'poke';
@@ -192,9 +193,33 @@ export type DaemonEvent =
## Recovery and Shutdown ## Recovery and Shutdown
- On startup, load `game_env.turntime` and catch up to `now`. - On startup, load `game_env.turntime` and catch up to `now`.
- Claim pending input events with `FOR UPDATE SKIP LOCKED`. A worker ID and
expiry timestamp prevent another live consumer from claiming the same event;
only expired processing leases return to `PENDING`.
- A handler exception or transaction failure pauses the daemon without
acknowledging the event. Restart/recovery reloads world state before retry.
- On shutdown, stop accepting new triggers, finish the current run, flush, then - On shutdown, stop accepting new triggers, finish the current run, flush, then
exit cleanly. exit cleanly.
## Input Event Commit Boundary
For engine-owned mutations the lifecycle opens one database-owned unit of work:
```text
claim input_event
-> execute command against in-memory world
-> specialized domain DB writes (auction/tournament)
-> persist world/reserved turns/logs
-> store command result and mark input_event SUCCEEDED
-> commit
-> acknowledge in-memory dirty sets
-> respond
```
If any step before commit fails, PostgreSQL rolls back and dirty sets are not
cleared. Realtime publication happens after commit and cannot roll gameplay
state back.
## Testing with a Controlled Clock ## Testing with a Controlled Clock
- Turn daemon tests should use a controllable `Clock` implementation (예: `ManualClock`) to - Turn daemon tests should use a controllable `Clock` implementation (예: `ManualClock`) to
+10 -3
View File
@@ -43,13 +43,14 @@ export interface TurnDaemonStatus {
export type TurnDaemonCommand = export type TurnDaemonCommand =
| { | {
type: 'run'; type: 'run';
requestId?: string;
reason: RunReason; reason: RunReason;
targetTime?: string; targetTime?: string;
budget?: TurnRunBudget; budget?: TurnRunBudget;
} }
| { type: 'pause'; reason?: string } | { type: 'pause'; requestId?: string; reason?: string }
| { type: 'resume'; reason?: string } | { type: 'resume'; requestId?: string; reason?: string }
| { type: 'shutdown'; reason?: string } | { type: 'shutdown'; requestId?: string; reason?: string }
| { type: 'getStatus'; requestId?: string } | { type: 'getStatus'; requestId?: string }
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number } | { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
| { type: 'troopExit'; requestId?: string; generalId: number } | { type: 'troopExit'; requestId?: string; generalId: number }
@@ -186,6 +187,12 @@ export type TurnDaemonCommand =
}; };
export type TurnDaemonCommandResult = export type TurnDaemonCommandResult =
| {
type: 'commandRejected';
ok: false;
commandType: TurnDaemonCommand['type'];
reason: string;
}
| { | {
type: 'auctionFinalize'; type: 'auctionFinalize';
ok: true; ok: true;
+33
View File
@@ -44,6 +44,39 @@ enum DiplomacyLetterState {
REPLACED REPLACED
} }
enum InputEventStatus {
PENDING
PROCESSING
SUCCEEDED
FAILED
}
enum InputEventTarget {
API
ENGINE
}
model InputEvent {
sequence BigInt @id @default(autoincrement())
requestId String @unique @map("request_id")
target InputEventTarget
eventType String @map("event_type")
payload Json @default(dbgenerated("'{}'::jsonb"))
actorUserId String? @map("actor_user_id")
status InputEventStatus @default(PENDING)
result Json?
error String?
attempts Int @default(0)
lockedBy String? @map("locked_by")
leaseUntil DateTime? @map("lease_until")
createdAt DateTime @default(now()) @map("created_at")
processingAt DateTime? @map("processing_at")
completedAt DateTime? @map("completed_at")
@@index([target, status, sequence])
@@map("input_event")
}
model WorldState { model WorldState {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code") scenarioCode String @map("scenario_code")
@@ -0,0 +1,25 @@
CREATE TYPE "InputEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED');
CREATE TYPE "InputEventTarget" AS ENUM ('API', 'ENGINE');
CREATE TABLE "input_event" (
"sequence" BIGSERIAL NOT NULL,
"request_id" TEXT NOT NULL,
"target" "InputEventTarget" NOT NULL,
"event_type" TEXT NOT NULL,
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
"actor_user_id" TEXT,
"status" "InputEventStatus" NOT NULL DEFAULT 'PENDING',
"result" JSONB,
"error" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"locked_by" TEXT,
"lease_until" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processing_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
CONSTRAINT "input_event_pkey" PRIMARY KEY ("sequence")
);
CREATE UNIQUE INDEX "input_event_request_id_key" ON "input_event"("request_id");
CREATE INDEX "input_event_target_status_sequence_idx" ON "input_event"("target", "status", "sequence");
+1
View File
@@ -25,4 +25,5 @@ export interface DatabaseClient {
inheritanceLog: GamePrisma.InheritanceLogDelegate; inheritanceLog: GamePrisma.InheritanceLogDelegate;
inheritanceResult: GamePrisma.InheritanceResultDelegate; inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate; inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
inputEvent: GamePrisma.InputEventDelegate;
} }