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:
@@ -2,11 +2,14 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
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';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -37,25 +40,6 @@ interface AuctionDetail {
|
||||
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 => {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return {};
|
||||
@@ -94,7 +78,9 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
|
||||
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[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
@@ -110,7 +96,7 @@ const loadAuction = async (prisma: GamePrismaClient, auctionId: number): Promise
|
||||
};
|
||||
|
||||
const loadHighestBid = async (
|
||||
prisma: GamePrismaClient,
|
||||
prisma: QueryClient,
|
||||
auctionId: number,
|
||||
isReverse: boolean
|
||||
): Promise<AuctionBidRow | null> => {
|
||||
@@ -135,7 +121,7 @@ const loadHighestBid = async (
|
||||
};
|
||||
|
||||
const loadMyPrevBid = async (
|
||||
prisma: GamePrismaClient,
|
||||
prisma: QueryClient,
|
||||
auctionId: number,
|
||||
generalId: number,
|
||||
isReverse: boolean
|
||||
@@ -160,8 +146,6 @@ const loadMyPrevBid = async (
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
type QueryClient = Pick<GamePrismaClient, '$queryRaw'>;
|
||||
|
||||
const resolveUserId = async (prisma: QueryClient, generalId: number): Promise<string | null> => {
|
||||
const rows = await prisma.$queryRaw<{ userId: string | null }[]>(
|
||||
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: {
|
||||
databaseUrl: string;
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
}): Promise<AuctionBidder> => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
const world = options.world;
|
||||
const hooks = options.hooks;
|
||||
|
||||
return {
|
||||
bid: async (command): Promise<TurnDaemonCommandResult> => {
|
||||
const auction = await loadAuction(prisma, command.auctionId);
|
||||
bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const db = commandDb ?? prisma;
|
||||
const auction = await loadAuction(db, command.auctionId);
|
||||
if (!auction) {
|
||||
return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 없습니다.' };
|
||||
}
|
||||
@@ -206,8 +189,8 @@ export const createAuctionBidder = async (options: {
|
||||
|
||||
const detail = parseDetail(auction.detail);
|
||||
const isReverse = detail.isReverse === true;
|
||||
const highestBid = await loadHighestBid(prisma, command.auctionId, isReverse);
|
||||
const myPrevBidRaw = await loadMyPrevBid(prisma, command.auctionId, command.generalId, isReverse);
|
||||
const highestBid = await loadHighestBid(db, command.auctionId, isReverse);
|
||||
const myPrevBidRaw = await loadMyPrevBid(db, command.auctionId, command.generalId, isReverse);
|
||||
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||
|
||||
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);
|
||||
if (!prev) {
|
||||
return {
|
||||
@@ -319,7 +307,7 @@ export const createAuctionBidder = async (options: {
|
||||
const eventAt = now;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
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) {
|
||||
const reason = error instanceof Error ? error.message : 'CONFLICT';
|
||||
if (reason === 'INSUFFICIENT_POINT') {
|
||||
@@ -445,15 +446,11 @@ export const createAuctionBidder = async (options: {
|
||||
const prev = world.getGeneralById(highestBid.generalId);
|
||||
if (prev) {
|
||||
world.updateGeneral(highestBid.generalId, {
|
||||
gold:
|
||||
resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold,
|
||||
rice:
|
||||
resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice,
|
||||
gold: resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold,
|
||||
rice: resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
}
|
||||
|
||||
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 { 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 { LogEntryDraft } from '@sammo-ts/logic';
|
||||
|
||||
export interface AuctionFinalizer {
|
||||
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
|
||||
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,25 +44,6 @@ interface AuctionDetailResource extends AuctionDetailBase {
|
||||
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 => {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return {};
|
||||
@@ -72,7 +53,9 @@ const parseDetail = (detail: unknown): AuctionDetailResource => {
|
||||
|
||||
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(
|
||||
GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1`
|
||||
)) as Array<{ tickSeconds: number }>;
|
||||
@@ -104,7 +87,7 @@ const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
|
||||
};
|
||||
|
||||
const refundInheritancePoint = async (options: {
|
||||
prisma: GamePrismaClient;
|
||||
prisma: AuctionDb;
|
||||
userId: string;
|
||||
amount: number;
|
||||
}): Promise<void> => {
|
||||
@@ -142,25 +125,24 @@ const refundInheritancePoint = async (options: {
|
||||
export const createAuctionFinalizer = async (options: {
|
||||
databaseUrl: string;
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
}): Promise<AuctionFinalizer> => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
const world = options.world;
|
||||
const hooks = options.hooks;
|
||||
const itemLoader = new ItemLoader();
|
||||
|
||||
const getGeneralUserId = async (generalId: number): Promise<string | null> => {
|
||||
const rows = await prisma.$queryRaw<{ userId: string | null }[]>(
|
||||
const getGeneralUserId = async (db: AuctionDb, generalId: number): Promise<string | null> => {
|
||||
const rows = await db.$queryRaw<{ userId: string | null }[]>(
|
||||
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}`
|
||||
);
|
||||
return rows[0]?.userId ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
finalize: async (auctionId: number): Promise<TurnDaemonCommandResult> => {
|
||||
const rows = await prisma.$queryRaw<AuctionRow[]>(
|
||||
finalize: async (auctionId: number, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const db = commandDb ?? prisma;
|
||||
const rows = await db.$queryRaw<AuctionRow[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
type,
|
||||
@@ -200,7 +182,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
const detail = parseDetail(auction.detail);
|
||||
const isReverse = detail.isReverse === true;
|
||||
|
||||
const bidRows = await prisma.$queryRaw<AuctionBidRow[]>(
|
||||
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
|
||||
isReverse
|
||||
? GamePrisma.sql`
|
||||
SELECT id, general_id as "generalId", amount
|
||||
@@ -224,7 +206,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
const globalLogger = new ActionLogger();
|
||||
|
||||
const finalizeStatus = async (status: AuctionStatus) => {
|
||||
await prisma.$executeRaw(
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = ${status},
|
||||
@@ -263,7 +245,10 @@ export const createAuctionFinalizer = async (options: {
|
||||
[resourceKey]: host[resourceKey] + amount,
|
||||
});
|
||||
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());
|
||||
globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN);
|
||||
}
|
||||
@@ -271,7 +256,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
await flushWorld(world, hooks);
|
||||
await finalizeStatus('FINISHED');
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
}
|
||||
@@ -358,8 +342,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (!itemModule) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await refundInheritancePoint({
|
||||
prisma,
|
||||
userId: (await getGeneralUserId(bidder.id)) ?? '',
|
||||
prisma: db,
|
||||
userId: (await getGeneralUserId(db, bidder.id)) ?? '',
|
||||
amount: highestBid.amount,
|
||||
});
|
||||
return {
|
||||
@@ -375,7 +359,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
|
||||
const currentModule = await itemLoader.load(currentItem).catch(() => null);
|
||||
if (currentModule && !currentModule.buyable) {
|
||||
const turnMinutes = await resolveTurnMinutes(prisma);
|
||||
const turnMinutes = await resolveTurnMinutes(db);
|
||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
||||
? new Date(detail.availableLatestBidCloseDate)
|
||||
: null;
|
||||
@@ -385,7 +369,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
turnMinutes,
|
||||
availableLatestBidCloseDate,
|
||||
});
|
||||
await prisma.$executeRaw(
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
@@ -400,7 +384,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -427,12 +410,15 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...bidderLogger.flush());
|
||||
|
||||
const bidderUserId = await getGeneralUserId(bidder.id);
|
||||
const bidderUserId = await getGeneralUserId(db, bidder.id);
|
||||
if (bidderUserId) {
|
||||
const userIdNum = Number(bidderUserId);
|
||||
if (Number.isFinite(userIdNum)) {
|
||||
const userLogger = new UserLogger(userIdNum);
|
||||
userLogger.push(`유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`, 'inheritPoint');
|
||||
userLogger.push(
|
||||
`유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`,
|
||||
'inheritPoint'
|
||||
);
|
||||
logs.push(...userLogger.flush());
|
||||
}
|
||||
}
|
||||
@@ -442,7 +428,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
await flushWorld(world, hooks);
|
||||
await finalizeStatus('FINISHED');
|
||||
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runTurnDaemonCli } from './turn/cli.js';
|
||||
|
||||
export * from './lifecycle/types.js';
|
||||
export * from './lifecycle/clock.js';
|
||||
export * from './lifecycle/databaseCommandQueue.js';
|
||||
export * from './lifecycle/inMemoryControlQueue.js';
|
||||
export * from './lifecycle/turnDaemonLifecycle.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,
|
||||
TurnDaemonCommandResponder,
|
||||
TurnDaemonCommandResult,
|
||||
TurnDaemonCommandExecutionContext,
|
||||
} from './types.js';
|
||||
|
||||
type PendingRun = {
|
||||
@@ -21,6 +22,12 @@ type PendingRun = {
|
||||
budget?: TurnRunBudget;
|
||||
};
|
||||
|
||||
type TurnDaemonControlCommand = Extract<
|
||||
TurnDaemonCommand,
|
||||
{ type: 'pause' | 'resume' | 'shutdown' | 'getStatus' | 'run' }
|
||||
>;
|
||||
type TurnDaemonMutationCommand = Exclude<TurnDaemonCommand, TurnDaemonControlCommand>;
|
||||
|
||||
export interface TurnDaemonLifecycleOptions {
|
||||
profile: string;
|
||||
defaultBudget: TurnRunBudget;
|
||||
@@ -217,15 +224,24 @@ export class TurnDaemonLifecycle {
|
||||
this.manualPaused = true;
|
||||
this.status.paused = true;
|
||||
this.status.state = 'paused';
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'resume':
|
||||
this.manualPaused = false;
|
||||
this.status.paused = this.errorPaused;
|
||||
this.status.state = 'idle';
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'shutdown':
|
||||
this.status.state = 'stopping';
|
||||
this.stopping = true;
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'getStatus': {
|
||||
if (command.requestId) {
|
||||
@@ -240,79 +256,61 @@ export class TurnDaemonLifecycle {
|
||||
budget: command.budget,
|
||||
};
|
||||
this.status.pendingReason = command.reason;
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'troopJoin':
|
||||
case 'troopExit':
|
||||
case 'dieOnPrestart':
|
||||
case 'buildNationCandidate':
|
||||
case 'instantRetreat':
|
||||
case 'vacation':
|
||||
case 'setMySetting':
|
||||
case 'dropItem':
|
||||
case 'auctionFinalize':
|
||||
case 'changePermission':
|
||||
case 'kick':
|
||||
case 'appoint':
|
||||
default:
|
||||
await this.handleMutationCommand(command);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMutationCommand(
|
||||
command: Extract<
|
||||
TurnDaemonCommand,
|
||||
| { type: 'troopJoin' }
|
||||
| { type: 'troopExit' }
|
||||
| { type: 'dieOnPrestart' }
|
||||
| { type: 'buildNationCandidate' }
|
||||
| { type: 'instantRetreat' }
|
||||
| { type: 'vacation' }
|
||||
| { type: 'setMySetting' }
|
||||
| { type: 'dropItem' }
|
||||
| { type: 'auctionFinalize' }
|
||||
| { type: 'changePermission' }
|
||||
| { 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;
|
||||
private async handleMutationCommand(command: TurnDaemonMutationCommand): Promise<void> {
|
||||
let result: TurnDaemonCommandResult;
|
||||
let committedByExecutionBoundary = false;
|
||||
const executeHandler = async (
|
||||
context?: TurnDaemonCommandExecutionContext
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
|
||||
return (
|
||||
handled ?? {
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
}
|
||||
);
|
||||
};
|
||||
try {
|
||||
if (command.requestId && this.hooks?.executeCommand) {
|
||||
result = await this.hooks.executeCommand(command.requestId, executeHandler);
|
||||
committedByExecutionBoundary = true;
|
||||
} else {
|
||||
result = await executeHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : 'Unknown command error.';
|
||||
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;
|
||||
// A handler may already have changed the in-memory world. Do not commit
|
||||
// either those changes or the inbox completion marker after an exception.
|
||||
// Pausing forces a reload/retry instead of acknowledging a partial event.
|
||||
this.status.state = 'paused';
|
||||
this.status.paused = true;
|
||||
this.errorPaused = true;
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
|
||||
await this.hooks?.onRunError?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!committedByExecutionBoundary && command.requestId && this.hooks?.commitCommand) {
|
||||
try {
|
||||
await this.hooks.commitCommand(command.requestId, result);
|
||||
} catch (error) {
|
||||
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';
|
||||
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
|
||||
await this.stateStore.saveCheckpoint(result.checkpoint);
|
||||
await this.hooks?.flushChanges?.(result);
|
||||
await this.hooks?.publishEvents?.(result);
|
||||
try {
|
||||
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
|
||||
await this.stateStore.saveCheckpoint(result.checkpoint);
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TurnRunBudget,
|
||||
TurnRunResult,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
export type {
|
||||
RunReason,
|
||||
@@ -19,7 +20,14 @@ export type {
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
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 {
|
||||
@@ -53,6 +61,11 @@ export interface TurnDaemonControlQueue {
|
||||
|
||||
export interface TurnDaemonHooks {
|
||||
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>;
|
||||
onRunError?(error: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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 type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
databaseUrl: string;
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
}): Promise<TournamentRewardFinalizer> => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
|
||||
const finalize = async (
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
|
||||
commandDb?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
const { world, hooks } = options;
|
||||
const { world } = options;
|
||||
const db = commandDb ?? prisma;
|
||||
const { winnerId, runnerUpId } = command;
|
||||
const rewardMap = new Map<
|
||||
number,
|
||||
{ gold: number; exp: number; label: string; inheritPoint: number }
|
||||
>();
|
||||
const rewardMap = new Map<number, { gold: number; exp: number; label: string; inheritPoint: number }>();
|
||||
|
||||
const applyTier = (
|
||||
ids: number[],
|
||||
@@ -126,7 +112,7 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
}
|
||||
|
||||
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()) } },
|
||||
select: { id: true, userId: true, name: true },
|
||||
});
|
||||
@@ -240,7 +226,7 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
.filter((entry) => !!entry.userId);
|
||||
|
||||
for (const entry of pointUpdates) {
|
||||
await prisma.inheritancePoint.upsert({
|
||||
await db.inheritancePoint.upsert({
|
||||
where: {
|
||||
userId_key: { userId: entry.userId!, key: 'tournament' },
|
||||
},
|
||||
@@ -249,8 +235,6 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
});
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
|
||||
return {
|
||||
type: 'tournamentReward',
|
||||
ok: true,
|
||||
@@ -269,4 +253,4 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
await connector.disconnect();
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
TurnDaemonCommand,
|
||||
TurnDaemonCommandType,
|
||||
TurnDaemonCommandByType,
|
||||
} from '@sammo-ts/common';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common';
|
||||
|
||||
export type TurnDaemonCommandEnvelope = {
|
||||
requestId: string;
|
||||
@@ -161,22 +157,21 @@ const zSetNationMeta = z.object({
|
||||
expectedUpdatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const zAdjustGeneralResources = z
|
||||
.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
adjustments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
generalId: zFiniteNumber,
|
||||
goldDelta: zFiniteNumber.optional(),
|
||||
riceDelta: zFiniteNumber.optional(),
|
||||
})
|
||||
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
const zAdjustGeneralResources = z.object({
|
||||
type: z.literal('adjustGeneralResources'),
|
||||
reason: z.string().optional(),
|
||||
adjustments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
generalId: zFiniteNumber,
|
||||
goldDelta: zFiniteNumber.optional(),
|
||||
riceDelta: zFiniteNumber.optional(),
|
||||
})
|
||||
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
const zAdjustGeneralMeta = z.object({
|
||||
type: z.literal('adjustGeneralMeta'),
|
||||
@@ -430,10 +425,22 @@ const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRun: CommandNormalizer<'run'> = (envelope) => parseWith(zRun, envelope.command);
|
||||
const normalizePause: CommandNormalizer<'pause'> = (envelope) => parseWith(zPause, envelope.command);
|
||||
const normalizeResume: CommandNormalizer<'resume'> = (envelope) => parseWith(zResume, envelope.command);
|
||||
const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => parseWith(zShutdown, envelope.command);
|
||||
const normalizeRun: CommandNormalizer<'run'> = (envelope) => {
|
||||
const command = parseWith(zRun, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
const normalizePause: CommandNormalizer<'pause'> = (envelope) => {
|
||||
const command = parseWith(zPause, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
const normalizeResume: CommandNormalizer<'resume'> = (envelope) => {
|
||||
const command = parseWith(zResume, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
|
||||
const command = parseWith(zShutdown, envelope.command);
|
||||
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||
};
|
||||
|
||||
const normalizers: CommandNormalizerMap = {
|
||||
auctionFinalize: normalizeAuctionFinalize,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
type InputJsonValue,
|
||||
type TurnEngineCityUpdateInput,
|
||||
type TurnEngineDiplomacyCreateManyInput,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
||||
@@ -305,32 +306,37 @@ export const createDatabaseTurnHooks = async (
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
|
||||
const hooks: TurnDaemonHooks = {
|
||||
flushChanges: async () => {
|
||||
const state = world.getState();
|
||||
const {
|
||||
generals,
|
||||
cities,
|
||||
nations,
|
||||
troops,
|
||||
deletedTroops,
|
||||
deletedGenerals,
|
||||
deletedNations,
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
createdDiplomacy,
|
||||
} = world.consumeDirtyState();
|
||||
const persistChanges = async (
|
||||
transaction?: GamePrisma.TransactionClient,
|
||||
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }
|
||||
): Promise<() => void> => {
|
||||
const state = world.getState();
|
||||
const changes = world.peekDirtyState();
|
||||
const {
|
||||
generals,
|
||||
cities,
|
||||
nations,
|
||||
troops,
|
||||
deletedTroops,
|
||||
deletedGenerals,
|
||||
deletedNations,
|
||||
deletedNationSnapshots,
|
||||
diplomacy,
|
||||
logs,
|
||||
createdGenerals,
|
||||
createdNations,
|
||||
createdTroops,
|
||||
createdDiplomacy,
|
||||
} = changes;
|
||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||
|
||||
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
meta: asJson(state.meta),
|
||||
};
|
||||
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
meta: asJson(state.meta),
|
||||
};
|
||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
||||
await prisma.worldState.update({
|
||||
where: { id: state.id },
|
||||
data: worldStateUpdate,
|
||||
@@ -462,10 +468,7 @@ export const createDatabaseTurnHooks = async (
|
||||
if (deletedNations.length > 0) {
|
||||
await prisma.diplomacy.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ srcNationId: { in: deletedNations } },
|
||||
{ destNationId: { in: deletedNations } },
|
||||
],
|
||||
OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }],
|
||||
},
|
||||
});
|
||||
await prisma.nationTurn.deleteMany({
|
||||
@@ -563,9 +566,52 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options?.reservedTurns) {
|
||||
await options.reservedTurns.flushChanges();
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, reservedTurnChanges);
|
||||
}
|
||||
if (commandCompletion) {
|
||||
await prisma.inputEvent.update({
|
||||
where: { requestId: commandCompletion.requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(commandCompletion.result),
|
||||
completedAt: new Date(),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
if (transaction) {
|
||||
await persist(transaction);
|
||||
} else {
|
||||
await prisma.$transaction(persist);
|
||||
}
|
||||
|
||||
return () => {
|
||||
world.acknowledgeDirtyState(changes);
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const hooks: TurnDaemonHooks = {
|
||||
flushChanges: async () => {
|
||||
const acknowledge = await persistChanges();
|
||||
acknowledge();
|
||||
},
|
||||
commitCommand: async (requestId, result) => {
|
||||
const acknowledge = await persistChanges(undefined, { requestId, result });
|
||||
acknowledge();
|
||||
},
|
||||
executeCommand: async (requestId, execute) => {
|
||||
const committed = await prisma.$transaction(async (transaction) => {
|
||||
const result = await execute({ db: transaction });
|
||||
const acknowledge = await persistChanges(transaction, { requestId, result });
|
||||
return { result, acknowledge };
|
||||
});
|
||||
committed.acknowledge();
|
||||
return committed.result;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -68,6 +68,23 @@ export interface InMemoryTurnWorldOptions {
|
||||
calendarHandler?: TurnCalendarHandler;
|
||||
}
|
||||
|
||||
export interface TurnWorldChanges {
|
||||
generals: TurnGeneral[];
|
||||
cities: City[];
|
||||
nations: Nation[];
|
||||
troops: Troop[];
|
||||
deletedTroops: number[];
|
||||
deletedGenerals: number[];
|
||||
deletedNations: number[];
|
||||
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
|
||||
diplomacy: TurnDiplomacy[];
|
||||
logs: LogEntryDraft[];
|
||||
createdGenerals: TurnGeneral[];
|
||||
createdNations: Nation[];
|
||||
createdTroops: Troop[];
|
||||
createdDiplomacy: TurnDiplomacy[];
|
||||
}
|
||||
|
||||
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
|
||||
if (timeDiff !== 0) {
|
||||
@@ -694,22 +711,7 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
}
|
||||
|
||||
consumeDirtyState(): {
|
||||
generals: TurnGeneral[];
|
||||
cities: City[];
|
||||
nations: Nation[];
|
||||
troops: Troop[];
|
||||
deletedTroops: number[];
|
||||
deletedGenerals: number[];
|
||||
deletedNations: number[];
|
||||
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
|
||||
diplomacy: TurnDiplomacy[];
|
||||
logs: LogEntryDraft[];
|
||||
createdGenerals: TurnGeneral[];
|
||||
createdNations: Nation[];
|
||||
createdTroops: Troop[];
|
||||
createdDiplomacy: TurnDiplomacy[];
|
||||
} {
|
||||
peekDirtyState(): TurnWorldChanges {
|
||||
const generals = Array.from(this.dirtyGeneralIds)
|
||||
.map((id) => this.generals.get(id))
|
||||
.filter((general): general is TurnGeneral => Boolean(general));
|
||||
@@ -740,21 +742,8 @@ export class InMemoryTurnWorld {
|
||||
const deletedTroops = Array.from(this.deletedTroopIds);
|
||||
const deletedGenerals = Array.from(this.deletedGeneralIds);
|
||||
const deletedNations = Array.from(this.deletedNationIds);
|
||||
const deletedNationSnapshots = this.deletedNationSnapshots.splice(0, this.deletedNationSnapshots.length);
|
||||
const logs = this.logs.splice(0, this.logs.length);
|
||||
|
||||
this.dirtyGeneralIds.clear();
|
||||
this.dirtyCityIds.clear();
|
||||
this.dirtyNationIds.clear();
|
||||
this.dirtyTroopIds.clear();
|
||||
this.dirtyDiplomacyKeys.clear();
|
||||
this.createdGeneralIds.clear();
|
||||
this.createdNationIds.clear();
|
||||
this.createdTroopIds.clear();
|
||||
this.createdDiplomacyKeys.clear();
|
||||
this.deletedTroopIds.clear();
|
||||
this.deletedGeneralIds.clear();
|
||||
this.deletedNationIds.clear();
|
||||
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
|
||||
const logs = this.logs.slice();
|
||||
|
||||
return {
|
||||
generals,
|
||||
@@ -774,6 +763,33 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
}
|
||||
|
||||
acknowledgeDirtyState(changes: TurnWorldChanges): void {
|
||||
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
|
||||
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
|
||||
for (const nation of changes.nations) this.dirtyNationIds.delete(nation.id);
|
||||
for (const troop of changes.troops) this.dirtyTroopIds.delete(troop.id);
|
||||
for (const entry of changes.diplomacy) {
|
||||
this.dirtyDiplomacyKeys.delete(buildDiplomacyKey(entry.fromNationId, entry.toNationId));
|
||||
}
|
||||
for (const general of changes.createdGenerals) this.createdGeneralIds.delete(general.id);
|
||||
for (const nation of changes.createdNations) this.createdNationIds.delete(nation.id);
|
||||
for (const troop of changes.createdTroops) this.createdTroopIds.delete(troop.id);
|
||||
for (const entry of changes.createdDiplomacy) {
|
||||
this.createdDiplomacyKeys.delete(buildDiplomacyKey(entry.fromNationId, entry.toNationId));
|
||||
}
|
||||
for (const id of changes.deletedTroops) this.deletedTroopIds.delete(id);
|
||||
for (const id of changes.deletedGenerals) this.deletedGeneralIds.delete(id);
|
||||
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
|
||||
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
|
||||
this.logs.splice(0, changes.logs.length);
|
||||
}
|
||||
|
||||
consumeDirtyState(): TurnWorldChanges {
|
||||
const changes = this.peekDirtyState();
|
||||
this.acknowledgeDirtyState(changes);
|
||||
return changes;
|
||||
}
|
||||
|
||||
private removeCollapsedNations(): void {
|
||||
const collapsedNationIds: number[] = [];
|
||||
for (const nation of this.nations.values()) {
|
||||
|
||||
@@ -72,6 +72,11 @@ const buildNationKey = (nationId: number, officerLevel: number): string => `${na
|
||||
|
||||
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'>;
|
||||
|
||||
export interface ReservedTurnChanges {
|
||||
generalIds: number[];
|
||||
nationKeys: string[];
|
||||
}
|
||||
|
||||
export class InMemoryReservedTurnStore {
|
||||
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
|
||||
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
|
||||
@@ -213,12 +218,27 @@ export class InMemoryReservedTurnStore {
|
||||
this.dirtyNationKeys.add(key);
|
||||
}
|
||||
|
||||
async flushChanges(): Promise<void> {
|
||||
const generalIds = Array.from(this.dirtyGeneralIds);
|
||||
for (const generalId of generalIds) {
|
||||
peekDirtyState(): ReservedTurnChanges {
|
||||
return {
|
||||
generalIds: Array.from(this.dirtyGeneralIds),
|
||||
nationKeys: Array.from(this.dirtyNationKeys),
|
||||
};
|
||||
}
|
||||
|
||||
acknowledgeDirtyState(changes: ReservedTurnChanges): void {
|
||||
for (const generalId of changes.generalIds) {
|
||||
this.dirtyGeneralIds.delete(generalId);
|
||||
}
|
||||
for (const key of changes.nationKeys) {
|
||||
this.dirtyNationKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise<void> {
|
||||
for (const generalId of changes.generalIds) {
|
||||
const turns = this.getGeneralTurns(generalId);
|
||||
await this.prisma.generalTurn.deleteMany({ where: { generalId } });
|
||||
await this.prisma.generalTurn.createMany({
|
||||
await prisma.generalTurn.deleteMany({ where: { generalId } });
|
||||
await prisma.generalTurn.createMany({
|
||||
data: turns.map((entry, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
@@ -228,16 +248,15 @@ export class InMemoryReservedTurnStore {
|
||||
});
|
||||
}
|
||||
|
||||
const nationKeys = Array.from(this.dirtyNationKeys);
|
||||
for (const key of nationKeys) {
|
||||
for (const key of changes.nationKeys) {
|
||||
const [nationIdRaw, officerLevelRaw] = key.split(':');
|
||||
const nationId = Number(nationIdRaw);
|
||||
const officerLevel = Number(officerLevelRaw);
|
||||
const turns = this.getNationTurns(nationId, officerLevel);
|
||||
await this.prisma.nationTurn.deleteMany({
|
||||
await prisma.nationTurn.deleteMany({
|
||||
where: { nationId, officerLevel },
|
||||
});
|
||||
await this.prisma.nationTurn.createMany({
|
||||
await prisma.nationTurn.createMany({
|
||||
data: turns.map((entry, turnIdx) => ({
|
||||
nationId,
|
||||
officerLevel,
|
||||
@@ -247,9 +266,12 @@ export class InMemoryReservedTurnStore {
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.dirtyGeneralIds.clear();
|
||||
this.dirtyNationKeys.clear();
|
||||
async flushChanges(): Promise<void> {
|
||||
const changes = this.peekDirtyState();
|
||||
await this.persistChanges(this.prisma, changes);
|
||||
this.acknowledgeDirtyState(changes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
||||
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
||||
|
||||
import { SystemClock } from '../lifecycle/clock.js';
|
||||
@@ -8,7 +8,7 @@ import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
|
||||
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
|
||||
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
|
||||
import { buildTurnDaemonStreamKeys, RedisTurnDaemonCommandStream } from '../lifecycle/redisCommandStream.js';
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js';
|
||||
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||
import { createDatabaseTurnHooks } from './databaseHooks.js';
|
||||
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
@@ -201,7 +201,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||
let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
|
||||
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
||||
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||
let pauseGate: (() => Promise<boolean>) | undefined;
|
||||
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
|
||||
const gatewayGate = options.profileName
|
||||
@@ -222,17 +221,14 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
auctionBidder = await createAuctionBidder({
|
||||
databaseUrl: options.databaseUrl,
|
||||
world,
|
||||
hooks: dbHooks.hooks,
|
||||
});
|
||||
auctionFinalizer = await createAuctionFinalizer({
|
||||
databaseUrl: options.databaseUrl,
|
||||
world,
|
||||
hooks: dbHooks.hooks,
|
||||
});
|
||||
tournamentRewardFinalizer = await createTournamentRewardFinalizer({
|
||||
databaseUrl: options.databaseUrl,
|
||||
world,
|
||||
hooks: dbHooks.hooks,
|
||||
});
|
||||
hooks = {
|
||||
...dbHooks.hooks,
|
||||
@@ -290,10 +286,6 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
redisConnector = createRedisConnector(redisConfig);
|
||||
await redisConnector.connect();
|
||||
const redisClient = redisConnector.client;
|
||||
redisCommandStream = new RedisTurnDaemonCommandStream(redisClient, {
|
||||
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
|
||||
startId: options.commandStreamStartId,
|
||||
});
|
||||
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
|
||||
publishRealtimeEvent = async (event: RealtimeEvent) => {
|
||||
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
||||
@@ -320,6 +312,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
};
|
||||
}
|
||||
|
||||
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
||||
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
||||
if (commandConnector && databaseCommandQueue) {
|
||||
await commandConnector.connect();
|
||||
await databaseCommandQueue.initialize();
|
||||
}
|
||||
|
||||
const baseClose = close;
|
||||
close = async () => {
|
||||
await baseClose();
|
||||
@@ -330,12 +329,12 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
if (redisConnector) {
|
||||
await redisConnector.disconnect();
|
||||
}
|
||||
await commandConnector?.disconnect();
|
||||
};
|
||||
|
||||
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue;
|
||||
const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
|
||||
const commandHandler = createTurnDaemonCommandHandler({
|
||||
world,
|
||||
hooks,
|
||||
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||
auctionBidder: auctionBidder ?? undefined,
|
||||
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
|
||||
@@ -357,7 +356,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
hooks,
|
||||
pauseGate,
|
||||
commandHandler,
|
||||
commandResponder: redisCommandStream ?? undefined,
|
||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||
},
|
||||
{ profile: options.profile, defaultBudget }
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
TurnDaemonHooks,
|
||||
TurnDaemonCommandHandler,
|
||||
TurnDaemonCommand,
|
||||
TurnDaemonCommandExecutionContext,
|
||||
TurnDaemonCommandResult,
|
||||
TurnRunResult,
|
||||
} from '../lifecycle/types.js';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
LogCategory,
|
||||
@@ -23,25 +23,6 @@ import {
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
|
||||
const state = world.getState();
|
||||
return {
|
||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
checkpoint: world.getCheckpoint(),
|
||||
};
|
||||
};
|
||||
|
||||
const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise<void> => {
|
||||
if (!hooks?.flushChanges) {
|
||||
return;
|
||||
}
|
||||
await hooks.flushChanges(buildFlushResult(world));
|
||||
};
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
|
||||
@@ -67,29 +48,35 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: nu
|
||||
|
||||
interface CommandHandlerContext {
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
commandDb?: GamePrisma.TransactionClient;
|
||||
auctionFinalizer?: AuctionFinalizer;
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
}
|
||||
|
||||
interface AuctionFinalizer {
|
||||
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
|
||||
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
|
||||
}
|
||||
|
||||
interface AuctionBidder {
|
||||
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>;
|
||||
bid(
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
}
|
||||
|
||||
interface TournamentRewardFinalizer {
|
||||
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>;
|
||||
finalize(
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
}
|
||||
|
||||
async function handleSetNationMeta(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setNationMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const nation = world.getNationById(command.nationId);
|
||||
if (!nation) {
|
||||
return {
|
||||
@@ -122,7 +109,6 @@ async function handleSetNationMeta(
|
||||
world.updateNation(command.nationId, {
|
||||
meta: nextMeta,
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'setNationMeta',
|
||||
ok: true,
|
||||
@@ -135,7 +121,7 @@ async function handleAdjustGeneralResources(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
@@ -176,8 +162,6 @@ async function handleAdjustGeneralResources(
|
||||
totalGoldDelta += goldDelta;
|
||||
totalRiceDelta += riceDelta;
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
@@ -192,7 +176,7 @@ async function handleAdjustGeneralMeta(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
if (!command.adjustments || command.adjustments.length === 0) {
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
@@ -224,8 +208,6 @@ async function handleAdjustGeneralMeta(
|
||||
world.updateGeneral(adjustment.generalId, { meta: nextMeta });
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'adjustGeneralMeta',
|
||||
ok: true,
|
||||
@@ -238,7 +220,7 @@ async function handleTournamentMatchResult(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const resolvePrefix = (type: number): string => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
@@ -342,8 +324,6 @@ async function handleTournamentMatchResult(
|
||||
nextMeta[rankKey('g')] = defenderG + defenderGDelta;
|
||||
world.updateGeneral(defender.id, { meta: nextMeta });
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'tournamentMatchResult',
|
||||
ok: true,
|
||||
@@ -358,7 +338,7 @@ async function handlePatchGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -396,7 +376,6 @@ async function handlePatchGeneral(
|
||||
}
|
||||
|
||||
world.updateGeneral(command.generalId, patch);
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -404,7 +383,7 @@ async function handleTroopJoin(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -448,7 +427,6 @@ async function handleTroopJoin(
|
||||
world.updateGeneral(command.generalId, {
|
||||
troopId: command.troopId,
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopJoin',
|
||||
ok: true,
|
||||
@@ -461,7 +439,7 @@ async function handleTroopExit(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -484,7 +462,6 @@ async function handleTroopExit(
|
||||
world.updateGeneral(command.generalId, {
|
||||
troopId: 0,
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopExit',
|
||||
ok: true,
|
||||
@@ -499,7 +476,6 @@ async function handleTroopExit(
|
||||
world.updateGeneral(member.id, { troopId: 0 });
|
||||
}
|
||||
world.removeTroop(troopId);
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'troopExit',
|
||||
ok: true,
|
||||
@@ -512,7 +488,7 @@ async function handleDieOnPrestart(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -533,7 +509,6 @@ async function handleDieOnPrestart(
|
||||
}
|
||||
|
||||
world.removeGeneral(command.generalId);
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -619,7 +594,7 @@ async function handleSetMySetting(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'setMySetting' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -635,7 +610,6 @@ async function handleSetMySetting(
|
||||
...command.settings,
|
||||
},
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -643,7 +617,7 @@ async function handleDropItem(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'dropItem' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
@@ -664,7 +638,6 @@ async function handleDropItem(
|
||||
items,
|
||||
},
|
||||
});
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'dropItem', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -680,7 +653,7 @@ async function handleAuctionFinalize(
|
||||
reason: '경매 확정기가 준비되지 않았습니다.',
|
||||
};
|
||||
}
|
||||
return ctx.auctionFinalizer.finalize(command.auctionId);
|
||||
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
|
||||
}
|
||||
|
||||
async function handleAuctionBid(
|
||||
@@ -695,14 +668,14 @@ async function handleAuctionBid(
|
||||
reason: '경매 입찰기가 준비되지 않았습니다.',
|
||||
};
|
||||
}
|
||||
return ctx.auctionBidder.bid(command);
|
||||
return ctx.auctionBidder.bid(command, ctx.commandDb);
|
||||
}
|
||||
|
||||
async function handleChangePermission(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -728,8 +701,6 @@ async function handleChangePermission(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'changePermission', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -737,7 +708,7 @@ async function handleKick(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'kick' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
@@ -761,8 +732,6 @@ async function handleKick(
|
||||
nationId: 0,
|
||||
officerLevel: 0,
|
||||
});
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'kick', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -770,7 +739,7 @@ async function handleAppoint(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'appoint' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
|
||||
@@ -825,8 +794,6 @@ async function handleAppoint(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return { type: 'appoint', ok: true, generalId: command.generalId };
|
||||
}
|
||||
|
||||
@@ -834,7 +801,7 @@ async function handleTournamentRefund(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
if (!command.refunds || command.refunds.length === 0) {
|
||||
return {
|
||||
type: 'tournamentRefund',
|
||||
@@ -866,8 +833,6 @@ async function handleTournamentRefund(
|
||||
processed += 1;
|
||||
totalRefund += refund.amount;
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'tournamentRefund',
|
||||
ok: true,
|
||||
@@ -882,7 +847,7 @@ async function handleTournamentBettingPayout(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
if (!command.payouts || command.payouts.length === 0) {
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
@@ -935,8 +900,6 @@ async function handleTournamentBettingPayout(
|
||||
nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold;
|
||||
world.updateGeneral(generalId, { meta: nextMeta });
|
||||
}
|
||||
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
ok: true,
|
||||
@@ -960,7 +923,7 @@ async function handleTournamentReward(
|
||||
reason: '보상 처리기가 준비되지 않았습니다.',
|
||||
};
|
||||
}
|
||||
return ctx.tournamentRewardFinalizer.finalize(command);
|
||||
return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb);
|
||||
}
|
||||
|
||||
// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다.
|
||||
@@ -968,7 +931,7 @@ async function handleVoteReward(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const { world, hooks } = ctx;
|
||||
const { world } = ctx;
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return {
|
||||
@@ -1153,7 +1116,6 @@ async function handleVoteReward(
|
||||
}
|
||||
|
||||
world.updateGeneral(command.generalId, patch);
|
||||
await flushWorld(world, hooks);
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: true,
|
||||
@@ -1166,54 +1128,81 @@ async function handleVoteReward(
|
||||
|
||||
export const createTurnDaemonCommandHandler = (options: {
|
||||
world: InMemoryTurnWorld;
|
||||
hooks?: TurnDaemonHooks;
|
||||
auctionFinalizer?: AuctionFinalizer;
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
}): TurnDaemonCommandHandler => {
|
||||
const ctx = {
|
||||
const ctx: CommandHandlerContext = {
|
||||
world: options.world,
|
||||
hooks: options.hooks,
|
||||
auctionFinalizer: options.auctionFinalizer,
|
||||
auctionBidder: options.auctionBidder,
|
||||
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
||||
};
|
||||
|
||||
type HandlerMap = Partial<Record<TurnDaemonCommand['type'], (command: TurnDaemonCommand) => Promise<TurnDaemonCommandResult>>>;
|
||||
type HandlerMap = Partial<
|
||||
Record<TurnDaemonCommand['type'], (command: TurnDaemonCommand) => Promise<TurnDaemonCommandResult>>
|
||||
>;
|
||||
|
||||
const handlers: HandlerMap = {
|
||||
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
|
||||
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
|
||||
dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||
buildNationCandidate: (command) => handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
|
||||
instantRetreat: (command) => handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
|
||||
dieOnPrestart: (command) =>
|
||||
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||
buildNationCandidate: (command) =>
|
||||
handleBuildNationCandidate(ctx, command as Extract<TurnDaemonCommand, { type: 'buildNationCandidate' }>),
|
||||
instantRetreat: (command) =>
|
||||
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
|
||||
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
|
||||
setMySetting: (command) => handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
|
||||
setMySetting: (command) =>
|
||||
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
|
||||
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
|
||||
auctionFinalize: (command) => handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
|
||||
auctionFinalize: (command) =>
|
||||
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
|
||||
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
|
||||
changePermission: (command) => handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||
changePermission: (command) =>
|
||||
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
|
||||
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
|
||||
tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
||||
tournamentBettingPayout: (command) => handleTournamentBettingPayout(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>),
|
||||
tournamentReward: (command) => handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
|
||||
tournamentRefund: (command) =>
|
||||
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
||||
tournamentBettingPayout: (command) =>
|
||||
handleTournamentBettingPayout(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>
|
||||
),
|
||||
tournamentReward: (command) =>
|
||||
handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
|
||||
voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
|
||||
setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
|
||||
adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>),
|
||||
adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
|
||||
setNationMeta: (command) =>
|
||||
handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
|
||||
adjustGeneralResources: (command) =>
|
||||
handleAdjustGeneralResources(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||
),
|
||||
adjustGeneralMeta: (command) =>
|
||||
handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
|
||||
tournamentMatchResult: (command) =>
|
||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||
patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
patchGeneral: (command) =>
|
||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
};
|
||||
|
||||
return {
|
||||
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
|
||||
handle: async (
|
||||
command,
|
||||
executionContext?: TurnDaemonCommandExecutionContext
|
||||
): Promise<TurnDaemonCommandResult | null> => {
|
||||
const handler = handlers[command.type];
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
return handler(command);
|
||||
ctx.commandDb = executionContext?.db;
|
||||
try {
|
||||
return await handler(command);
|
||||
} finally {
|
||||
ctx.commandDb = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user