feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user