feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가

This commit is contained in:
2026-01-04 16:05:22 +00:00
parent b2a625d4e7
commit 2753b85a26
12 changed files with 716 additions and 63 deletions
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonStatus,
} from './types.js';
import type { TurnDaemonTransport } from './transport.js';
@@ -18,6 +19,7 @@ const buildDefaultStatus = (): TurnDaemonStatus => ({
export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
public readonly commands: TurnDaemonCommandEnvelope[] = [];
private status: TurnDaemonStatus;
private readonly results = new Map<string, TurnDaemonCommandResult>();
constructor(initialStatus: TurnDaemonStatus = buildDefaultStatus()) {
this.status = initialStatus;
@@ -34,6 +36,14 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
return requestId;
}
async requestCommand(
command: TurnDaemonCommand,
_timeoutMs?: number
): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
return this.results.get(requestId) ?? null;
}
async requestStatus(): Promise<TurnDaemonStatus> {
return this.status;
}
@@ -41,4 +51,8 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
setStatus(status: TurnDaemonStatus): void {
this.status = status;
}
setCommandResult(requestId: string, result: TurnDaemonCommandResult): void {
this.results.set(requestId, result);
}
}
+45
View File
@@ -5,6 +5,7 @@ import type { TurnDaemonTransport } from './transport.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonEventEnvelope,
TurnDaemonStatus,
} from './types.js';
@@ -75,6 +76,50 @@ export class RedisTurnDaemonTransport implements TurnDaemonTransport {
return envelope.requestId;
}
async requestCommand(
command: TurnDaemonCommand,
timeoutMs?: number
): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '$';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (
envelope.event.type === 'commandResult' &&
envelope.requestId === requestId
) {
return envelope.event.result;
}
}
}
}
return null;
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = randomUUID();
await this.sendCommand({ type: 'getStatus', requestId });
+9 -1
View File
@@ -1,6 +1,14 @@
import type { TurnDaemonCommand, TurnDaemonStatus } from './types.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandResult,
TurnDaemonStatus,
} from './types.js';
export interface TurnDaemonTransport {
sendCommand(command: TurnDaemonCommand): Promise<string>;
requestCommand(
command: TurnDaemonCommand,
timeoutMs?: number
): Promise<TurnDaemonCommandResult | null>;
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
}
+35 -2
View File
@@ -38,19 +38,52 @@ export interface TurnDaemonStatus {
checkpoint?: TurnCheckpoint;
}
export type TurnDaemonMutationCommand =
| { type: 'troopJoin'; generalId: number; troopId: number }
| { type: 'troopExit'; generalId: number };
// 턴 데몬 제어 요청은 Redis 스트림으로 전달한다.
export type TurnDaemonCommand =
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
| { type: 'pause'; reason?: string }
| { type: 'resume'; reason?: string }
| { type: 'getStatus'; requestId: string };
| { type: 'getStatus'; requestId: string }
| TurnDaemonMutationCommand;
export type TurnDaemonCommandResult =
| {
type: 'troopJoin';
ok: true;
generalId: number;
troopId: number;
}
| {
type: 'troopJoin';
ok: false;
generalId: number;
troopId: number;
reason: string;
}
| {
type: 'troopExit';
ok: true;
generalId: number;
wasLeader: boolean;
}
| {
type: 'troopExit';
ok: false;
generalId: number;
reason: string;
};
// 턴 데몬 이벤트는 상태/실행 결과를 API 서버에 알려준다.
export type TurnDaemonEvent =
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
| { type: 'runStarted'; at: string; reason: RunReason }
| { type: 'runCompleted'; at: string; result: TurnRunResult }
| { type: 'runFailed'; at: string; error: string };
| { type: 'runFailed'; at: string; error: string }
| { type: 'commandResult'; result: TurnDaemonCommandResult };
export interface TurnDaemonCommandEnvelope {
requestId: string;
+30 -54
View File
@@ -634,42 +634,29 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: input.generalId,
troopId: input.troopId,
});
if (!general) {
if (!result) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (general.troopId !== 0) {
if (result.type !== 'troopJoin') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Already in a troop.',
message: result.reason,
});
}
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'General is not part of a nation.',
});
}
const troop = await ctx.db.troop.findUnique({
where: { troopLeaderId: input.troopId },
});
if (!troop || troop.nationId !== general.nationId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Troop is invalid.',
});
}
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: input.troopId },
});
return { ok: true };
}),
@@ -680,41 +667,30 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
generalId: input.generalId,
});
if (!general) {
if (!result) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (general.troopId === 0) {
if (result.type !== 'troopExit') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Not in a troop.',
message: result.reason,
});
}
if (general.troopId !== general.id) {
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: 0 },
});
return { ok: true, wasLeader: false };
}
await ctx.db.$transaction([
ctx.db.general.updateMany({
where: { troopId: general.troopId },
data: { troopId: 0 },
}),
ctx.db.troop.deleteMany({
where: { troopLeaderId: general.troopId },
}),
]);
return { ok: true, wasLeader: true };
return { ok: true, wasLeader: result.wasLeader };
}),
}),
turnDaemon: router({