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:
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user